Initial Commit -> Text collaboration works

This commit is contained in:
Kevin Jahns
2014-07-29 21:29:10 +02:00
commit b91135157e
79 changed files with 34627 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
_ = require "underscore"
module.exports = (user_list)->
class TestConnector
constructor: (@engine, @HB, @execution_listener)->
send_ = (o)=>
@send o
@execution_listener.push send_
if not (user_list?.length is 0)
@engine.applyOps user_list[0].getHistoryBuffer().toJson()
@unexecuted = {}
@applied_operations = []
appliedOperationsListener = (o)=>
@applied_operations.push o
@execution_listener.push appliedOperationsListener
getOpsInExecutionOrder: ()->
@applied_operations
getRootElement: ()->
if user_list.length > 0
user_list[0].getRootElement()
send: (o)->
if o.creator is @HB.getUserId()
for user in user_list
if not user.getUserId() is @HB.getUserId()
user.getConnector().receive(o)
receive: (o)->
@unexecuted[o.creator] ?= []
@unexecuted[o.creator].push o
flushOne: (user)->
if @unexecuted[user]?.length > 0
@engine.applyOp @unexecuted[user].shift()
flushOneRandom: ()->
@flushOne (_.random 0, (user_list.length-1))
flushAll: ()->
for ops of @unexecuted
@engine.applyOps ops
@unexecuted = {}
sync: ()->
throw new Error "Can't use this a.t.m."

44
lib/Engine.coffee Normal file
View File

@@ -0,0 +1,44 @@
_ = require "underscore"
class Engine
constructor: (@HB, @parser)->
@unprocessed_ops = []
parseOperation: (json)->
typeParser = @parser[json.type]
if typeParser?
typeParser json
else
throw new Error "You forgot to specify a parser for type #{json.type}. The message is #{JSON.stringify json}."
# TODO:
applyOps: (ops)->
for o in ops
@applyOp o
applyOp: (op_json)->
# $parse_and_execute will return false if $o_json was parsed and executed, otherwise the parsed operadion
o = @parseOperation o_json
@HB.addOperation o
if not o.execute()
@unprocessed_ops.push o
unprocessed = []
for op in @unprocessed_ops
if not op.execute()
unprocessed.push op
@unprocessed_ops = unprocessed
module.exports = Engine

View File

@@ -0,0 +1,47 @@
text_types_uninitialized = require "../Types/TextTypes.coffee"
HistoryBuffer = require "../HistoryBuffer.coffee"
Engine = require "../Engine.coffee"
class TextYatta
constructor: (user_id, Connector)->
@HB = new HistoryBuffer user_id
text_types = text_types_uninitialized @HB
@engine = new Engine @HB, text_types.parser
@connector = new Connector @engine, @HB, text_types.execution_listener
@root_element = @connector.getRootElement()
if not @root_element?
first_word = new text_types.types.Word @HB.getNextOperationIdentifier()
@HB.addOperation(first_word)
first_word.execute()
@root_element = @HB.addOperation(new text_types.types.ReplaceManager first_word, @HB.getNextOperationIdentifier()).execute()
getRootElement: ()->
@root_element
getEngine: ()->
@engine
getConnector: ()->
@connector
getHistoryBuffer: ()->
@HB
getUserId: ()->
@HB.getUserId()
val: ()->
@root_element.val().val()
insertText: (pos, content)->
@root_element.val().insertText pos, content
deleteText: (pos, length)->
@root_element.val().deleteText pos, length
replaceText: (text)->
@root_element.val().replaceText text
module.exports = TextYatta

75
lib/HistoryBuffer.coffee Normal file
View File

@@ -0,0 +1,75 @@
_ = require "underscore"
#
# An object that holds all applied operations.
#
# @note The HistoryBuffer is commonly abbreviated to HB.
#
class HistoryBuffer
# @overload new HistoryBuffer()
# Creates an empty HB.
# @param {Object} user_id Creator of the HB.
# @overload new HistoryBuffer(initial_content)
# Creates an HB with initial operations that represent the initial_value.
# @param {Array<Object>} initial_content Initial content of the DUC
# @see DUC DUC - Document Under Collaboration
constructor: (@user_id)->
@operation_counter = {}
@buffer = {}
@change_listeners = []
getUserId: ()->
@user_id
getOperationCounter: ()->
_.clone @operation_counter
toJson: ()->
json = []
for user in @buffer
for o of user
json.push o.toJson()
json
# Get the number of operations that were created by a user.
# Accordingly you will get the next operation number that is expected from that user.
# You'll get new results only if you added the operation with $addOperation.
#
getNextOperationIdentifier: (user_id)->
if not user_id?
user_id = @user_id
if not @operation_counter[user_id]?
@operation_counter[user_id] = 0
uid = {
'creator' : user_id
'op_number' : @operation_counter[user_id]
}
@operation_counter[user_id]++
uid
# Retrieve an operation from a unique id.
getOperation: (uid)->
if uid instanceof Object
@buffer[uid.creator]?[uid.op_number]
else
throw new Error "This type of uid is not defined!"
# Add an operation to the HB. Note that this will not link it against
# other operations (it wont be executable)
addOperation: (o)->
if not @buffer[o.creator]?
@buffer[o.creator] = {}
if not @operation_counter[o.creator]?
@operation_counter[o.creator] = 0
#if @operation_counter[o.creator] isnt o.op_number and typeof o.op_number is 'number'
# throw new Error "You don't receive operations in the proper order. Try counting like this 0,1,2,3,4,.. ;)"
if @buffer[o.creator][o.op_number]?
throw new Error "You must not overwrite operations!"
@buffer[o.creator][o.op_number] = o
if typeof o.op_number is 'number' and o.creator isnt @getUserId()
@operation_counter[o.creator]++
o
module.exports = HistoryBuffer

284
lib/Types/BasicTypes.coffee Normal file
View File

@@ -0,0 +1,284 @@
module.exports = (HB)->
# @see Engine.parse
parser = {}
execution_listener = []
#
# A generic interface to operations.
#
class Operation
# @param {Object} uid A unique identifier
# @see HistoryBuffer.getNextOperationIdentifier
constructor: ({'creator': @creator, 'op_number' : @op_number})->
# Computes a unique identifier (uid).
getUid: ()->
{ 'creator': @creator, 'op_number': @op_number }
execute: ()->
for l in execution_listener
l @toJson()
@
#
# Operations may depend on other operations (linked lists, etc.). The saveOperation and validateSavedOperations methods provide
# an easy way to refer to these operations via an uid or object reference.
#
# For example: We can create a new Delete operation that deletes the operation $o like this
# - var d = new Delete(uid, $o); or
# - var d = new Delete(uid, $o.getUid());
# Either way we want to access $o via d.deletes. This is possible after calling validateSavedOperations.
#
# @overload saveOperation(name, op_uid)
# @param {String} name The name of the operation. After validating (with validateSavedOperations) the instantiated operation will be accessible via this[name].
# @param {Object} op_uid A uid that refers to an operation
# @overload saveOperation(name, op)
# @param {String} name The name of the operation. After calling this function op is accessible via this[name].
# @param {Operation} op An Operation object
#
saveOperation: (name, op)->
# Every instance of $Operation must have an $execute function.
# We use duck-typing to check if op is instantiated since there
# could exist multiple classes of $Operation
if op?.execute?
# is instantiated
@[name] = op
else if op?
# not initialized. Do it when calling $validateSavedOperations()
@unchecked ?= {}
@unchecked[name] = op
#
# After calling this function all not instantiated operations will be accessible.
# @see Operation.saveOperation
#
# @return [Boolean] Whether it was possible to instantiate all operations.
#
validateSavedOperations: ()->
uninstantiated = {}
success = @
for name, op_uid of @unchecked
op = HB.getOperation op_uid
if op
@[name] = op
else
uninstantiated[name] = op
success = false
delete @unchecked
if not success
@unchecked = uninstantiated
success
#
# A simple delete-type operation.
#
class Delete extends Operation
constructor: (uid, deletes)->
@saveOperation 'deletes', deletes
super uid
#
# Convert all relevant information of this operation to the json-format.
# This result can be sent to other clients.
#
toJson: ()->
{
'type': "Delete"
'uid': @getUid()
'deletes': @deletes.getUid()
}
execute: ()->
if @validateSavedOperations()
@deletes.applyDelete @
super
@
else
false
#
# Define how to parse $Delete operations.
#
parser['Delete'] = ({'uid' : uid, 'deletes': deletes_uid})->
new D uid, deletes_uid
#
# A simple insert-type operation.
#
# An insert operation is always positioned between two other insert operations.
# Internally this is realized as associative lists, whereby each insert operation has a predecessor and a successor.
# For the sake of efficiency we maintain two lists:
# - The short-list (abbrev. sl) maintains only the operations that are not deleted
# - The complete-list (abbrev. cl) maintains all operations
#
class Insert extends Operation
# @param {Value} content The value of the insert operation. E.g. for strings content is a char.
# @param {Object} creator A unique user identifier
# @param {Integer} op_number This Number was assigned via getNextOperationIdentifier().
# @param {Operation} prev_cl The predecessor of this operation in the complete-list (cl)
# @param {Operation} next_cl The successor of this operation in the complete-list (cl)
#
# @see HistoryBuffer.getNextOperationIdentifier
constructor: (uid, prev_cl, next_cl, origin)->
@saveOperation 'prev_cl', prev_cl
@saveOperation 'next_cl', next_cl
if origin?
@saveOperation 'origin', origin
else
@saveOperation 'origin', prev_cl
super uid
applyDelete: (o)->
@deleted_by ?= []
@deleted_by.push o
#
# If isDeleted() is true this operation won't be maintained in the sl
#
isDeleted: ()->
@deleted_by?.length > 0
#
# The amount of positions that $this operation was moved to the right.
#
getDistanceToOrigin: ()->
d = 0
o = @prev_cl
while true
if @origin is o
break
d++
o = o.prev_cl
d
#
# Update the short list
# TODO (Unused)
update_sl: ()->
o = @prev_cl
update: (dest_cl,dest_sl)->
while true
if o.isDeleted()
o = o[dest_cl]
else
@[dest_sl] = o
break
update "prev_cl", "prev_sl"
update "next_cl", "prev_sl"
#
# Include this operation in the associative lists.
#
execute: ()->
if not @validateSavedOperations()
return false
else
if @prev_cl? and @next_cl?
distance_to_origin = 0
o = @prev_cl.next_cl
i = 0
# $this has to find a unique position between origin and the next known character
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right
# let $OL= [o1,o2,o3,o4], whereby $this is to be inserted between o1 and o4
# o2,o3 and o4 origin is 1 (the position of o2)
# there is the case that $this.creator < o2.creator, but o3.creator < $this.creator
# then o2 knows o3. Since on another client $OL could be [o1,o3,o4] the problem is complex
# therefore $this would be always to the right of o3
# case 2: $origin < $o.origin
# if current $this insert_position > $o origin: $this ins
# else $insert_position will not change (maybe we encounter case 1 later, then this will be to the right of $o)
# case 3: $origin > $o.origin
# $this insert_position is to the left of $o (forever!)
while true
if not o?
# TODO: Debugging
console.log JSON.stringify @prev_cl.getUid()
console.log JSON.stringify @next_cl.getUid()
if o isnt @next_cl
# $o happened concurrently
if o.getDistanceToOrigin() is i
# case 1
if o.creator < @creator
@prev_cl = o
distance_to_origin = i + 1
else
# nop
else if o.getDistanceToOrigin() < i
# case 2
if i - distance_to_origin <= o.getDistanceToOrigin()
@prev_cl = o
distance_to_origin = i + 1
else
#nop
else
# case 3
break
i++
o = o.next_cl
else
# $this knows that $o exists,
break
# now reconnect everything
@next_cl = @prev_cl.next_cl
@prev_cl.next_cl = @
@next_cl.prev_cl = @
super # notify the execution_listeners
@
val: ()->
throw new Error "Implement this function!"
#
# A delimiter is placed at the end and at the beginning of the associative lists.
# This is necessary in order to have a beginning and an end even if the content
# of the Engine is empty.
#
class Delimiter extends Insert
isDeleted: ()->
false
getDistanceToOrigin: ()->
0
execute: ()->
a = @validateSavedOperations()
for l in execution_listener
l @toJson()
a
toJson: ()->
{
'type' : "Delimiter"
'uid' : @getUid()
'prev' : @prev_cl.getUid()
'next' : @next_cl.getUid()
}
parser['Delimiter'] = (json)->
{
'uid' : uid
'prev' : prev
'next' : next
} = json
new Delimiter uid, prev, next
# This is what this module exports after initializing it with the HistoryBuffer
{
'types' :
'Delete' : Delete
'Insert' : Insert
'Delimiter': Delimiter
'Operation': Operation
'parser' : parser
'execution_listener' : execution_listener
}

View File

@@ -0,0 +1,175 @@
_ = require "underscore"
basic_types_uninitialized = require "./BasicTypes.coffee"
module.exports = (HB)->
basic_types = basic_types_uninitialized HB
types = basic_types.types
parser = basic_types.parser
class MapManager
constructor: ()->
@map = {}
set: (name, content)->
if not @map[name]?
@map[name] = new Replaceable HB,
@map[name].replace content
class ListManager extends types.Insert
constructor: (uid, beginning, end, prev, next, origin)->
if beginning? and end?
saveOperation "beginning", beginning
saveOperation "end", end
else
@beginning = HB.addOperation new types.Delimiter HB.getNextOperationIdentifier(), undefined, undefined
@end = HB.addOperation new types.Delimiter HB.getNextOperationIdentifier(), @beginning, undefined
@beginning.next_cl = @end
super uid, prev, next, origin
# Get the element previous to the delemiter at the end
getLastOperation: ()->
@end.prev_cl
# similar to the above
getFirstOperation: ()->
@beginning.next_cl
# Transforms the the list to an array
# Doesn't return left-right delimiter.
toArray: ()->
o = @beginning.next_cl
result = []
while o isnt @end
result.push o
o = o.next_cl
result
#
# Retrieves the x-th not deleted element.
#
getOperationByPosition: (position)->
o = @beginning.next_cl
if position > 0
while true
o = o.next_cl
if not o.isDeleted()
position -= 1
if position is 0
break
if o instanceof types.Delimiter
throw new Error "position parameter exceeded the length of the document!"
o
class ReplaceManager extends ListManager
constructor: (initial_content, uid, beginning, end, prev, next, origin)->
super uid, beginning, end, prev, next, origin
if initial_content?
@replace initial_content
replace: (content)->
o = @getLastOperation()
op = new Replaceable content, @, HB.getNextOperationIdentifier(), o, o.next_cl
HB.addOperation(op).execute()
val: ()->
o = @getLastOperation()
if o instanceof types.Delimiter
throw new Error "dtrn"
o.val()
toJson: ()->
json =
{
'type': "ReplaceManager"
'uid' : @getUid()
'beginning' : @beginning
'end' : @end
}
if @prev_cl? and @next_cl?
json['prev'] = @prev_cl.getUid()
json['next'] = @next_cl.getUid()
if @origin? and @origin isnt @prev_cl
json["origin"] = @origin.getUid()
json
parser["ReplaceManager"] = (json)->
{
'content' : content
'uid' : uid
'prev': prev
'next': next
'origin' : origin
'beginning' : beginning
'end' : end
} = json
new ReplaceManager content, uid, beginning, end, prev, next, origin
#
# Extends the basic Insert type.
#
class Replaceable extends types.Insert
constructor: (content, parent, uid, prev, next, origin)->
@saveOperation 'content', content
@saveOperation 'parent', parent
if not (prev? and next?)
throw new Error "You must define prev, and next for Replaceable-types!"
super uid, prev, next, origin
#
#
val: ()->
@content
replace: (content)->
@parent.replace content
execute: ()->
super
@content.setReplaceManager?(@parent)
@
#
# Convert all relevant information of this operation to the json-format.
# This result can be send to other clients.
#
toJson: ()->
json =
{
'type': "Replaceable"
'content': @content.getUid()
'ReplaceManager' : @parent
'prev': @prev_cl.getUid()
'next': @next_cl.getUid()
'uid' : @getUid()
}
if @origin? and @origin isnt @prev_cl
json["origin"] = @origin.getUid()
json
parser["Replaceable"] = (json)->
{
'content' : content
'ReplaceManager' : parent
'uid' : uid
'prev': prev
'next': next
'origin' : origin
} = json
new Replaceable content, parent, uid, prev, next, origin
types['ListManager'] = ListManager
types['MapManager'] = MapManager
types['ReplaceManager'] = ReplaceManager
types['Replaceable'] = Replaceable
basic_types

145
lib/Types/TextTypes.coffee Normal file
View File

@@ -0,0 +1,145 @@
_ = require "underscore"
structured_types_uninitialized = require "./StructuredTypes.coffee"
module.exports = (HB)->
structured_types = structured_types_uninitialized HB
types = structured_types.types
parser = structured_types.parser
#
# At the moment TextDelete type equals the Delete type in BasicTypes.
# @see BasicTypes.Delete
#
class TextDelete extends types.Delete
parser["TextDelete"] = parser["Delete"]
#
# Extends the basic Insert type.
#
class TextInsert extends types.Insert
constructor: (@content, uid, prev, next, origin)->
if not (prev? and next?)
throw new Error "You must define prev, and next for TextInsert-types!"
super uid, prev, next, origin
#
# Retrieve the effective length of the $content of this operation.
#
getLength: ()->
if @isDeleted()
0
else
@content.length
#
# The result will be concatenated with the results from the other insert operations
# in order to retrieve the content of the engine.
# @see HistoryBuffer.toExecutedArray
#
val: (current_position)->
if @isDeleted()
""
else
@content
#
# Convert all relevant information of this operation to the json-format.
# This result can be send to other clients.
#
toJson: ()->
json =
{
'type': "TextInsert"
'content': @content
'uid' : @getUid()
'prev': @prev_cl.getUid()
'next': @next_cl.getUid()
}
if @origin? and @origin isnt @prev_cl
json["origin"] = @origin.getUid()
json
parser["TextInsert"] = (json)->
{
'content' : content
'uid' : uid
'prev': prev
'next': next
'origin' : origin
} = json
new TextInsert content, uid, prev, next, origin
class Word extends types.ListManager
constructor: (uid, prev, next, origin)->
super uid, prev, next, origin
# inserts a
insertText: (position, content)->
o = @getOperationByPosition position
for c in content
op = new TextInsert c, HB.getNextOperationIdentifier(), o.prev_cl, o
HB.addOperation(op).execute()
# Creates a set of delete operations
deleteText: (position, length)->
o = @getOperationByPosition position
for i in [0...length]
d = HB.addOperation(new TextDelete HB.getNextOperationIdentifier(), o).execute()
o = o.next_cl
while o.isDeleted()
if o instanceof types.Delimiter
throw new Error "You can't delete more than there is.."
o = o.next_cl
d.toJson()
replaceText: (text)->
if @replace_manager?
word = HB.addOperation(new Word HB.getNextOperationIdentifier()).execute()
word.insertText 0, text
@replace_manager.replace(word)
else
throw new Error "This type is currently not maintained by a ReplaceManager!"
val: ()->
c = for o in @toArray()
if o.val?
o.val()
else
""
c.join('')
setReplaceManager: (op)->
@saveOperation 'replace_manager', op
@validateSavedOperations
toJson: ()->
json = {
'type': "TextInsert"
'content': @content
'uid' : @getUid()
}
if @prev_cl?
json['prev'] = @prev_cl.getUid()
if @next_cl?
json['next'] = @next_cl.getUid()
if @origin? and @origin isnt @prev_cl
json["origin"] = @origin.getUid()
json
parser['Word'] = (json)->
{
'content' : content
'uid' : uid
'prev': prev
'next': next
'origin' : origin
} = json
new Word uid, prev, next, origin
types['TextInsert'] = TextInsert
types['TextDelete'] = TextDelete
types['Word'] = Word
structured_types

View File

@@ -0,0 +1 @@
_ = require "underscore"