codecombat/app/lib/deltas.coffee

178 lines
6.6 KiB
CoffeeScript
Raw Normal View History

2014-04-18 16:02:14 -04:00
SystemNameLoader = require 'lib/SystemNameLoader'
2014-06-30 22:16:26 -04:00
###
2014-04-12 00:11:52 -04:00
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']
###
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
module.exports.expandDelta = (delta, left, schema) ->
flattenedDeltas = flattenDelta(delta)
(expandFlattenedDelta(fd, left, schema) for fd in flattenedDeltas)
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
flattenDelta = (delta, dataPath=null, deltaPath=null) ->
# takes a single jsondiffpatch delta and returns an array of objects with
return [] unless delta
dataPath ?= []
deltaPath ?= []
2014-06-30 22:16:26 -04:00
return [{dataPath: dataPath, deltaPath: deltaPath, o: delta}] if _.isArray delta
2014-04-12 00:11:52 -04:00
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
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
expandFlattenedDelta = (delta, left, schema) ->
# takes a single flattened delta and converts into an object that can be
# easily formatted into something human readable.
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
delta.action = '???'
o = delta.o # the raw jsondiffpatch delta
2014-04-12 00:11:52 -04:00
if _.isArray(o) and o.length is 1
delta.action = 'added'
delta.newValue = o[0]
2014-04-12 00:11:52 -04:00
if _.isArray(o) and o.length is 2
delta.action = 'modified'
delta.oldValue = o[0]
delta.newValue = o[1]
2014-04-12 00:11:52 -04:00
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]
2014-04-12 00:11:52 -04:00
if _.isPlainObject(o) and o._t is 'a'
delta.action = 'modified-array'
2014-04-12 00:11:52 -04:00
if _.isPlainObject(o) and o._t isnt 'a'
delta.action = 'modified-object'
2014-04-14 14:14:09 -04:00
if _.isArray(o) and o.length is 3 and o[2] is 3
2014-04-12 00:11:52 -04:00
delta.action = 'moved-index'
delta.destinationIndex = o[1]
delta.originalIndex = delta.dataPath[delta.dataPath.length-1]
2014-04-12 00:11:52 -04:00
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]
2014-04-12 00:11:52 -04:00
humanPath = []
parentLeft = left
parentSchema = schema
2014-04-12 00:11:52 -04:00
for key, i in delta.dataPath
# TODO: Better schema/json walking
childSchema = parentSchema?.items or parentSchema?.properties?[key] or {}
childLeft = parentLeft?[key]
2014-04-12 00:11:52 -04:00
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
2014-04-18 16:02:14 -04:00
humanKey ?= SystemNameLoader.getName(childData?.original)
2014-04-12 00:11:52 -04:00
humanKey ?= "#{childSchema.title}" if childSchema.title
humanKey ?= _.string.titleize key
humanPath.push humanKey
parentLeft = childLeft
parentSchema = childSchema
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
delta.humanPath = humanPath.join(' :: ')
delta.schema = childSchema
delta.left = childLeft
delta.right = jsondiffpatch.patch childLeft, delta.o unless delta.action is 'moved-index'
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
delta
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
module.exports.makeJSONDiffer = ->
hasher = (obj) -> obj.name || obj.id || obj._id || JSON.stringify(_.keys(obj))
2014-06-30 22:16:26 -04:00
jsondiffpatch.create({objectHash: hasher})
2014-04-12 00:11:52 -04:00
module.exports.getConflicts = (headDeltas, pendingDeltas) ->
# headDeltas and pendingDeltas should be lists of deltas returned by expandDelta
2014-04-12 00:11:52 -04:00
# Returns a list of conflict objects with properties:
# headDelta
# pendingDelta
# The deltas that have conflicts also have conflict properties pointing to one another.
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
headPathMap = groupDeltasByAffectingPaths(headDeltas)
pendingPathMap = groupDeltasByAffectingPaths(pendingDeltas)
paths = _.keys(headPathMap).concat(_.keys(pendingPathMap))
2014-06-30 22:16:26 -04:00
# 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
2014-04-12 00:11:52 -04:00
# This is all to avoid an O(nm) brute force search.
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
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
2014-07-04 10:36:23 -04:00
# these stop being substrings of each other? Then conflict DNE
if not (nextPath.startsWith path) then break
2014-07-04 10:36:23 -04:00
# 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
2014-07-04 10:36:23 -04:00
conflicts.push({headDelta: headDelta, pendingDelta: pendingDelta})
pendingDelta.conflict = headDelta
headDelta.conflict = pendingDelta
2014-04-12 00:11:52 -04:00
return conflicts if conflicts.length
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
groupDeltasByAffectingPaths = (deltas) ->
metaDeltas = []
for delta in deltas
conflictPaths = []
# We're being fairly liberal with what's a conflict, because the alternative is worse
2014-04-12 00:11:52 -04:00
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)
2014-04-12 00:11:52 -04:00
else
conflictPaths.push delta.dataPath
for path in conflictPaths
metaDeltas.push {
delta: delta
path: (item.toString() for item in path).join('/')
}
2014-06-30 22:16:26 -04:00
map = _.groupBy metaDeltas, 'path'
return map
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
module.exports.pruneConflictsFromDelta = (delta, conflicts) ->
expandedDeltas = (conflict.pendingDelta for conflict in conflicts)
module.exports.pruneExpandedDeltasFromDelta delta, expandedDeltas
2014-06-30 22:16:26 -04:00
module.exports.pruneExpandedDeltasFromDelta = (delta, expandedDeltas) ->
2014-04-12 00:11:52 -04:00
# 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
2014-04-12 00:11:52 -04:00
if _.isEmpty delta then undefined else delta
2014-06-30 22:16:26 -04:00
2014-04-12 00:11:52 -04:00
prunePath = (delta, path) ->
if path.length is 1
delete delta[path]
else
prunePath delta[path[0]], path.slice(1)
keys = (k for k in _.keys(delta[path[0]]) when k isnt '_t')
2014-04-18 16:02:14 -04:00
delete delta[path[0]] if keys.length is 0