ot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +23 -0
- data/README.md +113 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/lib/ot.rb +5 -0
- data/lib/ot/text_operation.rb +566 -0
- data/lib/ot/version.rb +3 -0
- data/ot.gemspec +26 -0
- metadata +127 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: a2aa04ca759227fedfd134d4fe0fc7be9d81d2c6
|
4
|
+
data.tar.gz: d4ae2da9934dcc3969bca96f65c917b3edcb0ff3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d81ac40bb41788e563cd2591e1d70efe13c1b329cb43294104b955a4093e5c783a3b83c3852e20b6a8a144ccdb8648de48fbae13c2500708505049f9aa5e20a0
|
7
|
+
data.tar.gz: 100feef5b48e4428da6e0c6e4c2c711402c6d12deadeca6f21066cac8bad5b6c4111feb7164aa798e638ebaf8eac86116f1a72fcbd4273ecd36aee964cd2a8aa
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Hayden Ball, http://haydenball.me.uk
|
4
|
+
|
5
|
+
Original (ot.js) Copyright (c) 2012-2014 Tim Baumann, http://timbaumann.info
|
6
|
+
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
9
|
+
in the Software without restriction, including without limitation the rights
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
12
|
+
furnished to do so, subject to the following conditions:
|
13
|
+
|
14
|
+
The above copyright notice and this permission notice shall be included in
|
15
|
+
all copies or substantial portions of the Software.
|
16
|
+
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
23
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
# ot.rb
|
2
|
+
|
3
|
+
[](https://travis-ci.org/ball-hayden/ot.rb)
|
4
|
+
|
5
|
+
This is a Ruby port of the <https://github.com/Operational-Transformation/ot.js>
|
6
|
+
Operational Transformation library.
|
7
|
+
|
8
|
+
At this time, only `TextOperation` has been ported.
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
gem 'ot'
|
16
|
+
```
|
17
|
+
|
18
|
+
And then execute:
|
19
|
+
|
20
|
+
$ bundle
|
21
|
+
|
22
|
+
Or install it yourself as:
|
23
|
+
|
24
|
+
$ gem install ot
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
### TextOperation
|
29
|
+
|
30
|
+
`retain(n)`
|
31
|
+
|
32
|
+
Skip over a given number of charaters
|
33
|
+
|
34
|
+
`insert(string)`
|
35
|
+
|
36
|
+
Insert a string at the current position
|
37
|
+
|
38
|
+
`delete(n)`
|
39
|
+
|
40
|
+
Delete a string at the current position
|
41
|
+
|
42
|
+
`noop?`
|
43
|
+
|
44
|
+
Tests whether this operation has no effect.
|
45
|
+
|
46
|
+
`to_a`
|
47
|
+
|
48
|
+
Converts operation into an array value.
|
49
|
+
Note that this replaces the `toJSON` method in ot.js
|
50
|
+
|
51
|
+
`self.from_a(ops)`
|
52
|
+
|
53
|
+
Converts an array into an operation and validates it.
|
54
|
+
Note that this replaces the `fromJSON` method in ot.js
|
55
|
+
|
56
|
+
`apply(str)`
|
57
|
+
|
58
|
+
Apply an operation to a string, returning a new string. Throws an error if
|
59
|
+
there's a mismatch between the input string and the operation.
|
60
|
+
|
61
|
+
`invert(str)`
|
62
|
+
|
63
|
+
Computes the inverse of an operation. The inverse of an operation is the
|
64
|
+
operation that reverts the effects of the operation, e.g. when you have an
|
65
|
+
operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello ");
|
66
|
+
skip(6);'. The inverse should be used for implementing undo.
|
67
|
+
|
68
|
+
`compose(operation2)`
|
69
|
+
|
70
|
+
Compose merges two consecutive operations into one operation, that
|
71
|
+
preserves the changes of both. Or, in other words, for each input string S
|
72
|
+
and a pair of consecutive operations A and B,
|
73
|
+
apply(apply(S, A), B) = apply(S, compose(A, B)) must hold.
|
74
|
+
|
75
|
+
`compose_with?(other)`
|
76
|
+
|
77
|
+
When you use ctrl-z to undo your latest changes, you expect the program not
|
78
|
+
to undo every single keystroke but to undo your last sentence you wrote at
|
79
|
+
a stretch or the deletion you did by holding the backspace key down. This
|
80
|
+
This can be implemented by composing operations on the undo stack. This
|
81
|
+
method can help decide whether two operations should be composed. It
|
82
|
+
returns true if the operations are consecutive insert operations or both
|
83
|
+
operations delete text at the same position. You may want to include other
|
84
|
+
factors like the time since the last change in your decision.
|
85
|
+
|
86
|
+
`compose_with_inverted?(other)`
|
87
|
+
|
88
|
+
Decides whether two operations should be composed with each other
|
89
|
+
if they were inverted, that is
|
90
|
+
`shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`.
|
91
|
+
|
92
|
+
`transform(operation1, operation2)`
|
93
|
+
|
94
|
+
Transform takes two operations A and B that happened concurrently and
|
95
|
+
produces two operations A' and B' (in an array) such that
|
96
|
+
`apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
|
97
|
+
heart of OT.
|
98
|
+
|
99
|
+
## Development
|
100
|
+
|
101
|
+
After checking out the repo, run `bundle install` to install dependencies. Then,
|
102
|
+
run `rake rspec` to run the tests. You can also run `bin/console` for an
|
103
|
+
interactive prompt that will allow you to experiment.
|
104
|
+
|
105
|
+
## Contributing
|
106
|
+
|
107
|
+
Bug reports and pull requests are welcome on GitHub at
|
108
|
+
<https://github.com/ball-hayden/ot.rb>.
|
109
|
+
|
110
|
+
## License
|
111
|
+
|
112
|
+
The gem is available as open source under the terms of the
|
113
|
+
[MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "ot"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/lib/ot.rb
ADDED
@@ -0,0 +1,566 @@
|
|
1
|
+
module OT
|
2
|
+
class TextOperation
|
3
|
+
attr_reader :ops, :base_length, :target_length
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
# When an operation is applied to an input string, you can think of this as
|
7
|
+
# if an imaginary cursor runs over the entire string and skips over some
|
8
|
+
# parts, deletes some parts and inserts characters at some positions. These
|
9
|
+
# actions (skip/delete/insert) are stored as an array in the "ops" property.
|
10
|
+
@ops = []
|
11
|
+
# An operation's base_length is the length of every string the operation
|
12
|
+
# can be applied to.
|
13
|
+
@base_length = 0
|
14
|
+
# The target_length is the length of every string that results from applying
|
15
|
+
# the operation on a valid input string.
|
16
|
+
@target_length = 0
|
17
|
+
end
|
18
|
+
|
19
|
+
def ==(other)
|
20
|
+
return false unless base_length == other.base_length
|
21
|
+
return false unless target_length == other.target_length
|
22
|
+
return false unless ops.length == other.ops.length
|
23
|
+
|
24
|
+
ops.length.times do |i|
|
25
|
+
return false unless ops[i] == other.ops[i]
|
26
|
+
end
|
27
|
+
|
28
|
+
return true
|
29
|
+
end
|
30
|
+
|
31
|
+
# Operation are essentially lists of ops. There are three types of ops:
|
32
|
+
#
|
33
|
+
# * Retain ops: Advance the cursor position by a given number of characters.
|
34
|
+
# Represented by positive ints.
|
35
|
+
# * Insert ops: Insert a given string at the current cursor position.
|
36
|
+
# Represented by strings.
|
37
|
+
# * Delete ops: Delete the next n characters. Represented by negative ints.
|
38
|
+
|
39
|
+
def self.retain_op?(op)
|
40
|
+
op.is_a?(Integer) && op > 0
|
41
|
+
end
|
42
|
+
|
43
|
+
def retain_op?(op)
|
44
|
+
TextOperation.retain_op?(op)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.insert_op?(op)
|
48
|
+
op.is_a? String
|
49
|
+
end
|
50
|
+
|
51
|
+
def insert_op?(op)
|
52
|
+
TextOperation.insert_op?(op)
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.delete_op?(op)
|
56
|
+
op.is_a?(Integer) && op < 0
|
57
|
+
end
|
58
|
+
|
59
|
+
def delete_op?(op)
|
60
|
+
TextOperation.delete_op?(op)
|
61
|
+
end
|
62
|
+
|
63
|
+
# After an operation is constructed, the user of the library can specify the
|
64
|
+
# actions of an operation (skip/insert/delete) with these three builder
|
65
|
+
# methods. They all return the operation for convenient chaining.
|
66
|
+
|
67
|
+
# Skip over a given number of characters.
|
68
|
+
def retain(n)
|
69
|
+
fail 'retain expects an integer' unless n.is_a? Integer
|
70
|
+
|
71
|
+
return self if n == 0
|
72
|
+
|
73
|
+
@base_length += n
|
74
|
+
@target_length += n
|
75
|
+
|
76
|
+
if retain_op?(@ops.last)
|
77
|
+
# The last op is a retain op => we can merge them into one op.
|
78
|
+
@ops[-1] += n
|
79
|
+
else
|
80
|
+
# Create a new op.
|
81
|
+
@ops.push(n)
|
82
|
+
end
|
83
|
+
|
84
|
+
return self
|
85
|
+
end
|
86
|
+
|
87
|
+
# Insert a string at the current position.
|
88
|
+
def insert(str)
|
89
|
+
fail 'insert expects a string' unless str.is_a? String
|
90
|
+
|
91
|
+
return self if str == ''
|
92
|
+
|
93
|
+
@target_length += str.length
|
94
|
+
|
95
|
+
if insert_op?(ops.last)
|
96
|
+
# Merge insert op.
|
97
|
+
@ops[-1] += str
|
98
|
+
elsif delete_op?(ops.last)
|
99
|
+
# It doesn't matter when an operation is applied whether the operation
|
100
|
+
# is delete(3), insert("something") or insert("something"), delete(3).
|
101
|
+
# Here we enforce that in this case, the insert op always comes first.
|
102
|
+
# This makes all operations that have the same effect when applied to
|
103
|
+
# a document of the right length equal in respect to the `equals` method.
|
104
|
+
if insert_op?(ops[-2])
|
105
|
+
@ops[-2] += str
|
106
|
+
else
|
107
|
+
@ops.insert(-2, str)
|
108
|
+
end
|
109
|
+
else
|
110
|
+
@ops.push(str)
|
111
|
+
end
|
112
|
+
|
113
|
+
return self
|
114
|
+
end
|
115
|
+
|
116
|
+
# Delete a string at the current position.
|
117
|
+
def delete(n)
|
118
|
+
fail 'delete expects an integer or a string' unless n.is_a?(Integer) || n.is_a?(String)
|
119
|
+
|
120
|
+
n = n.length if n.is_a? String
|
121
|
+
|
122
|
+
return self if n == 0
|
123
|
+
|
124
|
+
n = -n if n > 0
|
125
|
+
|
126
|
+
@base_length -= n
|
127
|
+
|
128
|
+
if delete_op?(@ops.last)
|
129
|
+
@ops[-1] += n
|
130
|
+
else
|
131
|
+
@ops.push(n)
|
132
|
+
end
|
133
|
+
|
134
|
+
return self
|
135
|
+
end
|
136
|
+
|
137
|
+
# Tests whether this operation has no effect.
|
138
|
+
def noop?
|
139
|
+
return @ops.length == 0 || (@ops.length == 1 && retain_op?(@ops[0]))
|
140
|
+
end
|
141
|
+
|
142
|
+
# Pretty printing.
|
143
|
+
def to_s
|
144
|
+
# map: build a new array by applying a function to every element in an old
|
145
|
+
# array.
|
146
|
+
@ops.map do |op|
|
147
|
+
if retain_op?(op)
|
148
|
+
"retain #{op}"
|
149
|
+
elsif insert_op?(op)
|
150
|
+
"insert '#{op}'"
|
151
|
+
else
|
152
|
+
"delete #{-op}"
|
153
|
+
end
|
154
|
+
end.join(', ')
|
155
|
+
end
|
156
|
+
|
157
|
+
# Converts operation into an array value.
|
158
|
+
# Note that this replaces the toJSON method in ot.js
|
159
|
+
def to_a
|
160
|
+
return @ops
|
161
|
+
end
|
162
|
+
|
163
|
+
# Converts an array into an operation and validates it.
|
164
|
+
# Note that this replaces the fromJSON method in ot.js
|
165
|
+
def self.from_a(ops)
|
166
|
+
operation = TextOperation.new
|
167
|
+
|
168
|
+
ops.each do |op|
|
169
|
+
if retain_op?(op)
|
170
|
+
operation.retain(op)
|
171
|
+
elsif insert_op?(op)
|
172
|
+
operation.insert(op)
|
173
|
+
elsif delete_op?(op)
|
174
|
+
operation.delete(op)
|
175
|
+
else
|
176
|
+
fail 'unknown operation: ' + op.to_s
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
return operation
|
181
|
+
end
|
182
|
+
|
183
|
+
# Apply an operation to a string, returning a new string. Throws an error if
|
184
|
+
# there's a mismatch between the input string and the operation.
|
185
|
+
def apply(str)
|
186
|
+
if str.length != base_length
|
187
|
+
fail "The operation's base length must be equal to the string's length."
|
188
|
+
end
|
189
|
+
|
190
|
+
new_str = ''
|
191
|
+
str_index = 0
|
192
|
+
|
193
|
+
@ops.each do |op|
|
194
|
+
if retain_op?(op)
|
195
|
+
if (str_index + op) > str.length
|
196
|
+
fail "Operation can't retain more characters than are left in the string."
|
197
|
+
end
|
198
|
+
|
199
|
+
# Copy skipped part of the old string.
|
200
|
+
new_str += str.slice(str_index, op)
|
201
|
+
str_index += op
|
202
|
+
elsif insert_op?(op)
|
203
|
+
# Insert string.
|
204
|
+
new_str += op
|
205
|
+
else
|
206
|
+
# delete op
|
207
|
+
str_index -= op
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
if (str_index != str.length)
|
212
|
+
fail "The operation didn't operate on the whole string."
|
213
|
+
end
|
214
|
+
|
215
|
+
return new_str
|
216
|
+
end
|
217
|
+
|
218
|
+
# Computes the inverse of an operation. The inverse of an operation is the
|
219
|
+
# operation that reverts the effects of the operation, e.g. when you have an
|
220
|
+
# operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello ");
|
221
|
+
# skip(6);'. The inverse should be used for implementing undo.
|
222
|
+
def invert(str)
|
223
|
+
str_index = 0
|
224
|
+
inverse = TextOperation.new
|
225
|
+
|
226
|
+
@ops.each do |op|
|
227
|
+
if retain_op?(op)
|
228
|
+
inverse.retain(op)
|
229
|
+
str_index += op
|
230
|
+
elsif insert_op?(op)
|
231
|
+
inverse.delete(op.length)
|
232
|
+
else # delete op
|
233
|
+
inverse.insert(str.slice(str_index, -op))
|
234
|
+
str_index -= op
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
return inverse
|
239
|
+
end
|
240
|
+
|
241
|
+
# Compose merges two consecutive operations into one operation, that
|
242
|
+
# preserves the changes of both. Or, in other words, for each input string S
|
243
|
+
# and a pair of consecutive operations A and B,
|
244
|
+
# apply(apply(S, A), B) = apply(S, compose(A, B)) must hold.
|
245
|
+
def compose(operation2)
|
246
|
+
operation1 = self
|
247
|
+
if operation1.target_length != operation2.base_length
|
248
|
+
fail 'The base length of the second operation has to be the target length of the first operation'
|
249
|
+
end
|
250
|
+
|
251
|
+
operation = TextOperation.new; # the combined operation
|
252
|
+
|
253
|
+
# for fast access
|
254
|
+
ops1 = operation1.ops
|
255
|
+
ops2 = operation2.ops
|
256
|
+
|
257
|
+
# current index into ops1 respectively ops2
|
258
|
+
i1 = 0
|
259
|
+
i2 = 0
|
260
|
+
|
261
|
+
# current ops
|
262
|
+
op1 = ops1[i1]
|
263
|
+
op2 = ops2[i2]
|
264
|
+
|
265
|
+
loop do
|
266
|
+
# Dispatch on the type of op1 and op2
|
267
|
+
if op1.nil? && op2.nil?
|
268
|
+
# end condition: both ops1 and ops2 have been processed
|
269
|
+
break
|
270
|
+
end
|
271
|
+
|
272
|
+
if delete_op?(op1)
|
273
|
+
operation.delete(op1)
|
274
|
+
|
275
|
+
op1 = ops1[i1 += 1]
|
276
|
+
next
|
277
|
+
end
|
278
|
+
|
279
|
+
if insert_op?(op2)
|
280
|
+
operation.insert(op2)
|
281
|
+
|
282
|
+
op2 = ops2[i2 += 1]
|
283
|
+
next
|
284
|
+
end
|
285
|
+
|
286
|
+
if op1.nil?
|
287
|
+
fail 'Cannot compose operations: first operation is too short.'
|
288
|
+
end
|
289
|
+
if op2.nil?
|
290
|
+
fail 'Cannot compose operations: first operation is too long.'
|
291
|
+
end
|
292
|
+
|
293
|
+
if retain_op?(op1) && retain_op?(op2)
|
294
|
+
if op1 > op2
|
295
|
+
operation.retain(op2)
|
296
|
+
op1 -= op2
|
297
|
+
|
298
|
+
op2 = ops2[i2 += 1]
|
299
|
+
elsif (op1 == op2)
|
300
|
+
operation.retain(op1)
|
301
|
+
|
302
|
+
op1 = ops1[i1 += 1]
|
303
|
+
op2 = ops2[i2 += 1]
|
304
|
+
else
|
305
|
+
operation.retain(op1)
|
306
|
+
op2 -= op1
|
307
|
+
|
308
|
+
op1 = ops1[i1 += 1]
|
309
|
+
end
|
310
|
+
elsif insert_op?(op1) && delete_op?(op2)
|
311
|
+
if op1.length > -op2
|
312
|
+
op1 = op1.slice(-op2, op1.length)
|
313
|
+
op2 = ops2[i2 += 1]
|
314
|
+
elsif (op1.length == -op2)
|
315
|
+
op1 = ops1[i1 += 1]
|
316
|
+
op2 = ops2[i2 += 1]
|
317
|
+
else
|
318
|
+
op2 += op1.length
|
319
|
+
op1 = ops1[i1 += 1]
|
320
|
+
end
|
321
|
+
elsif insert_op?(op1) && retain_op?(op2)
|
322
|
+
if op1.length > op2
|
323
|
+
operation.insert(op1.slice(0, op2))
|
324
|
+
op1 = op1.slice(op2, op1.length - op2)
|
325
|
+
op2 = ops2[i2 += 1]
|
326
|
+
elsif (op1.length == op2)
|
327
|
+
operation.insert(op1)
|
328
|
+
op1 = ops1[i1 += 1]
|
329
|
+
op2 = ops2[i2 += 1]
|
330
|
+
else
|
331
|
+
operation.insert(op1)
|
332
|
+
op2 -= op1.length
|
333
|
+
op1 = ops1[i1 += 1]
|
334
|
+
end
|
335
|
+
elsif retain_op?(op1) && delete_op?(op2)
|
336
|
+
if op1 > -op2
|
337
|
+
operation.delete(op2)
|
338
|
+
op1 += op2
|
339
|
+
op2 = ops2[i2 += 1]
|
340
|
+
elsif (op1 == -op2)
|
341
|
+
operation.delete(op2)
|
342
|
+
op1 = ops1[i1 += 1]
|
343
|
+
op2 = ops2[i2 += 1]
|
344
|
+
else
|
345
|
+
operation.delete(op1)
|
346
|
+
op2 += op1
|
347
|
+
op1 = ops1[i1 += 1]
|
348
|
+
end
|
349
|
+
else
|
350
|
+
fail "This shouldn't happen: op1: " +
|
351
|
+
JSON.stringify(op1) + ', op2: ' +
|
352
|
+
JSON.stringify(op2)
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
return operation
|
357
|
+
end
|
358
|
+
|
359
|
+
def self.get_simple_op(operation)
|
360
|
+
ops = operation.ops
|
361
|
+
|
362
|
+
case (ops.length)
|
363
|
+
when 1
|
364
|
+
return ops[0]
|
365
|
+
when 2
|
366
|
+
return retain_op?(ops[0]) ? ops[1] : (retain_op?(ops[1]) ? ops[0] : nil)
|
367
|
+
when 3
|
368
|
+
return ops[1] if retain_op?(ops[0]) && retain_op?(ops[2])
|
369
|
+
end
|
370
|
+
|
371
|
+
return
|
372
|
+
end
|
373
|
+
|
374
|
+
def self.get_start_index(operation)
|
375
|
+
return operation.ops[0] if retain_op?(operation.ops[0])
|
376
|
+
return 0
|
377
|
+
end
|
378
|
+
|
379
|
+
# When you use ctrl-z to undo your latest changes, you expect the program not
|
380
|
+
# to undo every single keystroke but to undo your last sentence you wrote at
|
381
|
+
# a stretch or the deletion you did by holding the backspace key down. This
|
382
|
+
# This can be implemented by composing operations on the undo stack. This
|
383
|
+
# method can help decide whether two operations should be composed. It
|
384
|
+
# returns true if the operations are consecutive insert operations or both
|
385
|
+
# operations delete text at the same position. You may want to include other
|
386
|
+
# factors like the time since the last change in your decision.
|
387
|
+
def compose_with?(other)
|
388
|
+
return true if noop? || other.noop?
|
389
|
+
|
390
|
+
start_a = TextOperation.get_start_index(self)
|
391
|
+
start_b = TextOperation.get_start_index(other)
|
392
|
+
|
393
|
+
simple_a = TextOperation.get_simple_op(self)
|
394
|
+
simple_b = TextOperation.get_simple_op(other)
|
395
|
+
|
396
|
+
return false unless simple_a && simple_b
|
397
|
+
|
398
|
+
if insert_op?(simple_a) && insert_op?(simple_b)
|
399
|
+
return start_a + simple_a.length == start_b
|
400
|
+
end
|
401
|
+
|
402
|
+
if delete_op?(simple_a) && delete_op?(simple_b)
|
403
|
+
# there are two possibilities to delete: with backspace and with the
|
404
|
+
# delete key.
|
405
|
+
return (start_b - simple_b == start_a) || start_a == start_b
|
406
|
+
end
|
407
|
+
|
408
|
+
return false
|
409
|
+
end
|
410
|
+
|
411
|
+
# Decides whether two operations should be composed with each other
|
412
|
+
# if they were inverted, that is
|
413
|
+
# `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`.
|
414
|
+
def compose_with_inverted?(other)
|
415
|
+
return true if noop? || other.noop?
|
416
|
+
|
417
|
+
start_a = TextOperation.get_start_index(self)
|
418
|
+
start_b = TextOperation.get_start_index(other)
|
419
|
+
|
420
|
+
simple_a = TextOperation.get_simple_op(self)
|
421
|
+
simple_b = TextOperation.get_simple_op(other)
|
422
|
+
|
423
|
+
return false unless simple_a && simple_b
|
424
|
+
|
425
|
+
if insert_op?(simple_a) && insert_op?(simple_b)
|
426
|
+
return start_a + simple_a.length == start_b || start_a == start_b
|
427
|
+
end
|
428
|
+
|
429
|
+
if delete_op?(simple_a) && delete_op?(simple_b)
|
430
|
+
return start_b - simple_b == start_a
|
431
|
+
end
|
432
|
+
|
433
|
+
return false
|
434
|
+
end
|
435
|
+
|
436
|
+
# Transform takes two operations A and B that happened concurrently and
|
437
|
+
# produces two operations A' and B' (in an array) such that
|
438
|
+
# `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
|
439
|
+
# heart of OT.
|
440
|
+
def self.transform(operation1, operation2)
|
441
|
+
if (operation1.base_length != operation2.base_length)
|
442
|
+
fail 'Both operations have to have the same base length'
|
443
|
+
end
|
444
|
+
|
445
|
+
operation1prime = TextOperation.new
|
446
|
+
operation2prime = TextOperation.new
|
447
|
+
|
448
|
+
ops1 = operation1.ops
|
449
|
+
ops2 = operation2.ops
|
450
|
+
|
451
|
+
i1 = 0
|
452
|
+
i2 = 0
|
453
|
+
|
454
|
+
op1 = ops1[i1]
|
455
|
+
op2 = ops2[i2]
|
456
|
+
|
457
|
+
loop do
|
458
|
+
# At every iteration of the loop, the imaginary cursor that both
|
459
|
+
# operation1 and operation2 have that operates on the input string must
|
460
|
+
# have the same position in the input string.
|
461
|
+
|
462
|
+
if op1.nil? && op2.nil?
|
463
|
+
# end condition: both ops1 and ops2 have been processed
|
464
|
+
break
|
465
|
+
end
|
466
|
+
|
467
|
+
# next two cases: one or both ops are insert ops
|
468
|
+
# => insert the string in the corresponding prime operation, skip it in
|
469
|
+
# the other one. If both op1 and op2 are insert ops, prefer op1.
|
470
|
+
if insert_op?(op1)
|
471
|
+
operation1prime.insert(op1)
|
472
|
+
operation2prime.retain(op1.length)
|
473
|
+
op1 = ops1[i1 += 1]
|
474
|
+
next
|
475
|
+
end
|
476
|
+
|
477
|
+
if insert_op?(op2)
|
478
|
+
operation1prime.retain(op2.length)
|
479
|
+
operation2prime.insert(op2)
|
480
|
+
op2 = ops2[i2 += 1]
|
481
|
+
next
|
482
|
+
end
|
483
|
+
|
484
|
+
if op1.nil?
|
485
|
+
fail 'Cannot transform operations: first operation is too short.'
|
486
|
+
end
|
487
|
+
if op2.nil?
|
488
|
+
fail 'Cannot transform operations: first operation is too long.'
|
489
|
+
end
|
490
|
+
|
491
|
+
minl = nil
|
492
|
+
|
493
|
+
if retain_op?(op1) && retain_op?(op2)
|
494
|
+
# Simple case: retain/retain
|
495
|
+
if op1 > op2
|
496
|
+
minl = op2
|
497
|
+
op1 -= op2
|
498
|
+
op2 = ops2[i2 += 1]
|
499
|
+
elsif (op1 == op2)
|
500
|
+
minl = op2
|
501
|
+
op1 = ops1[i1 += 1]
|
502
|
+
op2 = ops2[i2 += 1]
|
503
|
+
else
|
504
|
+
minl = op1
|
505
|
+
op2 -= op1
|
506
|
+
op1 = ops1[i1 += 1]
|
507
|
+
end
|
508
|
+
|
509
|
+
operation1prime.retain(minl)
|
510
|
+
operation2prime.retain(minl)
|
511
|
+
elsif delete_op?(op1) && delete_op?(op2)
|
512
|
+
# Both operations delete the same string at the same position. We don't
|
513
|
+
# need to produce any operations, we just skip over the delete ops and
|
514
|
+
# handle the case that one operation deletes more than the other.
|
515
|
+
if -op1 > -op2
|
516
|
+
op1 -= op2
|
517
|
+
op2 = ops2[i2 += 1]
|
518
|
+
elsif (op1 == op2)
|
519
|
+
op1 = ops1[i1 += 1]
|
520
|
+
op2 = ops2[i2 += 1]
|
521
|
+
else
|
522
|
+
op2 -= op1
|
523
|
+
op1 = ops1[i1 += 1]
|
524
|
+
end
|
525
|
+
# next two cases: delete/retain and retain/delete
|
526
|
+
elsif delete_op?(op1) && retain_op?(op2)
|
527
|
+
if -op1 > op2
|
528
|
+
minl = op2
|
529
|
+
op1 += op2
|
530
|
+
op2 = ops2[i2 += 1]
|
531
|
+
elsif (-op1 == op2)
|
532
|
+
minl = op2
|
533
|
+
op1 = ops1[i1 += 1]
|
534
|
+
op2 = ops2[i2 += 1]
|
535
|
+
else
|
536
|
+
minl = -op1
|
537
|
+
op2 += op1
|
538
|
+
op1 = ops1[i1 += 1]
|
539
|
+
end
|
540
|
+
|
541
|
+
operation1prime.delete(minl)
|
542
|
+
elsif retain_op?(op1) && delete_op?(op2)
|
543
|
+
if op1 > -op2
|
544
|
+
minl = -op2
|
545
|
+
op1 += op2
|
546
|
+
op2 = ops2[i2 += 1]
|
547
|
+
elsif (op1 == -op2)
|
548
|
+
minl = op1
|
549
|
+
op1 = ops1[i1 += 1]
|
550
|
+
op2 = ops2[i2 += 1]
|
551
|
+
else
|
552
|
+
minl = op1
|
553
|
+
op2 += op1
|
554
|
+
op1 = ops1[i1 += 1]
|
555
|
+
end
|
556
|
+
|
557
|
+
operation2prime.delete(minl)
|
558
|
+
else
|
559
|
+
throw new Error("The two operations aren't compatible")
|
560
|
+
end
|
561
|
+
end
|
562
|
+
|
563
|
+
return [operation1prime, operation2prime]
|
564
|
+
end
|
565
|
+
end
|
566
|
+
end
|
data/lib/ot/version.rb
ADDED
data/ot.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'ot/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'ot'
|
8
|
+
spec.version = OT::VERSION
|
9
|
+
spec.authors = ['Hayden Ball']
|
10
|
+
spec.email = ['hayden@haydenball.me.uk']
|
11
|
+
|
12
|
+
spec.summary = 'A Ruby port of the ot.js Operational Transformation library'
|
13
|
+
spec.homepage = 'https://github.com/ball-hayden/ot.rb'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = 'exe'
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
22
|
+
spec.add_development_dependency 'byebug'
|
23
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
24
|
+
spec.add_development_dependency 'rspec'
|
25
|
+
spec.add_development_dependency 'rubocop'
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ot
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hayden Ball
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-10-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.10'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.10'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: byebug
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description:
|
84
|
+
email:
|
85
|
+
- hayden@haydenball.me.uk
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- ".gitignore"
|
91
|
+
- ".rspec"
|
92
|
+
- ".rubocop.yml"
|
93
|
+
- ".travis.yml"
|
94
|
+
- Gemfile
|
95
|
+
- LICENSE.txt
|
96
|
+
- README.md
|
97
|
+
- Rakefile
|
98
|
+
- bin/console
|
99
|
+
- lib/ot.rb
|
100
|
+
- lib/ot/text_operation.rb
|
101
|
+
- lib/ot/version.rb
|
102
|
+
- ot.gemspec
|
103
|
+
homepage: https://github.com/ball-hayden/ot.rb
|
104
|
+
licenses:
|
105
|
+
- MIT
|
106
|
+
metadata: {}
|
107
|
+
post_install_message:
|
108
|
+
rdoc_options: []
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
requirements: []
|
122
|
+
rubyforge_project:
|
123
|
+
rubygems_version: 2.4.8
|
124
|
+
signing_key:
|
125
|
+
specification_version: 4
|
126
|
+
summary: A Ruby port of the ot.js Operational Transformation library
|
127
|
+
test_files: []
|