codecombat/app/core/deltas.coffee

181 lines
No EOL
6.9 KiB
CoffeeScript

SystemNameLoader = require './../core/SystemNameLoader'
###
Good-to-knows:
dataPath: an array of keys that walks you up a JSON object that's being patched
ex: ['scripts', 0, 'description']
deltaPath: an array of keys that walks you up a JSON Diff Patch object.
ex: ['scripts', '_0', 'description']
###
module.exports.expandDelta = (delta, left, schema) ->
flattenedDeltas = flattenDelta(delta)
(expandFlattenedDelta(fd, left, schema) for fd in flattenedDeltas)
module.exports.flattenDelta = flattenDelta = (delta, dataPath=null, deltaPath=null) ->
# takes a single jsondiffpatch delta and returns an array of objects with
return [] unless delta
dataPath ?= []
deltaPath ?= []
return [{dataPath: dataPath, deltaPath: deltaPath, o: delta}] if _.isArray delta
results = []
affectingArray = delta._t is 'a'
for deltaIndex, childDelta of delta
continue if deltaIndex is '_t'
dataIndex = if affectingArray then parseInt(deltaIndex.replace('_', '')) else deltaIndex
results = results.concat flattenDelta(
childDelta, dataPath.concat([dataIndex]), deltaPath.concat([deltaIndex]))
results
expandFlattenedDelta = (delta, left, schema) ->
# takes a single flattened delta and converts into an object that can be
# easily formatted into something human readable.
delta.action = '???'
o = delta.o # the raw jsondiffpatch delta
if _.isArray(o) and o.length is 1
delta.action = 'added'
delta.newValue = o[0]
if _.isArray(o) and o.length is 2
delta.action = 'modified'
delta.oldValue = o[0]
delta.newValue = o[1]
if _.isArray(o) and o.length is 3 and o[1] is 0 and o[2] is 0
delta.action = 'deleted'
delta.oldValue = o[0]
if _.isPlainObject(o) and o._t is 'a'
delta.action = 'modified-array'
if _.isPlainObject(o) and o._t isnt 'a'
delta.action = 'modified-object'
if _.isArray(o) and o.length is 3 and o[2] is 3
delta.action = 'moved-index'
delta.destinationIndex = o[1]
delta.originalIndex = delta.dataPath[delta.dataPath.length-1]
if _.isArray(o) and o.length is 3 and o[1] is 0 and o[2] is 2
delta.action = 'text-diff'
delta.unidiff = o[0]
humanPath = []
parentLeft = left
parentSchema = schema
for key, i in delta.dataPath
# TODO: Better schema/json walking
childSchema = parentSchema?.items or parentSchema?.properties?[key] or {}
childLeft = parentLeft?[key]
humanKey = null
childData = if i is delta.dataPath.length-1 and delta.action is 'added' then o[0] else childLeft
humanKey ?= childData.name or childData.id if childData
humanKey ?= SystemNameLoader.getName(childData?.original)
humanKey ?= "#{childSchema.title}" if childSchema.title
humanKey ?= _.string.titleize key
humanPath.push humanKey
parentLeft = childLeft
parentSchema = childSchema
delta.humanPath = humanPath.join(' :: ')
delta.schema = childSchema
delta.left = childLeft
delta.right = jsondiffpatch.patch childLeft, delta.o unless delta.action is 'moved-index'
delta
module.exports.makeJSONDiffer = ->
hasher = (obj) -> if obj? then obj.name or obj.id or obj._id or JSON.stringify(_.keys(obj)) else 'null'
jsondiffpatch.create({objectHash: hasher})
module.exports.getConflicts = (headDeltas, pendingDeltas) ->
# headDeltas and pendingDeltas should be lists of deltas returned by expandDelta
# Returns a list of conflict objects with properties:
# headDelta
# pendingDelta
# The deltas that have conflicts also have conflict properties pointing to one another.
headPathMap = groupDeltasByAffectingPaths(headDeltas)
pendingPathMap = groupDeltasByAffectingPaths(pendingDeltas)
paths = _.keys(headPathMap).concat(_.keys(pendingPathMap))
# Here's my thinking: conflicts happen when one delta path is a substring of another delta path
# So, sort paths from both deltas together, which will naturally make conflicts adjacent,
# and if one is identified AND one path is from the headDeltas AND the other is from pendingDeltas
# This is all to avoid an O(nm) brute force search.
conflicts = []
paths.sort()
for path, i in paths
offset = 1
while i + offset < paths.length
# Look at the neighbor
nextPath = paths[i+offset]
offset += 1
# these stop being substrings of each other? Then conflict DNE
if not (_.string.startsWith nextPath, path) then break
# check if these two are from the same group, but we still need to check for more beyond
unless headPathMap[path] or headPathMap[nextPath] then continue
unless pendingPathMap[path] or pendingPathMap[nextPath] then continue
# Okay, we found two deltas from different groups which conflict
for headMetaDelta in (headPathMap[path] or headPathMap[nextPath])
headDelta = headMetaDelta.delta
for pendingMetaDelta in (pendingPathMap[path] or pendingPathMap[nextPath])
pendingDelta = pendingMetaDelta.delta
conflicts.push({headDelta: headDelta, pendingDelta: pendingDelta})
pendingDelta.conflict = headDelta
headDelta.conflict = pendingDelta
return conflicts if conflicts.length
groupDeltasByAffectingPaths = (deltas) ->
metaDeltas = []
for delta in deltas
conflictPaths = []
# We're being fairly liberal with what's a conflict, because the alternative is worse
if delta.action is 'moved-index'
# If you moved items around in an array, mark the whole array as a gonner
conflictPaths.push delta.dataPath.slice(0, delta.dataPath.length-1)
else if delta.action in ['deleted', 'added'] and _.isNumber(delta.dataPath[delta.dataPath.length-1])
# If you remove or add items in an array, mark the whole thing as a gonner
conflictPaths.push delta.dataPath.slice(0, delta.dataPath.length-1)
else
conflictPaths.push delta.dataPath
for path in conflictPaths
metaDeltas.push {
delta: delta
path: (item.toString() for item in path).join('/')
}
map = _.groupBy metaDeltas, 'path'
return map
module.exports.pruneConflictsFromDelta = (delta, conflicts) ->
expandedDeltas = (conflict.pendingDelta for conflict in conflicts)
module.exports.pruneExpandedDeltasFromDelta delta, expandedDeltas
module.exports.pruneExpandedDeltasFromDelta = (delta, expandedDeltas) ->
# the jsondiffpatch delta mustn't include any dangling nodes,
# or else things will get removed which shouldn't be, or errors will occur
for expandedDelta in expandedDeltas
prunePath delta, expandedDelta.deltaPath
if _.isEmpty delta then undefined else delta
prunePath = (delta, path) ->
if path.length is 1
delete delta[path] unless delta[path] is undefined
else
prunePath delta[path[0]], path.slice(1) unless delta[path[0]] is undefined
keys = (k for k in _.keys(delta[path[0]]) when k isnt '_t')
delete delta[path[0]] if keys.length is 0
module.exports.DOC_SKIP_PATHS = [
'_id','version', 'commitMessage', 'parent', 'created',
'slug', 'index', '__v', 'patches', 'creator', 'js', 'watchers']