Browse Source

use new tandem lib

Jason Chen 12 years ago
parent
commit
a449194419
3 changed files with 516 additions and 480 deletions
  1. 1 0
      .gitignore
  2. 0 480
      lib/jetsync.coffee
  3. 515 0
      lib/tandem.coffee

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@ build/
 .DS_Store
 lib/jetsync.js
 node_modules/
+lib/tandem.js

+ 0 - 480
lib/jetsync.coffee

@@ -1,480 +0,0 @@
-# This library contains the synchronization code for clients' edits.
-dmp = new diff_match_patch
-diff_match_patch.DIFF_DELETE = -1
-diff_match_patch.DIFF_INSERT = 1
-diff_match_patch.DIFF_EQUAL = 0
-
-class JetDeltaItem
-  constructor: (@attributes = {})
-
-  addAttributes: (attributes) ->
-    addedAttributes = {}
-    for key, value of attributes
-      if @attributes[key] == undefined
-        addedAttributes[key] = value
-    return addedAttributes
-
-  attributesMatch: (other) ->
-    otherAttributes = other.attributes || {}
-    return _.isEqual(@attributes, otherAttributes)
-
-  composeAttributes: (attributes) ->
-    return if !attributes?
-    for key, value of attributes
-      if JetDelta.isInsert(this) && value == null
-        delete @attributes[key]
-      else if typeof value != 'undefined'
-        @attributes[key] = value
-
-  numAttributes: () ->
-    _.keys(@attributes).length
-
-  toString: ->
-    attr_str = ""
-    for key,value of @attributes
-      attr_str += "#{key}: #{value}, "
-    return "{#{attr_str}}"
-
-# Used to represent retains in the delta. [inclusive, exclusive)
-class JetRetain extends JetDeltaItem
-  constructor: (@start, @end, @attributes = {}) ->
-    console.assert(@start >= 0, "JetRetain start cannot be negative!", @start)
-    console.assert(@end >= @start, "JetRetain end must be >= start!", @start, @end)
-
-  @copy: (subject) ->
-    console.assert(JetRetain.isRetain(subject), "Copy called on non-retain", subject)
-    attributes = _.clone(subject.attributes)
-    return new JetRetain(subject.start, subject.end, attributes)
-
-  # True if this retain contains part or all of other.
-  contains: (other) ->
-    return @start <= other.start and @end >= other.start
-
-  getLength: ->
-    return @end - @start
-
-  toString: ->
-    return "{{#{@start} - #{@end}), #{super()}}"
-
-  @isRetain: (r) ->
-    return r? && typeof r.start == "number" && typeof r.end == "number"
-
-
-class JetInsert extends JetDeltaItem
-  constructor: (@text, @attributes = {}) ->
-    # console.assert(@text.length > 0)
-
-  @copy: (subject) ->
-    attributes = _.clone(subject.attributes)
-    return new JetInsert(subject.text, attributes)
-
-  getLength: ->
-    return @text.length
-
-  toString: ->
-    return "{#{@text}, #{super()}}"
-
-  @isInsert: (i) ->
-    return i? && typeof i.text == "string"
-
-
-class JetDelta
-  constructor: (@startLength, @endLength, @deltas, skipNormalizing = false) ->
-    if !skipNormalizing
-      this.normalizeChanges()
-      length = 0
-      for delta in @deltas
-        length += delta.getLength()
-      console.assert(length == @endLength, "Given end length is incorrect", this)
-
-  isIdentity: ->
-    if @startLength == @endLength
-      if @deltas.length == 0
-        return true
-      index = 0
-      for delta in @deltas
-        if !JetRetain.isRetain(delta) then return false
-        if delta.start != index then return false
-        if !(delta.numAttributes() == 0 || (delta.numAttributes() == 1 && _.has(delta.attributes, 'authorId')))
-          return false
-        index = delta.end
-      if index != @endLength then return false
-      return true
-    return false
-
-  normalizeChanges: ->
-    return if @deltas.length == 0
-    for i in [0..@deltas.length - 1]
-      switch typeof @deltas[i]
-        when 'string' then @deltas[i] = new JetInsert(@deltas[i])
-        when 'number' then @deltas[i] = new JetRetain(@deltas[i], @deltas[i] + 1)
-        when 'object'
-          if @deltas[i].text?
-            @deltas[i] = new JetInsert(@deltas[i].text, @deltas[i].attributes)
-          else if @deltas[i].start? && @deltas[i].end?
-            @deltas[i] = new JetRetain(@deltas[i].start, @deltas[i].end, @deltas[i].attributes)
-      @deltas[i].attributes = {} unless @deltas[i].attributes?
-    @deltas = _.reject(@deltas, (delta) -> delta.getLength() == 0)
-
-  compact: ->
-    this.normalizeChanges()
-    compacted = []
-    for delta in @deltas
-      if compacted.length == 0
-        compacted.push(delta) unless JetRetain.isRetain(delta) && delta.start == delta.end
-      else
-        if JetRetain.isRetain(delta) && delta.start == delta.end
-          continue
-        last = _.last(compacted)
-        if JetDelta.isInsert(last) && JetDelta.isInsert(delta) && last.attributesMatch(delta)
-          # If two neighboring inserts, combine
-          last.text = last.text + delta.text
-        else if JetRetain.isRetain(last) && JetRetain.isRetain(delta) && last.end == delta.start && last.attributesMatch(delta)
-          # If two neighboring ranges first's end + 1 == second's start, combine
-          last.end = delta.end
-        else
-          # Cannot coalesce with previous
-          compacted.push(delta)
-    @deltas = compacted
-
-  getDeltasAt: (start, length) ->
-    changes = []
-    index = 0
-    if typeof length == 'undefined'
-      if typeof start == 'number'
-        range = new JetRetain(start, start + 1)
-      else
-        range = JetRetain.copy(start)
-    else
-      range = new JetRetain(start, start + length)
-    for delta in @deltas
-      if range.start == range.end then break
-      console.assert(JetDelta.isRetain(delta) || JetDelta.isInsert(delta), "Invalid change in delta", this)
-      length = delta.getLength()
-      if index <= range.start && range.start < index + length
-        start = Math.max(index, range.start)
-        end = Math.min(index + length, range.end)
-        if JetDelta.isInsert(delta)
-          changes.push(new JetInsert(delta.text.substring(start - index, end -
-            index), _.clone(delta.attributes)))
-        else
-          changes.push(new JetRetain(start - index + delta.start, end - index +
-            delta.start, _.clone(delta.attributes)))
-        range.start = end
-      index += length
-    return changes
-
-  getDocIndex: (elemIndex) ->
-    return _.reduce(@deltas.slice(0, elemIndex), (length, elem) ->
-      return length + elem.getLength()
-    , 0)
-
-  getElemIndexAndOffset: (docIndex) ->
-    console.assert docIndex < @endLength, "docIndex of length #{docIndex} is
-      greater than or equal to delta length #{@endLength}"
-    count = 0
-    elemIndex = 0
-    for elem in @deltas
-      if count + elem.getLength() > docIndex
-        offset = docIndex - count
-        return [elemIndex, offset]
-      elemIndex += 1
-      count += elem.getLength()
-
-  @copy: (subject) ->
-    changes = []
-    for delta in subject.deltas
-      if JetDelta.isRetain(delta)
-        changes.push(JetRetain.copy(delta))
-      else
-        changes.push(JetInsert.copy(delta))
-    return new JetDelta(subject.startLength, subject.endLength, changes, true)
-
-  @getInitial: (contents) ->
-    return new JetDelta(0, contents.length, [new JetInsert(contents)])
-
-  @getIdentity: (length) ->
-    delta = new JetDelta(length, length, [new JetRetain(0, length)])
-    return delta
-
-  @isDelta: (delta) ->
-    if (delta? && typeof delta == "object" && typeof delta.startLength == "number" &&
-        typeof delta.endLength == "number" && typeof delta.deltas == "object")
-      for delta in delta.deltas
-        if !JetDelta.isRetain(delta) && !JetDelta.isInsert(delta)
-          return false
-      return true
-    return false
-
-  @makeDelta: (obj, skipNormalizing = false) ->
-    return new JetDelta(obj.startLength, obj.endLength, obj.deltas, skipNormalizing)
-
-  @isInsert: (change) ->
-    return JetInsert.isInsert(change)
-
-  @isRetain: (change) ->
-    return JetRetain.isRetain(change) || typeof(change) == "number"
-
-  toString: ->
-    return "{(#{@startLength}->#{@endLength})[#{@deltas.join(', ')}]}"
-
-JetSync =
-  # Inserts in deltaB are given priority. Retains in deltaB are indexes into A,
-  # and we take whatever is there (insert or retain).
-  compose: (deltaA, deltaB) ->
-    console.assert(JetDelta.isDelta(deltaA), "Compose called when deltaA is not a JetDelta, type: " + typeof deltaA)
-    console.assert(JetDelta.isDelta(deltaB), "Compose called when deltaB is not a JetDelta, type: " + typeof deltaB)
-    console.assert(deltaA.endLength == deltaB.startLength, "startLength #{deltaB.startLength} / endlength #{deltaA.endLength} mismatch")
-
-    deltaA = JetDelta.copy(deltaA)
-    deltaB = JetDelta.copy(deltaB)
-    deltaA.normalizeChanges()
-    deltaB.normalizeChanges()
-
-    composed = []
-    for elem in deltaB.deltas
-      elem = new JetInsert(elem) if typeof elem == 'string'
-      if JetDelta.isInsert(elem)
-        composed.push(elem)
-      else if JetDelta.isRetain(elem)
-        deltasInRange = deltaA.getDeltasAt(elem)
-        for delta in deltasInRange
-          delta.composeAttributes(elem.attributes)
-        composed = composed.concat(deltasInRange)
-      else
-        console.assert(false, "Invalid delta in deltaB when composing", deltaB)
-    deltaC = new JetDelta(deltaA.startLength, deltaB.endLength, composed)
-    deltaC.compact()
-    console.assert(JetDelta.isDelta(deltaC), "Composed returning invalid JetDelta", deltaC)
-    return deltaC
-
-  # For each element in deltaC, compare it to the current element in deltaA in
-  # order to construct deltaB. Given A and C, there is more than one valid B.
-  # Its impossible to guarantee that decompose yields the actual B that was
-  # used in the original composition. However, the function is deterministic in
-  # which of the possible B's it chooses. How it works:
-  # 1. Inserts in deltaC are matched against the current elem in deltaA. If
-  #    there is a match, we create a corresponding retain in deltaB. Otherwise,
-  #    we create an insertion in deltaB.
-  # 2. Retains in deltaC become retains in deltaB, which reference the original
-  #    retain in deltaA.
-  decompose: (deltaA, deltaC) ->
-    console.assert(JetDelta.isDelta(deltaA), "Decompose2 called when deltaA is not a JetDelta, type: " + typeof deltaA)
-    console.assert(JetDelta.isDelta(deltaC), "Decompose2 called when deltaC is not a JetDelta, type: " + typeof deltaC)
-    console.assert(deltaA.startLength == deltaC.startLength, "startLength #{deltaA.startLength} / startLength #{deltaC.startLength} mismatch")
-    console.assert(_.all(deltaA.deltas, ((delta) -> return delta.text?)), "DeltA has retain in decompose")
-    console.assert(_.all(deltaC.deltas, ((delta) -> return delta.text?)), "DeltC has retain in decompose")
-
-
-    decomposeAttributes = (attrA, attrC) ->
-      decomposedAttributes = {}
-      for key, value of attrC
-        if attrA[key] == undefined or attrA[key] != value
-          decomposedAttributes[key] = value
-      for key, value of attrA
-        if attrC[key] == undefined
-          decomposedAttributes[key] = null
-      return decomposedAttributes
-
-    deltaToText = (delta) ->
-      return _.map(delta.deltas, (delta) ->
-        return if delta.text? then delta.text else ""
-      ).join('')
-
-    diffTexts = (oldText, newText) ->
-      diff = dmp.diff_main(oldText, newText)
-      if (diff.length > 2)
-        dmp.diff_cleanupSemantic(diff)
-        dmp.diff_cleanupEfficiency(diff)
-      return diff
-
-    diffToDelta = (diff) ->
-      console.assert(diff.length > 0, "diffToDelta called with diff with length <= 0")
-      originalLength = 0
-      finalLength = 0
-      deltas = []
-      # For each difference apply them separately so we do not disrupt the cursor
-      for [operation, value] in diff
-        switch operation
-          when diff_match_patch.DIFF_DELETE
-            # Deletes implied
-            originalLength += value.length
-          when diff_match_patch.DIFF_INSERT
-            deltas.push(new JetInsert(value))
-            finalLength += value.length
-          when diff_match_patch.DIFF_EQUAL
-            deltas.push(new JetRetain(originalLength, originalLength + value.length))
-            originalLength += value.length
-            finalLength += value.length
-      return new JetDelta(originalLength, finalLength, deltas)
-
-    textA = deltaToText(deltaA)
-    textC = deltaToText(deltaC)
-    unless textA == '' and textC == ''
-      diff = diffTexts(textA, textC)
-      insertDelta = diffToDelta(diff)
-    else
-      insertDelta = new JetDelta(0, 0, [])
-    deltas = []
-    offset = 0
-    _.each(insertDelta.deltas, (delta) ->
-      deltasInC = deltaC.getDeltasAt(offset, delta.getLength())
-      offsetC = 0
-      _.each(deltasInC, (deltaInC) ->
-        if JetDelta.isInsert(delta)
-          d = new JetInsert(delta.text.substring(offsetC, offsetC + deltaInC.getLength()), deltaInC.attributes)
-          deltas.push(d)
-        else if JetDelta.isRetain(delta)
-          deltasInA = deltaA.getDeltasAt(delta.start + offsetC, deltaInC.getLength())
-          offsetA = 0
-          _.each(deltasInA, (deltaInA) ->
-            attributes = decomposeAttributes(deltaInA.attributes, deltaInC.attributes)
-            start = delta.start + offsetA + offsetC
-            e = new JetRetain(start, start + deltaInA.getLength(), attributes)
-            deltas.push(e)
-            offsetA += deltaInA.getLength()
-          )
-        else
-          console.error("Invalid delta in deltaB when composing", deltaB)
-        offsetC += deltaInC.getLength()
-      )
-      offset += delta.getLength()
-    )
-
-    deltaB = new JetDelta(insertDelta.startLength, insertDelta.endLength, deltas)
-    deltaB.compact()
-    return deltaB
-
-
-
-  # We compute the follow according to the following rules:
-  # 1. Insertions in deltaA become retained characters in the follow set
-  # 2. Insertions in deltaB become inserted characters in the follow set
-  # 3. Characters retained in deltaA and deltaB become retained characters in
-  #    the follow set
-  follows: (deltaA, deltaB, aIsRemote) ->
-    console.assert(JetDelta.isDelta(deltaA), "Follows called when deltaA is not a JetDelta, type: " + typeof deltaA, deltaA)
-    console.assert(JetDelta.isDelta(deltaB), "Follows called when deltaB is not a JetDelta, type: " + typeof deltaB, deltaB)
-    console.assert(aIsRemote?, "Remote delta not specified")
-
-    deltaA = JetDelta.copy(deltaA)
-    deltaB = JetDelta.copy(deltaB)
-    deltaA.normalizeChanges()
-    deltaB.normalizeChanges()
-    followStartLength = deltaA.endLength
-    followSet = []
-    indexA = indexB = 0 # Tracks character offset in the 'document'
-    elemIndexA = elemIndexB = 0 # Tracks offset into the deltas list
-    while elemIndexA < deltaA.deltas.length and elemIndexB < deltaB.deltas.length
-      elemA = deltaA.deltas[elemIndexA]
-      elemB = deltaB.deltas[elemIndexB]
-
-      if JetDelta.isInsert(elemA) and JetDelta.isInsert(elemB)
-        length = Math.min(elemA.getLength(), elemB.getLength())
-        if aIsRemote
-          followSet.push(new JetRetain(indexA, indexA + length))
-          indexA += length
-          if length == elemA.getLength()
-            elemIndexA++
-          else
-            console.assert(length < elemA.getLength())
-            deltaA.deltas[elemIndexA] = new JetInsert(elemA.text.substring(length), elemA.attributes)
-        else
-          followSet.push(new JetInsert(elemB.text.substring(0, length), elemB.attributes))
-          indexB += length
-          if length == elemB.getLength()
-            elemIndexB++
-          else
-            deltaB.deltas[elemIndexB] = new JetInsert(elemB.text.substring(length), elemB.attributes)
-
-      else if JetDelta.isRetain(elemA) and JetDelta.isRetain(elemB)
-        if elemA.end < elemB.start
-          # Not a match, can't save. Throw away lower and adv.
-          indexA += elemA.getLength()
-          elemIndexA++
-        else if elemB.end < elemA.start
-          # Not a match, can't save. Throw away lower and adv.
-          indexB += elemB.getLength()
-          elemIndexB++
-        else
-          # A subrange or the entire range matches
-          if elemA.start < elemB.start
-            indexA += elemB.start - elemA.start
-            elemA = deltaA.deltas[elemIndexA] = new JetRetain(elemB.start, elemA.end, elemA.attributes)
-          else if elemB.start < elemA.start
-            indexB += elemA.start - elemB.start
-            elemB = deltaB.deltas[elemIndexB] = new JetRetain(elemA.start, elemB.end, elemB.attributes)
-
-          console.assert(elemA.start == elemB.start, "JetRetains must have same
-          start length when propagating into followset", elemA, elemB)
-          length = Math.min(elemA.end, elemB.end) - elemA.start
-          addedAttributes = elemA.addAttributes(elemB.attributes)
-          followSet.push(new JetRetain(indexA, indexA + length, addedAttributes)) # Keep the retain
-          indexA += length
-          indexB += length
-          if (elemA.end == elemB.end)
-            elemIndexA++
-            elemIndexB++
-          else if (elemA.end < elemB.end)
-            elemIndexA++
-            deltaB.deltas[elemIndexB] = new JetRetain(elemB.start + length, elemB.end, elemB.attributes)
-          else
-            deltaA.deltas[elemIndexA] = new JetRetain(elemA.start + length, elemA.end, elemA.attributes)
-            elemIndexB++
-
-      else if JetDelta.isInsert(elemA) and JetDelta.isRetain(elemB)
-        followSet.push(new JetRetain(indexA, indexA + elemA.getLength()))
-        indexA += elemA.getLength()
-        elemIndexA++
-      else if JetDelta.isRetain(elemA) and JetDelta.isInsert(elemB)
-        followSet.push(elemB)
-        indexB += elemB.getLength()
-        elemIndexB++
-      else
-        console.warn("Mismatch. elemA is: " + typeof(elemA) + ", elemB is:  " + typeof(elemB))
-
-    # Remaining loops account for different length deltas, only inserts will be
-    # accepted
-    while elemIndexA < deltaA.deltas.length
-      elemA = deltaA.deltas[elemIndexA]
-      followSet.push(new JetRetain(indexA, indexA + elemA.getLength())) if JetDelta.isInsert(elemA) # retain elemA
-      indexA += elemA.getLength()
-      elemIndexA++
-
-    while elemIndexB < deltaB.deltas.length
-      elemB = deltaB.deltas[elemIndexB]
-      followSet.push(elemB) if JetDelta.isInsert(elemB) # insert elemB
-      indexB += elemB.getLength()
-      elemIndexB++
-
-    followEndLength = 0
-    for elem in followSet
-      followEndLength += elem.getLength()
-
-    follow = new JetDelta(followStartLength, followEndLength, followSet, true)
-    follow.compact()
-    console.assert(JetDelta.isDelta(follow), "Follows returning invalid JetDelta", follow)
-    return follow
-
-  applyDeltaToText: (delta, text) ->
-    console.assert(text.length == delta.startLength, "Start length of delta: " + delta.startLength + " is not equal to the text: " + text.length)
-    appliedText = []
-    for elem in delta.deltas
-      if JetDelta.isInsert(elem)
-        appliedText.push(elem.text)
-      else
-        appliedText.push(text.substring(elem.start, elem.end))
-    result = appliedText.join("")
-    if delta.endLength != result.length
-      console.log "Delta", delta
-      console.log "text", text
-      console.log "result", result
-      console.assert(false, "End length of delta: " + delta.endLength + " is not equal to result text: " + result.length )
-    return result
-
-# Expose this code to other files
-root = if (typeof exports != "undefined" && exports != null) then exports else window
-root.JetRetain = JetRetain
-root.JetInsert = JetInsert
-root.JetDelta = JetDelta
-root.JetSync = JetSync

+ 515 - 0
lib/tandem.coffee

@@ -0,0 +1,515 @@
+# Tandem Operational Transform Engine - v0.1.0 - #2012-12-04
+# https://www.stypi.com/
+# Copyright (c) 2012
+# Byron Milligan, Salesforce.com
+# Jason Chen, Salesforce.com
+
+if require?
+  _ = require('underscore')._
+  diff_match_patch = require('../lib/diff_match_patch')
+  dmp = new diff_match_patch.diff_match_patch
+
+
+Tandem =
+  Op: class Op
+    constructor: (@attributes = {})
+
+    addAttributes: (attributes) ->
+      addedAttributes = {}
+      for key, value of attributes
+        if @attributes[key] == undefined
+          addedAttributes[key] = value
+      return addedAttributes
+
+    attributesMatch: (other) ->
+      otherAttributes = other.attributes || {}
+      return _.isEqual(@attributes, otherAttributes)
+
+    composeAttributes: (attributes) ->
+      that = this
+      resolveAttributes = (oldAttrs, newAttrs) ->
+        return oldAttrs if !newAttrs
+        resolvedAttrs = _.clone(oldAttrs)
+        for key, value of newAttrs
+          if Delta.isInsert(that) && value == null
+            delete resolvedAttrs[key]
+          else if typeof value != 'undefined'
+            if typeof resolvedAttrs[key] == 'object' and typeof value == 'object' and _.all([resolvedAttrs[key], newAttrs[key]], ((val) -> val != null))
+              resolvedAttrs[key] = resolveAttributes(resolvedAttrs[key], value)
+            else
+              resolvedAttrs[key] = value
+        return resolvedAttrs
+      return resolveAttributes(@attributes, attributes)
+
+    numAttributes: () ->
+      _.keys(@attributes).length
+
+    toString: ->
+      printAttrs = (attrs) ->
+        attr_str = ""
+        for key, value of @attributes
+          attr_str += key + ":"
+          if typeof value == 'object' and value != null
+            attr_str += "{" + printAttrs(value) + "},"
+          else
+            attr_str += value + ","
+        return "{" + attr_str + "}"
+      return printAttrs(@attributes)
+
+  # Used to represent retains in the delta. [inclusive, exclusive)
+  RetainOp: class RetainOp extends Op
+    constructor: (@start, @end, @attributes = {}) ->
+      console.assert(@start >= 0, "RetainOp start cannot be negative!", @start)
+      console.assert(@end >= @start, "RetainOp end must be >= start!", @start, @end)
+
+    @copy: (subject) ->
+      console.assert(RetainOp.isRetain(subject), "Copy called on non-retain", subject)
+      attributes = _.clone(subject.attributes)
+      return new RetainOp(subject.start, subject.end, attributes)
+
+    getLength: ->
+      return @end - @start
+
+    split: (offset) ->
+      console.assert(offset <= @end, "Split called with offset beyond end of retain")
+      left = new RetainOp(@start, @start + offset, @attributes)
+      right = new RetainOp(@start + offset, @end, @attributes)
+      return [left, right]
+
+    toString: ->
+      return "{{#{@start} - #{@end}), #{super()}}"
+
+    @isRetain: (r) ->
+      return r? && typeof r.start == "number" && typeof r.end == "number"
+
+
+  InsertOp: class InsertOp extends Op
+    constructor: (@value, @attributes = {}) ->
+
+    @copy: (subject) ->
+      attributes = _.clone(subject.attributes)
+      return new InsertOp(subject.value, attributes)
+
+    getAt: (start, length) ->
+      return new InsertOp(@value.substring(start, length), op.attributes)
+
+    getLength: ->
+      return @value.length
+
+    join: (other) ->
+      if _.isEqual(@attributes, other.attributes)
+        return new InsertOp(@value + second.value, @attributes)
+      else
+        throw Error
+
+    split: (offset) ->
+      console.assert(offset <= @value.length, "Split called with offset beyond end of insert")
+      left = new InsertOp(@value.substring(0, offset), @attributes)
+      right = new InsertOp(@value.substring(offset), @attributes)
+      return [left, right]
+
+    toString: ->
+      return "{#{@value}, #{super()}}"
+
+    @isInsert: (i) ->
+      return i? && typeof i.value == "string"
+
+  Delta: class Delta
+    constructor: (@startLength, @endLength, @ops, skipNormalizing = false) ->
+      if @ops == undefined
+        @ops = @endLength
+        @endLength = _.reduce(@ops, (len, op) ->
+          return len + op.getLength()
+        , 0)
+      else if typeof @ops == 'boolean'
+        skipNormalizing = @ops
+        @ops = @endLength
+        @endLength = _.reduce(@ops, (len, op) ->
+          return len + op.getLength()
+        , 0)
+      if !skipNormalizing
+        normalized = this.normalizeChanges()
+        @ops = normalized.ops
+        length = 0
+        for op in @ops
+          length += op.getLength()
+        console.assert(length == @endLength, "Given end length is incorrect", this)
+
+    isIdentity: ->
+      if @startLength == @endLength
+        if @ops.length == 0
+          return true
+        index = 0
+        for op in @ops
+          if !RetainOp.isRetain(op) then return false
+          if op.start != index then return false
+          if !(op.numAttributes() == 0 || (op.numAttributes() == 1 && _.has(op.attributes, 'authorId')))
+            return false
+          index = op.end
+        if index != @endLength then return false
+        return true
+      return false
+
+    normalizeChanges: ->
+      return Delta.copy(this) if @ops.length == 0
+      normalizedOps = []
+      for op in @ops
+        switch typeof op
+          when 'string' then normalizedOps.push(new InsertOp(op))
+          when 'number' then normalizedOps.push(new RetainOp(op, op + 1))
+          when 'object'
+            if op.value?
+              normalizedOps.push(new InsertOp(op.value, op.attributes))
+            else if op.start? && op.end?
+              normalizedOps.push(new RetainOp(op.start, op.end, op.attributes))
+      normalizedOps = _.reject(normalizedOps, (op) -> op.getLength() == 0)
+      return new Delta(this.startLength, this.endLength, normalizedOps, true)
+
+    compact: ->
+      normalized = this.normalizeChanges()
+      compacted = []
+      for op in normalized.ops
+        if compacted.length == 0
+          compacted.push(op) unless RetainOp.isRetain(op) && op.start == op.end
+        else
+          if RetainOp.isRetain(op) && op.start == op.end
+            continue
+          last = _.last(compacted)
+          if Delta.isInsert(last) && Delta.isInsert(op) && last.attributesMatch(op)
+            # If two neighboring inserts, combine
+            last.value = last.value + op.value
+          else if RetainOp.isRetain(last) && RetainOp.isRetain(op) && last.end == op.start && last.attributesMatch(op)
+            # If two neighboring ranges first's end + 1 == second's start, combine
+            last.end = op.end
+          else
+            # Cannot coalesce with previous
+            compacted.push(op)
+      return new Delta(this.startLength, this.endLength, compacted)
+
+    getOpsAt: (start, length) ->
+      changes = []
+      index = 0
+      if typeof length == 'undefined'
+        if typeof start == 'number'
+          range = new RetainOp(start, start + 1)
+        else
+          range = RetainOp.copy(start)
+      else
+        range = new RetainOp(start, start + length)
+      for op in @ops
+        if range.start == range.end then break
+        console.assert(Delta.isRetain(op) || Delta.isInsert(op), "Invalid change in op", this)
+        length = op.getLength()
+        if index <= range.start && range.start < index + length
+          start = Math.max(index, range.start)
+          end = Math.min(index + length, range.end)
+          if Delta.isInsert(op)
+            changes.push(new InsertOp(op.value.substring(start - index, end -
+              index), _.clone(op.attributes)))
+          else
+            changes.push(new RetainOp(start - index + op.start, end - index +
+              op.start, _.clone(op.attributes)))
+          range.start = end
+        index += length
+      return changes
+
+    @copy: (subject) ->
+      changes = []
+      for op in subject.ops
+        if Delta.isRetain(op)
+          changes.push(RetainOp.copy(op))
+        else
+          changes.push(InsertOp.copy(op))
+      return new Delta(subject.startLength, subject.endLength, changes, true)
+
+    @getInitial: (contents) ->
+      return new Delta(0, contents.length, [new InsertOp(contents)])
+
+    @getIdentity: (length) ->
+      delta = new Delta(length, length, [new RetainOp(0, length)])
+      return delta
+
+    @isDelta: (delta) ->
+      if (delta? && typeof delta == "object" && typeof delta.startLength == "number" &&
+          typeof delta.endLength == "number" && typeof delta.ops == "object")
+        for op in delta.ops
+          if !Delta.isRetain(op) && !Delta.isInsert(op)
+            return false
+        return true
+      return false
+
+    @makeDelta: (obj, skipNormalizing = false) ->
+      return new Delta(obj.startLength, obj.endLength, obj.ops, skipNormalizing)
+
+    @isInsert: (change) ->
+      return InsertOp.isInsert(change)
+
+    @isRetain: (change) ->
+      return RetainOp.isRetain(change) || typeof(change) == "number"
+
+    toString: ->
+      return "{(#{@startLength}->#{@endLength})[#{@ops.join(', ')}]}"
+
+    diff: (other) ->
+      diffToDelta = (diff) ->
+        console.assert(diff.length > 0, "diffToDelta called with diff with length <= 0")
+        originalLength = 0
+        finalLength = 0
+        ops = []
+        # For each difference apply them separately so we do not disrupt the cursor
+        for [operation, value] in diff
+          switch operation
+            when diff_match_patch.DIFF_DELETE
+              # Deletes implied
+              originalLength += value.length
+            when diff_match_patch.DIFF_INSERT
+              ops.push(new InsertOp(value))
+              finalLength += value.length
+            when diff_match_patch.DIFF_EQUAL
+              ops.push(new RetainOp(originalLength, originalLength + value.length))
+              originalLength += value.length
+              finalLength += value.length
+        return new Delta(originalLength, finalLength, ops)
+
+      deltaToText = (delta) ->
+        return _.map(delta.ops, (op) ->
+          return if op.value? then op.value else ""
+        ).join('')
+
+      diffTexts = (oldText, newText) ->
+        diff = dmp.diff_main(oldText, newText)
+        if (diff.length > 2)
+          dmp.diff_cleanupEfficiency(diff)
+        return diff
+
+      textA = deltaToText(this)
+      textC = deltaToText(other)
+      unless textA == '' and textC == ''
+        diff = diffTexts(textA, textC)
+        insertDelta = diffToDelta(diff)
+      else
+        insertDelta = new Delta(0, 0, [])
+      return insertDelta
+
+    # Inserts in deltaB are given priority. Retains in deltaB are indexes into A,
+    # and we take whatever is there (insert or retain).
+    compose: (deltaB) ->
+      console.assert(Delta.isDelta(deltaB), "Compose called when deltaB is not a Delta, type: " + typeof deltaB)
+      console.assert(@endLength == deltaB.startLength, "startLength #{deltaB.startLength} / endlength #{this.endLength} mismatch")
+
+      deltaA = Delta.copy(this)
+      deltaB = Delta.copy(deltaB)
+      deltaA = deltaA.normalizeChanges()
+      deltaB = deltaB.normalizeChanges()
+
+      composed = []
+      for elem in deltaB.ops
+        elem = new InsertOp(elem) if typeof elem == 'string'
+        if Delta.isInsert(elem)
+          composed.push(elem)
+        else if Delta.isRetain(elem)
+          opsInRange = deltaA.getOpsAt(elem)
+          opsInRange = _.map(opsInRange, (op) ->
+            if Delta.isInsert(op)
+              return new InsertOp(op.value, op.composeAttributes(elem.attributes))
+            else
+              return new RetainOp(op.start, op.end, op.composeAttributes(elem.attributes))
+          )
+          composed = composed.concat(opsInRange)
+        else
+          console.assert(false, "Invalid op in deltaB when composing", deltaB)
+      deltaC = new Delta(deltaA.startLength, deltaB.endLength, composed)
+      deltaC = deltaC.compact()
+      console.assert(Delta.isDelta(deltaC), "Composed returning invalid Delta", deltaC)
+      return deltaC
+
+    # For each element in deltaC, compare it to the current element in deltaA in
+    # order to construct deltaB. Given A and C, there is more than one valid B.
+    # Its impossible to guarantee that decompose yields the actual B that was
+    # used in the original composition. However, the function is deterministic in
+    # which of the possible B's it chooses. How it works:
+    # 1. Inserts in deltaC are matched against the current elem in deltaA. If
+    #    there is a match, we create a corresponding retain in deltaB. Otherwise,
+    #    we create an insertion in deltaB.
+    # 2. Retains in deltaC become retains in deltaB, which reference the original
+    #    retain in deltaA.
+    decompose: (deltaA) ->
+      deltaC = this
+      console.assert(Delta.isDelta(deltaA), "Decompose2 called when deltaA is not a Delta, type: " + typeof deltaA)
+      console.assert(deltaA.startLength == @startLength, "startLength #{deltaA.startLength} / startLength #{@startLength} mismatch")
+      console.assert(_.all(deltaA.ops, ((op) -> return op.value?)), "DeltaA has retain in decompose")
+      console.assert(_.all(deltaC.ops, ((op) -> return op.value?)), "DeltaC has retain in decompose")
+
+      decomposeAttributes = (attrA, attrC) ->
+        decomposedAttributes = {}
+        for key, value of attrC
+          if attrA[key] == undefined or attrA[key] != value
+            if attrA[key] != null and typeof attrA[key] == 'object' and value != null and typeof value == 'object'
+              decomposedAttributes[key] = decomposeAttributes(attrA[key], value)
+            else
+              decomposedAttributes[key] = value
+        for key, value of attrA
+          if attrC[key] == undefined
+            decomposedAttributes[key] = null
+        return decomposedAttributes
+
+      insertDelta = deltaA.diff(deltaC)
+      ops = []
+      offset = 0
+      _.each(insertDelta.ops, (op) ->
+        opsInC = deltaC.getOpsAt(offset, op.getLength())
+        offsetC = 0
+        _.each(opsInC, (opInC) ->
+          if Delta.isInsert(op)
+            d = new InsertOp(op.value.substring(offsetC, offsetC + opInC.getLength()), opInC.attributes)
+            ops.push(d)
+          else if Delta.isRetain(op)
+            opsInA = deltaA.getOpsAt(op.start + offsetC, opInC.getLength())
+            offsetA = 0
+            _.each(opsInA, (opInA) ->
+              attributes = decomposeAttributes(opInA.attributes, opInC.attributes)
+              start = op.start + offsetA + offsetC
+              e = new RetainOp(start, start + opInA.getLength(), attributes)
+              ops.push(e)
+              offsetA += opInA.getLength()
+            )
+          else
+            console.error("Invalid delta in deltaB when composing", deltaB)
+          offsetC += opInC.getLength()
+        )
+        offset += op.getLength()
+      )
+
+      deltaB = new Delta(insertDelta.startLength, insertDelta.endLength, ops)
+      deltaB = deltaB.compact()
+      return deltaB
+
+    # We compute the follow according to the following rules:
+    # 1. Insertions in deltaA become retained characters in the follow set
+    # 2. Insertions in deltaB become inserted characters in the follow set
+    # 3. Characters retained in deltaA and deltaB become retained characters in
+    #    the follow set
+    follows: (deltaA, aIsRemote) ->
+      deltaB = this
+      console.assert(Delta.isDelta(deltaA), "Follows called when deltaA is not a Delta, type: " + typeof deltaA, deltaA)
+      console.assert(aIsRemote?, "Remote delta not specified")
+
+      deltaA = Delta.copy(deltaA)
+      deltaB = Delta.copy(deltaB)
+      deltaA = deltaA.normalizeChanges()
+      deltaB = deltaB.normalizeChanges()
+      followStartLength = deltaA.endLength
+      followSet = []
+      indexA = indexB = 0 # Tracks character offset in the 'document'
+      elemIndexA = elemIndexB = 0 # Tracks offset into the ops list
+      while elemIndexA < deltaA.ops.length and elemIndexB < deltaB.ops.length
+        elemA = deltaA.ops[elemIndexA]
+        elemB = deltaB.ops[elemIndexB]
+
+        if Delta.isInsert(elemA) and Delta.isInsert(elemB)
+          length = Math.min(elemA.getLength(), elemB.getLength())
+          if aIsRemote
+            followSet.push(new RetainOp(indexA, indexA + length))
+            indexA += length
+            if length == elemA.getLength()
+              elemIndexA++
+            else
+              console.assert(length < elemA.getLength())
+              deltaA.ops[elemIndexA] = _.last(elemA.split(length))
+          else
+            followSet.push(_.first(elemB.split(length)))
+            indexB += length
+            if length == elemB.getLength()
+              elemIndexB++
+            else
+              deltaB.ops[elemIndexB] = _.last(elemB.split(length))
+
+        else if Delta.isRetain(elemA) and Delta.isRetain(elemB)
+          if elemA.end < elemB.start
+            # Not a match, can't save. Throw away lower and adv.
+            indexA += elemA.getLength()
+            elemIndexA++
+          else if elemB.end < elemA.start
+            # Not a match, can't save. Throw away lower and adv.
+            indexB += elemB.getLength()
+            elemIndexB++
+          else
+            # A subrange or the entire range matches
+            if elemA.start < elemB.start
+              indexA += elemB.start - elemA.start
+              elemA = deltaA.ops[elemIndexA] = new RetainOp(elemB.start, elemA.end, elemA.attributes)
+            else if elemB.start < elemA.start
+              indexB += elemA.start - elemB.start
+              elemB = deltaB.ops[elemIndexB] = new RetainOp(elemA.start, elemB.end, elemB.attributes)
+
+            console.assert(elemA.start == elemB.start, "RetainOps must have same
+            start length when propagating into followset", elemA, elemB)
+            length = Math.min(elemA.end, elemB.end) - elemA.start
+            addedAttributes = elemA.addAttributes(elemB.attributes)
+            followSet.push(new RetainOp(indexA, indexA + length, addedAttributes)) # Keep the retain
+            indexA += length
+            indexB += length
+            if (elemA.end == elemB.end)
+              elemIndexA++
+              elemIndexB++
+            else if (elemA.end < elemB.end)
+              elemIndexA++
+              deltaB.ops[elemIndexB] = _.last(elemB.split(length))
+            else
+              deltaA.ops[elemIndexA] = _.last(elemA.split(length))
+              elemIndexB++
+
+        else if Delta.isInsert(elemA) and Delta.isRetain(elemB)
+          followSet.push(new RetainOp(indexA, indexA + elemA.getLength()))
+          indexA += elemA.getLength()
+          elemIndexA++
+        else if Delta.isRetain(elemA) and Delta.isInsert(elemB)
+          followSet.push(elemB)
+          indexB += elemB.getLength()
+          elemIndexB++
+        else
+          console.warn("Mismatch. elemA is: " + typeof(elemA) + ", elemB is:  " + typeof(elemB))
+
+      # Remaining loops account for different length ops, only inserts will be
+      # accepted
+      while elemIndexA < deltaA.ops.length
+        elemA = deltaA.ops[elemIndexA]
+        followSet.push(new RetainOp(indexA, indexA + elemA.getLength())) if Delta.isInsert(elemA) # retain elemA
+        indexA += elemA.getLength()
+        elemIndexA++
+
+      while elemIndexB < deltaB.ops.length
+        elemB = deltaB.ops[elemIndexB]
+        followSet.push(elemB) if Delta.isInsert(elemB) # insert elemB
+        indexB += elemB.getLength()
+        elemIndexB++
+
+      followEndLength = 0
+      for elem in followSet
+        followEndLength += elem.getLength()
+
+      follow = new Delta(followStartLength, followEndLength, followSet, true)
+      follow = follow.compact()
+      console.assert(Delta.isDelta(follow), "Follows returning invalid Delta", follow)
+      return follow
+
+    applyToText: (text) ->
+      delta = this
+      console.assert(text.length == delta.startLength, "Start length of delta: " + delta.startLength + " is not equal to the text: " + text.length)
+      appliedText = []
+      for elem in delta.ops
+        if Delta.isInsert(elem)
+          appliedText.push(elem.value)
+        else
+          appliedText.push(text.substring(elem.start, elem.end))
+      result = appliedText.join("")
+      if delta.endLength != result.length
+        console.log "Delta", delta
+        console.log "text", text
+        console.log "result", result
+        console.assert(false, "End length of delta: " + delta.endLength + " is not equal to result text: " + result.length )
+      return result
+
+# Expose this code to other files
+root = if (typeof exports != "undefined" && exports != null) then exports else window
+root.Tandem = Tandem