mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #16934 from overleaf/ae-sharejs-readme
Add LICENSE and README for forked ShareJS code GitOrigin-RevId: f24abcc5d9136f9bfbd651ef4087dae7aec00e12
This commit is contained in:
parent
8f9a3cc6a0
commit
14e14be8ef
18 changed files with 73 additions and 3693 deletions
22
services/document-updater/app/js/sharejs/LICENSE
Normal file
22
services/document-updater/app/js/sharejs/LICENSE
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
Licensed under the standard MIT license:
|
||||||
|
|
||||||
|
Copyright 2011 Joseph Gentle.
|
||||||
|
Copyright 2012-2024 Overleaf.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
|
@ -1,48 +1,6 @@
|
||||||
This directory contains all the operational transform code. Each file defines a type.
|
This folder contains a modified version of the ShareJS source code, forked from [v0.5.0](https://github.com/josephg/ShareJS/tree/v0.5.0/).
|
||||||
|
|
||||||
Most of the types in here are for testing or demonstration. The only types which are sent to the webclient
|
The original CoffeeScript code has been decaffeinated to JavaScript, and further modified. Some folders have been removed. See https://github.com/josephg/ShareJS/blob/v0.5.0/src/types/README.md for the original README.
|
||||||
are `text` and `json`.
|
|
||||||
|
|
||||||
|
The original code, and the current modified code in this directory, are published under the MIT license.
|
||||||
|
|
||||||
# An OT type
|
|
||||||
|
|
||||||
All OT types have the following fields:
|
|
||||||
|
|
||||||
`name`: _(string)_ Name of the type. Should match the filename.
|
|
||||||
`create() -> snapshot`: Function which creates and returns a new document snapshot
|
|
||||||
|
|
||||||
`apply(snapshot, op) -> snapshot`: A function which creates a new document snapshot with the op applied
|
|
||||||
`transform(op1, op2, side) -> op1'`: OT transform function.
|
|
||||||
|
|
||||||
Given op1, op2, `apply(s, op2, transform(op1, op2, 'left')) == apply(s, op1, transform(op2, op1, 'right'))`.
|
|
||||||
|
|
||||||
Transform and apply must never modify their arguments.
|
|
||||||
|
|
||||||
|
|
||||||
Optional properties:
|
|
||||||
|
|
||||||
`tp2`: _(bool)_ True if the transform function supports TP2. This allows p2p architectures to work.
|
|
||||||
`compose(op1, op2) -> op`: Create and return a new op which has the same effect as op1 + op2.
|
|
||||||
`serialize(snapshot) -> JSON object`: Serialize a document to something we can JSON.stringify()
|
|
||||||
`deserialize(object) -> snapshot`: Deserialize a JSON object into the document's internal snapshot format
|
|
||||||
`prune(op1', op2, side) -> op1`: Inserse transform function. Only required for TP2 types.
|
|
||||||
`normalize(op) -> op`: Fix up an op to make it valid. Eg, remove skips of size zero.
|
|
||||||
`api`: _(object)_ Set of helper methods which will be mixed in to the client document object for manipulating documents. See below.
|
|
||||||
|
|
||||||
|
|
||||||
# Examples
|
|
||||||
|
|
||||||
`count` and `simple` are two trivial OT type definitions if you want to take a look. JSON defines
|
|
||||||
the ot-for-JSON type (see the wiki for documentation) and all the text types define different text
|
|
||||||
implementations. (I still have no idea which one I like the most, and they're fun to write!)
|
|
||||||
|
|
||||||
|
|
||||||
# API
|
|
||||||
|
|
||||||
Types can also define API functions. These methods are mixed into the client's Doc object when a document is created.
|
|
||||||
You can use them to help construct ops programatically (so users don't need to understand how ops are structured).
|
|
||||||
|
|
||||||
For example, the three text types defined here (text, text-composable and text-tp2) all provide the text API, supplying
|
|
||||||
`.insert()`, `.del()`, `.getLength` and `.getText` methods.
|
|
||||||
|
|
||||||
See text-api.coffee for an example.
|
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Sanity-check the conversion and remove this comment.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// This is a simple type used for testing other OT code. Each op is [expectedSnapshot, increment]
|
|
||||||
|
|
||||||
exports.name = 'count'
|
|
||||||
exports.create = () => 1
|
|
||||||
|
|
||||||
exports.apply = function (snapshot, op) {
|
|
||||||
const [v, inc] = Array.from(op)
|
|
||||||
if (snapshot !== v) {
|
|
||||||
throw new Error(`Op ${v} != snapshot ${snapshot}`)
|
|
||||||
}
|
|
||||||
return snapshot + inc
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform op1 by op2. Return transformed version of op1.
|
|
||||||
exports.transform = function (op1, op2) {
|
|
||||||
if (op1[0] !== op2[0]) {
|
|
||||||
throw new Error(`Op1 ${op1[0]} != op2 ${op2[0]}`)
|
|
||||||
}
|
|
||||||
return [op1[0] + op2[1], op1[1]]
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.compose = function (op1, op2) {
|
|
||||||
if (op1[0] + op1[1] !== op2[0]) {
|
|
||||||
throw new Error(`Op1 ${op1} + 1 != op2 ${op2}`)
|
|
||||||
}
|
|
||||||
return [op1[0], op1[1] + op2[1]]
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.generateRandomOp = doc => [[doc, 1], doc + 1]
|
|
|
@ -1,116 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-return-assign,
|
|
||||||
no-unused-vars,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// These methods let you build a transform function from a transformComponent function
|
|
||||||
// for OT types like text and JSON in which operations are lists of components
|
|
||||||
// and transforming them requires N^2 work.
|
|
||||||
|
|
||||||
// Add transform and transformX functions for an OT type which has transformComponent defined.
|
|
||||||
// transformComponent(destination array, component, other component, side)
|
|
||||||
let bootstrapTransform
|
|
||||||
exports._bt = bootstrapTransform = function (
|
|
||||||
type,
|
|
||||||
transformComponent,
|
|
||||||
checkValidOp,
|
|
||||||
append
|
|
||||||
) {
|
|
||||||
let transformX
|
|
||||||
const transformComponentX = function (left, right, destLeft, destRight) {
|
|
||||||
transformComponent(destLeft, left, right, 'left')
|
|
||||||
return transformComponent(destRight, right, left, 'right')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transforms rightOp by leftOp. Returns ['rightOp', clientOp']
|
|
||||||
type.transformX =
|
|
||||||
type.transformX =
|
|
||||||
transformX =
|
|
||||||
function (leftOp, rightOp) {
|
|
||||||
checkValidOp(leftOp)
|
|
||||||
checkValidOp(rightOp)
|
|
||||||
|
|
||||||
const newRightOp = []
|
|
||||||
|
|
||||||
for (let rightComponent of Array.from(rightOp)) {
|
|
||||||
// Generate newLeftOp by composing leftOp by rightComponent
|
|
||||||
const newLeftOp = []
|
|
||||||
|
|
||||||
let k = 0
|
|
||||||
while (k < leftOp.length) {
|
|
||||||
let l
|
|
||||||
const nextC = []
|
|
||||||
transformComponentX(leftOp[k], rightComponent, newLeftOp, nextC)
|
|
||||||
k++
|
|
||||||
|
|
||||||
if (nextC.length === 1) {
|
|
||||||
rightComponent = nextC[0]
|
|
||||||
} else if (nextC.length === 0) {
|
|
||||||
for (l of Array.from(leftOp.slice(k))) {
|
|
||||||
append(newLeftOp, l)
|
|
||||||
}
|
|
||||||
rightComponent = null
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
// Recurse.
|
|
||||||
const [l_, r_] = Array.from(transformX(leftOp.slice(k), nextC))
|
|
||||||
for (l of Array.from(l_)) {
|
|
||||||
append(newLeftOp, l)
|
|
||||||
}
|
|
||||||
for (const r of Array.from(r_)) {
|
|
||||||
append(newRightOp, r)
|
|
||||||
}
|
|
||||||
rightComponent = null
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rightComponent != null) {
|
|
||||||
append(newRightOp, rightComponent)
|
|
||||||
}
|
|
||||||
leftOp = newLeftOp
|
|
||||||
}
|
|
||||||
|
|
||||||
return [leftOp, newRightOp]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transforms op with specified type ('left' or 'right') by otherOp.
|
|
||||||
return (type.transform = type.transform =
|
|
||||||
function (op, otherOp, type) {
|
|
||||||
let _
|
|
||||||
if (type !== 'left' && type !== 'right') {
|
|
||||||
throw new Error("type must be 'left' or 'right'")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherOp.length === 0) {
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Benchmark with and without this line. I _think_ it'll make a big difference...?
|
|
||||||
if (op.length === 1 && otherOp.length === 1) {
|
|
||||||
return transformComponent([], op[0], otherOp[0], type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'left') {
|
|
||||||
let left
|
|
||||||
;[left, _] = Array.from(transformX(op, otherOp))
|
|
||||||
return left
|
|
||||||
} else {
|
|
||||||
let right
|
|
||||||
;[_, right] = Array.from(transformX(otherOp, op))
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WEB === 'undefined') {
|
|
||||||
exports.bootstrapTransform = bootstrapTransform
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Sanity-check the conversion and remove this comment.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
const register = function (file) {
|
|
||||||
const type = require(file)
|
|
||||||
exports[type.name] = type
|
|
||||||
try {
|
|
||||||
return require(`${file}-api`)
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import all the built-in types.
|
|
||||||
register('./simple')
|
|
||||||
register('./count')
|
|
||||||
|
|
||||||
register('./text')
|
|
||||||
register('./text-composable')
|
|
||||||
register('./text-tp2')
|
|
||||||
|
|
||||||
register('./json')
|
|
|
@ -1,356 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-undef,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// API for JSON OT
|
|
||||||
|
|
||||||
let json
|
|
||||||
if (typeof WEB === 'undefined') {
|
|
||||||
json = require('./json')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
const { extendDoc } = exports
|
|
||||||
exports.extendDoc = function (name, fn) {
|
|
||||||
SubDoc.prototype[name] = fn
|
|
||||||
return extendDoc(name, fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const depath = function (path) {
|
|
||||||
if (path.length === 1 && path[0].constructor === Array) {
|
|
||||||
return path[0]
|
|
||||||
} else {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubDoc {
|
|
||||||
constructor(doc, path) {
|
|
||||||
this.doc = doc
|
|
||||||
this.path = path
|
|
||||||
}
|
|
||||||
|
|
||||||
at(...path) {
|
|
||||||
return this.doc.at(this.path.concat(depath(path)))
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return this.doc.getAt(this.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// for objects and lists
|
|
||||||
set(value, cb) {
|
|
||||||
return this.doc.setAt(this.path, value, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// for strings and lists.
|
|
||||||
insert(pos, value, cb) {
|
|
||||||
return this.doc.insertAt(this.path, pos, value, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// for strings
|
|
||||||
del(pos, length, cb) {
|
|
||||||
return this.doc.deleteTextAt(this.path, length, pos, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// for objects and lists
|
|
||||||
remove(cb) {
|
|
||||||
return this.doc.removeAt(this.path, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
push(value, cb) {
|
|
||||||
return this.insert(this.get().length, value, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
move(from, to, cb) {
|
|
||||||
return this.doc.moveAt(this.path, from, to, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
add(amount, cb) {
|
|
||||||
return this.doc.addAt(this.path, amount, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
on(event, cb) {
|
|
||||||
return this.doc.addListener(this.path, event, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeListener(l) {
|
|
||||||
return this.doc.removeListener(l)
|
|
||||||
}
|
|
||||||
|
|
||||||
// text API compatibility
|
|
||||||
getLength() {
|
|
||||||
return this.get().length
|
|
||||||
}
|
|
||||||
|
|
||||||
getText() {
|
|
||||||
return this.get()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const traverse = function (snapshot, path) {
|
|
||||||
const container = { data: snapshot }
|
|
||||||
let key = 'data'
|
|
||||||
let elem = container
|
|
||||||
for (const p of Array.from(path)) {
|
|
||||||
elem = elem[key]
|
|
||||||
key = p
|
|
||||||
if (typeof elem === 'undefined') {
|
|
||||||
throw new Error('bad path')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { elem, key }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathEquals = function (p1, p2) {
|
|
||||||
if (p1.length !== p2.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for (let i = 0; i < p1.length; i++) {
|
|
||||||
const e = p1[i]
|
|
||||||
if (e !== p2[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
json.api = {
|
|
||||||
provides: { json: true },
|
|
||||||
|
|
||||||
at(...path) {
|
|
||||||
return new SubDoc(this, depath(path))
|
|
||||||
},
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return this.snapshot
|
|
||||||
},
|
|
||||||
set(value, cb) {
|
|
||||||
return this.setAt([], value, cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
getAt(path) {
|
|
||||||
const { elem, key } = traverse(this.snapshot, path)
|
|
||||||
return elem[key]
|
|
||||||
},
|
|
||||||
|
|
||||||
setAt(path, value, cb) {
|
|
||||||
const { elem, key } = traverse(this.snapshot, path)
|
|
||||||
const op = { p: path }
|
|
||||||
if (elem.constructor === Array) {
|
|
||||||
op.li = value
|
|
||||||
if (typeof elem[key] !== 'undefined') {
|
|
||||||
op.ld = elem[key]
|
|
||||||
}
|
|
||||||
} else if (typeof elem === 'object') {
|
|
||||||
op.oi = value
|
|
||||||
if (typeof elem[key] !== 'undefined') {
|
|
||||||
op.od = elem[key]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('bad path')
|
|
||||||
}
|
|
||||||
return this.submitOp([op], cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
removeAt(path, cb) {
|
|
||||||
const { elem, key } = traverse(this.snapshot, path)
|
|
||||||
if (typeof elem[key] === 'undefined') {
|
|
||||||
throw new Error('no element at that path')
|
|
||||||
}
|
|
||||||
const op = { p: path }
|
|
||||||
if (elem.constructor === Array) {
|
|
||||||
op.ld = elem[key]
|
|
||||||
} else if (typeof elem === 'object') {
|
|
||||||
op.od = elem[key]
|
|
||||||
} else {
|
|
||||||
throw new Error('bad path')
|
|
||||||
}
|
|
||||||
return this.submitOp([op], cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
insertAt(path, pos, value, cb) {
|
|
||||||
const { elem, key } = traverse(this.snapshot, path)
|
|
||||||
const op = { p: path.concat(pos) }
|
|
||||||
if (elem[key].constructor === Array) {
|
|
||||||
op.li = value
|
|
||||||
} else if (typeof elem[key] === 'string') {
|
|
||||||
op.si = value
|
|
||||||
}
|
|
||||||
return this.submitOp([op], cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
moveAt(path, from, to, cb) {
|
|
||||||
const op = [{ p: path.concat(from), lm: to }]
|
|
||||||
return this.submitOp(op, cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
addAt(path, amount, cb) {
|
|
||||||
const op = [{ p: path, na: amount }]
|
|
||||||
return this.submitOp(op, cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteTextAt(path, length, pos, cb) {
|
|
||||||
const { elem, key } = traverse(this.snapshot, path)
|
|
||||||
const op = [{ p: path.concat(pos), sd: elem[key].slice(pos, pos + length) }]
|
|
||||||
return this.submitOp(op, cb)
|
|
||||||
},
|
|
||||||
|
|
||||||
addListener(path, event, cb) {
|
|
||||||
const l = { path, event, cb }
|
|
||||||
this._listeners.push(l)
|
|
||||||
return l
|
|
||||||
},
|
|
||||||
removeListener(l) {
|
|
||||||
const i = this._listeners.indexOf(l)
|
|
||||||
if (i < 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
this._listeners.splice(i, 1)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
_register() {
|
|
||||||
this._listeners = []
|
|
||||||
this.on('change', function (op) {
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
let i
|
|
||||||
if (c.na !== undefined || c.si !== undefined || c.sd !== undefined) {
|
|
||||||
// no change to structure
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const toRemove = []
|
|
||||||
for (i = 0; i < this._listeners.length; i++) {
|
|
||||||
// Transform a dummy op by the incoming op to work out what
|
|
||||||
// should happen to the listener.
|
|
||||||
const l = this._listeners[i]
|
|
||||||
const dummy = { p: l.path, na: 0 }
|
|
||||||
const xformed = this.type.transformComponent([], dummy, c, 'left')
|
|
||||||
if (xformed.length === 0) {
|
|
||||||
// The op was transformed to noop, so we should delete the listener.
|
|
||||||
toRemove.push(i)
|
|
||||||
} else if (xformed.length === 1) {
|
|
||||||
// The op remained, so grab its new path into the listener.
|
|
||||||
l.path = xformed[0].p
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toRemove.sort((a, b) => b - a)
|
|
||||||
result.push(
|
|
||||||
(() => {
|
|
||||||
const result1 = []
|
|
||||||
for (i of Array.from(toRemove)) {
|
|
||||||
result1.push(this._listeners.splice(i, 1))
|
|
||||||
}
|
|
||||||
return result1
|
|
||||||
})()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
return this.on('remoteop', function (op) {
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
const matchPath =
|
|
||||||
c.na === undefined ? c.p.slice(0, c.p.length - 1) : c.p
|
|
||||||
result.push(
|
|
||||||
(() => {
|
|
||||||
const result1 = []
|
|
||||||
for (const { path, event, cb } of Array.from(this._listeners)) {
|
|
||||||
let common
|
|
||||||
if (pathEquals(path, matchPath)) {
|
|
||||||
switch (event) {
|
|
||||||
case 'insert':
|
|
||||||
if (c.li !== undefined && c.ld === undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.li))
|
|
||||||
} else if (c.oi !== undefined && c.od === undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.oi))
|
|
||||||
} else if (c.si !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.si))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'delete':
|
|
||||||
if (c.li === undefined && c.ld !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.ld))
|
|
||||||
} else if (c.oi === undefined && c.od !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.od))
|
|
||||||
} else if (c.sd !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.sd))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'replace':
|
|
||||||
if (c.li !== undefined && c.ld !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.ld, c.li))
|
|
||||||
} else if (c.oi !== undefined && c.od !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.od, c.oi))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'move':
|
|
||||||
if (c.lm !== undefined) {
|
|
||||||
result1.push(cb(c.p[c.p.length - 1], c.lm))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'add':
|
|
||||||
if (c.na !== undefined) {
|
|
||||||
result1.push(cb(c.na))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
(common = this.type.commonPath(matchPath, path)) != null
|
|
||||||
) {
|
|
||||||
if (event === 'child op') {
|
|
||||||
if (
|
|
||||||
matchPath.length === path.length &&
|
|
||||||
path.length === common
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"paths match length and have commonality, but aren't equal?"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const childPath = c.p.slice(common + 1)
|
|
||||||
result1.push(cb(childPath, c))
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result1.push(undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result1
|
|
||||||
})()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,630 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-return-assign,
|
|
||||||
no-undef,
|
|
||||||
no-useless-catch,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// This is the implementation of the JSON OT type.
|
|
||||||
//
|
|
||||||
// Spec is here: https://github.com/josephg/ShareJS/wiki/JSON-Operations
|
|
||||||
|
|
||||||
let text
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
;({ text } = exports.types)
|
|
||||||
} else {
|
|
||||||
text = require('./text')
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = {}
|
|
||||||
|
|
||||||
json.name = 'json'
|
|
||||||
|
|
||||||
json.create = () => null
|
|
||||||
|
|
||||||
json.invertComponent = function (c) {
|
|
||||||
const c_ = { p: c.p }
|
|
||||||
if (c.si !== undefined) {
|
|
||||||
c_.sd = c.si
|
|
||||||
}
|
|
||||||
if (c.sd !== undefined) {
|
|
||||||
c_.si = c.sd
|
|
||||||
}
|
|
||||||
if (c.oi !== undefined) {
|
|
||||||
c_.od = c.oi
|
|
||||||
}
|
|
||||||
if (c.od !== undefined) {
|
|
||||||
c_.oi = c.od
|
|
||||||
}
|
|
||||||
if (c.li !== undefined) {
|
|
||||||
c_.ld = c.li
|
|
||||||
}
|
|
||||||
if (c.ld !== undefined) {
|
|
||||||
c_.li = c.ld
|
|
||||||
}
|
|
||||||
if (c.na !== undefined) {
|
|
||||||
c_.na = -c.na
|
|
||||||
}
|
|
||||||
if (c.lm !== undefined) {
|
|
||||||
c_.lm = c.p[c.p.length - 1]
|
|
||||||
c_.p = c.p.slice(0, c.p.length - 1).concat([c.lm])
|
|
||||||
}
|
|
||||||
return c_
|
|
||||||
}
|
|
||||||
|
|
||||||
json.invert = op =>
|
|
||||||
Array.from(op.slice().reverse()).map(c => json.invertComponent(c))
|
|
||||||
|
|
||||||
json.checkValidOp = function (op) {}
|
|
||||||
|
|
||||||
const isArray = o => Object.prototype.toString.call(o) === '[object Array]'
|
|
||||||
json.checkList = function (elem) {
|
|
||||||
if (!isArray(elem)) {
|
|
||||||
throw new Error('Referenced element not a list')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.checkObj = function (elem) {
|
|
||||||
if (elem.constructor !== Object) {
|
|
||||||
throw new Error(
|
|
||||||
`Referenced element not an object (it was ${JSON.stringify(elem)})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.apply = function (snapshot, op) {
|
|
||||||
json.checkValidOp(op)
|
|
||||||
op = clone(op)
|
|
||||||
|
|
||||||
const container = { data: clone(snapshot) }
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < op.length; i++) {
|
|
||||||
const c = op[i]
|
|
||||||
let parent = null
|
|
||||||
let parentkey = null
|
|
||||||
let elem = container
|
|
||||||
let key = 'data'
|
|
||||||
|
|
||||||
for (const p of Array.from(c.p)) {
|
|
||||||
parent = elem
|
|
||||||
parentkey = key
|
|
||||||
elem = elem[key]
|
|
||||||
key = p
|
|
||||||
|
|
||||||
if (parent == null) {
|
|
||||||
throw new Error('Path invalid')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.na !== undefined) {
|
|
||||||
// Number add
|
|
||||||
if (typeof elem[key] !== 'number') {
|
|
||||||
throw new Error('Referenced element not a number')
|
|
||||||
}
|
|
||||||
elem[key] += c.na
|
|
||||||
} else if (c.si !== undefined) {
|
|
||||||
// String insert
|
|
||||||
if (typeof elem !== 'string') {
|
|
||||||
throw new Error(
|
|
||||||
`Referenced element not a string (it was ${JSON.stringify(elem)})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
parent[parentkey] = elem.slice(0, key) + c.si + elem.slice(key)
|
|
||||||
} else if (c.sd !== undefined) {
|
|
||||||
// String delete
|
|
||||||
if (typeof elem !== 'string') {
|
|
||||||
throw new Error('Referenced element not a string')
|
|
||||||
}
|
|
||||||
if (elem.slice(key, key + c.sd.length) !== c.sd) {
|
|
||||||
throw new Error('Deleted string does not match')
|
|
||||||
}
|
|
||||||
parent[parentkey] = elem.slice(0, key) + elem.slice(key + c.sd.length)
|
|
||||||
} else if (c.li !== undefined && c.ld !== undefined) {
|
|
||||||
// List replace
|
|
||||||
json.checkList(elem)
|
|
||||||
|
|
||||||
// Should check the list element matches c.ld
|
|
||||||
elem[key] = c.li
|
|
||||||
} else if (c.li !== undefined) {
|
|
||||||
// List insert
|
|
||||||
json.checkList(elem)
|
|
||||||
|
|
||||||
elem.splice(key, 0, c.li)
|
|
||||||
} else if (c.ld !== undefined) {
|
|
||||||
// List delete
|
|
||||||
json.checkList(elem)
|
|
||||||
|
|
||||||
// Should check the list element matches c.ld here too.
|
|
||||||
elem.splice(key, 1)
|
|
||||||
} else if (c.lm !== undefined) {
|
|
||||||
// List move
|
|
||||||
json.checkList(elem)
|
|
||||||
if (c.lm !== key) {
|
|
||||||
const e = elem[key]
|
|
||||||
// Remove it...
|
|
||||||
elem.splice(key, 1)
|
|
||||||
// And insert it back.
|
|
||||||
elem.splice(c.lm, 0, e)
|
|
||||||
}
|
|
||||||
} else if (c.oi !== undefined) {
|
|
||||||
// Object insert / replace
|
|
||||||
json.checkObj(elem)
|
|
||||||
|
|
||||||
// Should check that elem[key] == c.od
|
|
||||||
elem[key] = c.oi
|
|
||||||
} else if (c.od !== undefined) {
|
|
||||||
// Object delete
|
|
||||||
json.checkObj(elem)
|
|
||||||
|
|
||||||
// Should check that elem[key] == c.od
|
|
||||||
delete elem[key]
|
|
||||||
} else {
|
|
||||||
throw new Error('invalid / missing instruction in op')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// TODO: Roll back all already applied changes. Write tests before implementing this code.
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return container.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks if two paths, p1 and p2 match.
|
|
||||||
json.pathMatches = function (p1, p2, ignoreLast) {
|
|
||||||
if (p1.length !== p2.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < p1.length; i++) {
|
|
||||||
const p = p1[i]
|
|
||||||
if (p !== p2[i] && (!ignoreLast || i !== p1.length - 1)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
json.append = function (dest, c) {
|
|
||||||
let last
|
|
||||||
c = clone(c)
|
|
||||||
if (
|
|
||||||
dest.length !== 0 &&
|
|
||||||
json.pathMatches(c.p, (last = dest[dest.length - 1]).p)
|
|
||||||
) {
|
|
||||||
if (last.na !== undefined && c.na !== undefined) {
|
|
||||||
return (dest[dest.length - 1] = { p: last.p, na: last.na + c.na })
|
|
||||||
} else if (
|
|
||||||
last.li !== undefined &&
|
|
||||||
c.li === undefined &&
|
|
||||||
c.ld === last.li
|
|
||||||
) {
|
|
||||||
// insert immediately followed by delete becomes a noop.
|
|
||||||
if (last.ld !== undefined) {
|
|
||||||
// leave the delete part of the replace
|
|
||||||
return delete last.li
|
|
||||||
} else {
|
|
||||||
return dest.pop()
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
last.od !== undefined &&
|
|
||||||
last.oi === undefined &&
|
|
||||||
c.oi !== undefined &&
|
|
||||||
c.od === undefined
|
|
||||||
) {
|
|
||||||
return (last.oi = c.oi)
|
|
||||||
} else if (c.lm !== undefined && c.p[c.p.length - 1] === c.lm) {
|
|
||||||
return null // don't do anything
|
|
||||||
} else {
|
|
||||||
return dest.push(c)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return dest.push(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.compose = function (op1, op2) {
|
|
||||||
json.checkValidOp(op1)
|
|
||||||
json.checkValidOp(op2)
|
|
||||||
|
|
||||||
const newOp = clone(op1)
|
|
||||||
for (const c of Array.from(op2)) {
|
|
||||||
json.append(newOp, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
json.normalize = function (op) {
|
|
||||||
const newOp = []
|
|
||||||
|
|
||||||
if (!isArray(op)) {
|
|
||||||
op = [op]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
if (c.p == null) {
|
|
||||||
c.p = []
|
|
||||||
}
|
|
||||||
json.append(newOp, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// hax, copied from test/types/json. Apparently this is still the fastest way to deep clone an object, assuming
|
|
||||||
// we have browser support for JSON.
|
|
||||||
// http://jsperf.com/cloning-an-object/12
|
|
||||||
const clone = o => JSON.parse(JSON.stringify(o))
|
|
||||||
|
|
||||||
json.commonPath = function (p1, p2) {
|
|
||||||
p1 = p1.slice()
|
|
||||||
p2 = p2.slice()
|
|
||||||
p1.unshift('data')
|
|
||||||
p2.unshift('data')
|
|
||||||
p1 = p1.slice(0, p1.length - 1)
|
|
||||||
p2 = p2.slice(0, p2.length - 1)
|
|
||||||
if (p2.length === 0) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
let i = 0
|
|
||||||
while (p1[i] === p2[i] && i < p1.length) {
|
|
||||||
i++
|
|
||||||
if (i === p2.length) {
|
|
||||||
return i - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform c so it applies to a document with otherC applied.
|
|
||||||
json.transformComponent = function (dest, c, otherC, type) {
|
|
||||||
let oc
|
|
||||||
c = clone(c)
|
|
||||||
if (c.na !== undefined) {
|
|
||||||
c.p.push(0)
|
|
||||||
}
|
|
||||||
if (otherC.na !== undefined) {
|
|
||||||
otherC.p.push(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const common = json.commonPath(c.p, otherC.p)
|
|
||||||
const common2 = json.commonPath(otherC.p, c.p)
|
|
||||||
|
|
||||||
const cplength = c.p.length
|
|
||||||
const otherCplength = otherC.p.length
|
|
||||||
|
|
||||||
if (c.na !== undefined) {
|
|
||||||
c.p.pop()
|
|
||||||
} // hax
|
|
||||||
if (otherC.na !== undefined) {
|
|
||||||
otherC.p.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherC.na) {
|
|
||||||
if (
|
|
||||||
common2 != null &&
|
|
||||||
otherCplength >= cplength &&
|
|
||||||
otherC.p[common2] === c.p[common2]
|
|
||||||
) {
|
|
||||||
if (c.ld !== undefined) {
|
|
||||||
oc = clone(otherC)
|
|
||||||
oc.p = oc.p.slice(cplength)
|
|
||||||
c.ld = json.apply(clone(c.ld), [oc])
|
|
||||||
} else if (c.od !== undefined) {
|
|
||||||
oc = clone(otherC)
|
|
||||||
oc.p = oc.p.slice(cplength)
|
|
||||||
c.od = json.apply(clone(c.od), [oc])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
json.append(dest, c)
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
common2 != null &&
|
|
||||||
otherCplength > cplength &&
|
|
||||||
c.p[common2] === otherC.p[common2]
|
|
||||||
) {
|
|
||||||
// transform based on c
|
|
||||||
if (c.ld !== undefined) {
|
|
||||||
oc = clone(otherC)
|
|
||||||
oc.p = oc.p.slice(cplength)
|
|
||||||
c.ld = json.apply(clone(c.ld), [oc])
|
|
||||||
} else if (c.od !== undefined) {
|
|
||||||
oc = clone(otherC)
|
|
||||||
oc.p = oc.p.slice(cplength)
|
|
||||||
c.od = json.apply(clone(c.od), [oc])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (common != null) {
|
|
||||||
let from, p, to
|
|
||||||
const commonOperand = cplength === otherCplength
|
|
||||||
// transform based on otherC
|
|
||||||
if (otherC.na !== undefined) {
|
|
||||||
// this case is handled above due to icky path hax
|
|
||||||
} else if (otherC.si !== undefined || otherC.sd !== undefined) {
|
|
||||||
// String op vs string op - pass through to text type
|
|
||||||
if (c.si !== undefined || c.sd !== undefined) {
|
|
||||||
if (!commonOperand) {
|
|
||||||
throw new Error('must be a string?')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert an op component to a text op component
|
|
||||||
const convert = function (component) {
|
|
||||||
const newC = { p: component.p[component.p.length - 1] }
|
|
||||||
if (component.si) {
|
|
||||||
newC.i = component.si
|
|
||||||
} else {
|
|
||||||
newC.d = component.sd
|
|
||||||
}
|
|
||||||
return newC
|
|
||||||
}
|
|
||||||
|
|
||||||
const tc1 = convert(c)
|
|
||||||
const tc2 = convert(otherC)
|
|
||||||
|
|
||||||
const res = []
|
|
||||||
text._tc(res, tc1, tc2, type)
|
|
||||||
for (const tc of Array.from(res)) {
|
|
||||||
const jc = { p: c.p.slice(0, common) }
|
|
||||||
jc.p.push(tc.p)
|
|
||||||
if (tc.i != null) {
|
|
||||||
jc.si = tc.i
|
|
||||||
}
|
|
||||||
if (tc.d != null) {
|
|
||||||
jc.sd = tc.d
|
|
||||||
}
|
|
||||||
json.append(dest, jc)
|
|
||||||
}
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
} else if (otherC.li !== undefined && otherC.ld !== undefined) {
|
|
||||||
if (otherC.p[common] === c.p[common]) {
|
|
||||||
// noop
|
|
||||||
if (!commonOperand) {
|
|
||||||
// we're below the deleted element, so -> noop
|
|
||||||
return dest
|
|
||||||
} else if (c.ld !== undefined) {
|
|
||||||
// we're trying to delete the same element, -> noop
|
|
||||||
if (c.li !== undefined && type === 'left') {
|
|
||||||
// we're both replacing one element with another. only one can
|
|
||||||
// survive!
|
|
||||||
c.ld = clone(otherC.li)
|
|
||||||
} else {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (otherC.li !== undefined) {
|
|
||||||
if (
|
|
||||||
c.li !== undefined &&
|
|
||||||
c.ld === undefined &&
|
|
||||||
commonOperand &&
|
|
||||||
c.p[common] === otherC.p[common]
|
|
||||||
) {
|
|
||||||
// in li vs. li, left wins.
|
|
||||||
if (type === 'right') {
|
|
||||||
c.p[common]++
|
|
||||||
}
|
|
||||||
} else if (otherC.p[common] <= c.p[common]) {
|
|
||||||
c.p[common]++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.lm !== undefined) {
|
|
||||||
if (commonOperand) {
|
|
||||||
// otherC edits the same list we edit
|
|
||||||
if (otherC.p[common] <= c.lm) {
|
|
||||||
c.lm++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// changing c.from is handled above.
|
|
||||||
} else if (otherC.ld !== undefined) {
|
|
||||||
if (c.lm !== undefined) {
|
|
||||||
if (commonOperand) {
|
|
||||||
if (otherC.p[common] === c.p[common]) {
|
|
||||||
// they deleted the thing we're trying to move
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
// otherC edits the same list we edit
|
|
||||||
p = otherC.p[common]
|
|
||||||
from = c.p[common]
|
|
||||||
to = c.lm
|
|
||||||
if (p < to || (p === to && from < to)) {
|
|
||||||
c.lm--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherC.p[common] < c.p[common]) {
|
|
||||||
c.p[common]--
|
|
||||||
} else if (otherC.p[common] === c.p[common]) {
|
|
||||||
if (otherCplength < cplength) {
|
|
||||||
// we're below the deleted element, so -> noop
|
|
||||||
return dest
|
|
||||||
} else if (c.ld !== undefined) {
|
|
||||||
if (c.li !== undefined) {
|
|
||||||
// we're replacing, they're deleting. we become an insert.
|
|
||||||
delete c.ld
|
|
||||||
} else {
|
|
||||||
// we're trying to delete the same element, -> noop
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (otherC.lm !== undefined) {
|
|
||||||
if (c.lm !== undefined && cplength === otherCplength) {
|
|
||||||
// lm vs lm, here we go!
|
|
||||||
from = c.p[common]
|
|
||||||
to = c.lm
|
|
||||||
const otherFrom = otherC.p[common]
|
|
||||||
const otherTo = otherC.lm
|
|
||||||
if (otherFrom !== otherTo) {
|
|
||||||
// if otherFrom == otherTo, we don't need to change our op.
|
|
||||||
|
|
||||||
// where did my thing go?
|
|
||||||
if (from === otherFrom) {
|
|
||||||
// they moved it! tie break.
|
|
||||||
if (type === 'left') {
|
|
||||||
c.p[common] = otherTo
|
|
||||||
if (from === to) {
|
|
||||||
// ugh
|
|
||||||
c.lm = otherTo
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// they moved around it
|
|
||||||
if (from > otherFrom) {
|
|
||||||
c.p[common]--
|
|
||||||
}
|
|
||||||
if (from > otherTo) {
|
|
||||||
c.p[common]++
|
|
||||||
} else if (from === otherTo) {
|
|
||||||
if (otherFrom > otherTo) {
|
|
||||||
c.p[common]++
|
|
||||||
if (from === to) {
|
|
||||||
// ugh, again
|
|
||||||
c.lm++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// step 2: where am i going to put it?
|
|
||||||
if (to > otherFrom) {
|
|
||||||
c.lm--
|
|
||||||
} else if (to === otherFrom) {
|
|
||||||
if (to > from) {
|
|
||||||
c.lm--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (to > otherTo) {
|
|
||||||
c.lm++
|
|
||||||
} else if (to === otherTo) {
|
|
||||||
// if we're both moving in the same direction, tie break
|
|
||||||
if (
|
|
||||||
(otherTo > otherFrom && to > from) ||
|
|
||||||
(otherTo < otherFrom && to < from)
|
|
||||||
) {
|
|
||||||
if (type === 'right') {
|
|
||||||
c.lm++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (to > from) {
|
|
||||||
c.lm++
|
|
||||||
} else if (to === otherFrom) {
|
|
||||||
c.lm--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (c.li !== undefined && c.ld === undefined && commonOperand) {
|
|
||||||
// li
|
|
||||||
from = otherC.p[common]
|
|
||||||
to = otherC.lm
|
|
||||||
p = c.p[common]
|
|
||||||
if (p > from) {
|
|
||||||
c.p[common]--
|
|
||||||
}
|
|
||||||
if (p > to) {
|
|
||||||
c.p[common]++
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ld, ld+li, si, sd, na, oi, od, oi+od, any li on an element beneath
|
|
||||||
// the lm
|
|
||||||
//
|
|
||||||
// i.e. things care about where their item is after the move.
|
|
||||||
from = otherC.p[common]
|
|
||||||
to = otherC.lm
|
|
||||||
p = c.p[common]
|
|
||||||
if (p === from) {
|
|
||||||
c.p[common] = to
|
|
||||||
} else {
|
|
||||||
if (p > from) {
|
|
||||||
c.p[common]--
|
|
||||||
}
|
|
||||||
if (p > to) {
|
|
||||||
c.p[common]++
|
|
||||||
} else if (p === to) {
|
|
||||||
if (from > to) {
|
|
||||||
c.p[common]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (otherC.oi !== undefined && otherC.od !== undefined) {
|
|
||||||
if (c.p[common] === otherC.p[common]) {
|
|
||||||
if (c.oi !== undefined && commonOperand) {
|
|
||||||
// we inserted where someone else replaced
|
|
||||||
if (type === 'right') {
|
|
||||||
// left wins
|
|
||||||
return dest
|
|
||||||
} else {
|
|
||||||
// we win, make our op replace what they inserted
|
|
||||||
c.od = otherC.oi
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// -> noop if the other component is deleting the same object (or any
|
|
||||||
// parent)
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (otherC.oi !== undefined) {
|
|
||||||
if (c.oi !== undefined && c.p[common] === otherC.p[common]) {
|
|
||||||
// left wins if we try to insert at the same place
|
|
||||||
if (type === 'left') {
|
|
||||||
json.append(dest, { p: c.p, od: otherC.oi })
|
|
||||||
} else {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (otherC.od !== undefined) {
|
|
||||||
if (c.p[common] === otherC.p[common]) {
|
|
||||||
if (!commonOperand) {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
if (c.oi !== undefined) {
|
|
||||||
delete c.od
|
|
||||||
} else {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.append(dest, c)
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
if (!exports.types) {
|
|
||||||
exports.types = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is kind of awful - come up with a better way to hook this helper code up.
|
|
||||||
exports._bt(json, json.transformComponent, json.checkValidOp, json.append)
|
|
||||||
|
|
||||||
// [] is used to prevent closure from renaming types.text
|
|
||||||
exports.types.json = json
|
|
||||||
} else {
|
|
||||||
module.exports = json
|
|
||||||
|
|
||||||
require('./helpers').bootstrapTransform(
|
|
||||||
json,
|
|
||||||
json.transformComponent,
|
|
||||||
json.checkValidOp,
|
|
||||||
json.append
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,882 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-console,
|
|
||||||
no-return-assign,
|
|
||||||
n/no-callback-literal,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS103: Rewrite code to no longer use __guard__
|
|
||||||
* DS104: Avoid inline assignments
|
|
||||||
* DS204: Change includes calls to have a more natural evaluation order
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// The model of all the ops. Responsible for applying & transforming remote deltas
|
|
||||||
// and managing the storage layer.
|
|
||||||
//
|
|
||||||
// Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
|
|
||||||
|
|
||||||
let Model
|
|
||||||
const { EventEmitter } = require('events')
|
|
||||||
|
|
||||||
const queue = require('./syncqueue')
|
|
||||||
const types = require('../types')
|
|
||||||
|
|
||||||
const isArray = o => Object.prototype.toString.call(o) === '[object Array]'
|
|
||||||
|
|
||||||
// This constructor creates a new Model object. There will be one model object
|
|
||||||
// per server context.
|
|
||||||
//
|
|
||||||
// The model object is responsible for a lot of things:
|
|
||||||
//
|
|
||||||
// - It manages the interactions with the database
|
|
||||||
// - It maintains (in memory) a set of all active documents
|
|
||||||
// - It calls out to the OT functions when necessary
|
|
||||||
//
|
|
||||||
// The model is an event emitter. It emits the following events:
|
|
||||||
//
|
|
||||||
// create(docName, data): A document has been created with the specified name & data
|
|
||||||
module.exports = Model = function (db, options) {
|
|
||||||
// db can be null if the user doesn't want persistance.
|
|
||||||
|
|
||||||
let getOps
|
|
||||||
if (!(this instanceof Model)) {
|
|
||||||
return new Model(db, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = this
|
|
||||||
|
|
||||||
if (options == null) {
|
|
||||||
options = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a cache of 'live' documents.
|
|
||||||
//
|
|
||||||
// The cache is a map from docName -> {
|
|
||||||
// ops:[{op, meta}]
|
|
||||||
// snapshot
|
|
||||||
// type
|
|
||||||
// v
|
|
||||||
// meta
|
|
||||||
// eventEmitter
|
|
||||||
// reapTimer
|
|
||||||
// committedVersion: v
|
|
||||||
// snapshotWriteLock: bool to make sure writeSnapshot isn't re-entrant
|
|
||||||
// dbMeta: database specific data
|
|
||||||
// opQueue: syncQueue for processing ops
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// The ops list contains the document's last options.numCachedOps ops. (Or all
|
|
||||||
// of them if we're using a memory store).
|
|
||||||
//
|
|
||||||
// Documents are stored in this set so long as the document has been accessed in
|
|
||||||
// the last few seconds (options.reapTime) OR at least one client has the document
|
|
||||||
// open. I don't know if I should keep open (but not being edited) documents live -
|
|
||||||
// maybe if a client has a document open but the document isn't being edited, I should
|
|
||||||
// flush it from the cache.
|
|
||||||
//
|
|
||||||
// In any case, the API to model is designed such that if we want to change that later
|
|
||||||
// it should be pretty easy to do so without any external-to-the-model code changes.
|
|
||||||
const docs = {}
|
|
||||||
|
|
||||||
// This is a map from docName -> [callback]. It is used when a document hasn't been
|
|
||||||
// cached and multiple getSnapshot() / getVersion() requests come in. All requests
|
|
||||||
// are added to the callback list and called when db.getSnapshot() returns.
|
|
||||||
//
|
|
||||||
// callback(error, snapshot data)
|
|
||||||
const awaitingGetSnapshot = {}
|
|
||||||
|
|
||||||
// The time that documents which no clients have open will stay in the cache.
|
|
||||||
// Should be > 0.
|
|
||||||
if (options.reapTime == null) {
|
|
||||||
options.reapTime = 3000
|
|
||||||
}
|
|
||||||
|
|
||||||
// The number of operations the cache holds before reusing the space
|
|
||||||
if (options.numCachedOps == null) {
|
|
||||||
options.numCachedOps = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// This option forces documents to be reaped, even when there's no database backend.
|
|
||||||
// This is useful when you don't care about persistance and don't want to gradually
|
|
||||||
// fill memory.
|
|
||||||
//
|
|
||||||
// You might want to set reapTime to a day or something.
|
|
||||||
if (options.forceReaping == null) {
|
|
||||||
options.forceReaping = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Until I come up with a better strategy, we'll save a copy of the document snapshot
|
|
||||||
// to the database every ~20 submitted ops.
|
|
||||||
if (options.opsBeforeCommit == null) {
|
|
||||||
options.opsBeforeCommit = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
// It takes some processing time to transform client ops. The server will punt ops back to the
|
|
||||||
// client to transform if they're too old.
|
|
||||||
if (options.maximumAge == null) {
|
|
||||||
options.maximumAge = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
// **** Cache API methods
|
|
||||||
|
|
||||||
// Its important that all ops are applied in order. This helper method creates the op submission queue
|
|
||||||
// for a single document. This contains the logic for transforming & applying ops.
|
|
||||||
const makeOpQueue = (docName, doc) =>
|
|
||||||
queue(function (opData, callback) {
|
|
||||||
if (!(opData.v >= 0)) {
|
|
||||||
return callback('Version missing')
|
|
||||||
}
|
|
||||||
if (opData.v > doc.v) {
|
|
||||||
return callback('Op at future version')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Punt the transforming work back to the client if the op is too old.
|
|
||||||
if (opData.v + options.maximumAge < doc.v) {
|
|
||||||
return callback('Op too old')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opData.meta) {
|
|
||||||
opData.meta = {}
|
|
||||||
}
|
|
||||||
opData.meta.ts = Date.now()
|
|
||||||
|
|
||||||
// We'll need to transform the op to the current version of the document. This
|
|
||||||
// calls the callback immediately if opVersion == doc.v.
|
|
||||||
return getOps(docName, opData.v, doc.v, function (error, ops) {
|
|
||||||
let snapshot
|
|
||||||
if (error) {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc.v - opData.v !== ops.length) {
|
|
||||||
// This should never happen. It indicates that we didn't get all the ops we
|
|
||||||
// asked for. Its important that the submitted op is correctly transformed.
|
|
||||||
console.error(
|
|
||||||
`Could not get old ops in model for document ${docName}`
|
|
||||||
)
|
|
||||||
console.error(
|
|
||||||
`Expected ops ${opData.v} to ${doc.v} and got ${ops.length} ops`
|
|
||||||
)
|
|
||||||
return callback('Internal error')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ops.length > 0) {
|
|
||||||
try {
|
|
||||||
// If there's enough ops, it might be worth spinning this out into a webworker thread.
|
|
||||||
for (const oldOp of Array.from(ops)) {
|
|
||||||
// Dup detection works by sending the id(s) the op has been submitted with previously.
|
|
||||||
// If the id matches, we reject it. The client can also detect the op has been submitted
|
|
||||||
// already if it sees its own previous id in the ops it sees when it does catchup.
|
|
||||||
if (
|
|
||||||
oldOp.meta.source &&
|
|
||||||
opData.dupIfSource &&
|
|
||||||
Array.from(opData.dupIfSource).includes(oldOp.meta.source)
|
|
||||||
) {
|
|
||||||
return callback('Op already submitted')
|
|
||||||
}
|
|
||||||
|
|
||||||
opData.op = doc.type.transform(opData.op, oldOp.op, 'left')
|
|
||||||
opData.v++
|
|
||||||
}
|
|
||||||
} catch (error1) {
|
|
||||||
error = error1
|
|
||||||
return callback(error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
snapshot = doc.type.apply(doc.snapshot, opData.op)
|
|
||||||
} catch (error2) {
|
|
||||||
error = error2
|
|
||||||
return callback(error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The op data should be at the current version, and the new document data should be at
|
|
||||||
// the next version.
|
|
||||||
//
|
|
||||||
// This should never happen in practice, but its a nice little check to make sure everything
|
|
||||||
// is hunky-dory.
|
|
||||||
if (opData.v !== doc.v) {
|
|
||||||
// This should never happen.
|
|
||||||
console.error(
|
|
||||||
'Version mismatch detected in model. File a ticket - this is a bug.'
|
|
||||||
)
|
|
||||||
console.error(`Expecting ${opData.v} == ${doc.v}`)
|
|
||||||
return callback('Internal error')
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
|
|
||||||
const writeOp =
|
|
||||||
(db != null ? db.writeOp : undefined) ||
|
|
||||||
((docName, newOpData, callback) => callback())
|
|
||||||
|
|
||||||
return writeOp(docName, opData, function (error) {
|
|
||||||
if (error) {
|
|
||||||
// The user should probably know about this.
|
|
||||||
console.warn(`Error writing ops to database: ${error}`)
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
__guardMethod__(options.stats, 'writeOp', o => o.writeOp())
|
|
||||||
|
|
||||||
// This is needed when we emit the 'change' event, below.
|
|
||||||
const oldSnapshot = doc.snapshot
|
|
||||||
|
|
||||||
// All the heavy lifting is now done. Finally, we'll update the cache with the new data
|
|
||||||
// and (maybe!) save a new document snapshot to the database.
|
|
||||||
|
|
||||||
doc.v = opData.v + 1
|
|
||||||
doc.snapshot = snapshot
|
|
||||||
|
|
||||||
doc.ops.push(opData)
|
|
||||||
if (db && doc.ops.length > options.numCachedOps) {
|
|
||||||
doc.ops.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
model.emit('applyOp', docName, opData, snapshot, oldSnapshot)
|
|
||||||
doc.eventEmitter.emit('op', opData, snapshot, oldSnapshot)
|
|
||||||
|
|
||||||
// The callback is called with the version of the document at which the op was applied.
|
|
||||||
// This is the op.v after transformation, and its doc.v - 1.
|
|
||||||
callback(null, opData.v)
|
|
||||||
|
|
||||||
// I need a decent strategy here for deciding whether or not to save the snapshot.
|
|
||||||
//
|
|
||||||
// The 'right' strategy looks something like "Store the snapshot whenever the snapshot
|
|
||||||
// is smaller than the accumulated op data". For now, I'll just store it every 20
|
|
||||||
// ops or something. (Configurable with doc.committedVersion)
|
|
||||||
if (
|
|
||||||
!doc.snapshotWriteLock &&
|
|
||||||
doc.committedVersion + options.opsBeforeCommit <= doc.v
|
|
||||||
) {
|
|
||||||
return tryWriteSnapshot(docName, function (error) {
|
|
||||||
if (error) {
|
|
||||||
return console.warn(
|
|
||||||
`Error writing snapshot ${error}. This is nonfatal`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add the data for the given docName to the cache. The named document shouldn't already
|
|
||||||
// exist in the doc set.
|
|
||||||
//
|
|
||||||
// Returns the new doc.
|
|
||||||
const add = function (docName, error, data, committedVersion, ops, dbMeta) {
|
|
||||||
let callback, doc
|
|
||||||
const callbacks = awaitingGetSnapshot[docName]
|
|
||||||
delete awaitingGetSnapshot[docName]
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (callbacks) {
|
|
||||||
for (callback of Array.from(callbacks)) {
|
|
||||||
callback(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
doc = docs[docName] = {
|
|
||||||
snapshot: data.snapshot,
|
|
||||||
v: data.v,
|
|
||||||
type: data.type,
|
|
||||||
meta: data.meta,
|
|
||||||
|
|
||||||
// Cache of ops
|
|
||||||
ops: ops || [],
|
|
||||||
|
|
||||||
eventEmitter: new EventEmitter(),
|
|
||||||
|
|
||||||
// Timer before the document will be invalidated from the cache (if the document has no
|
|
||||||
// listeners)
|
|
||||||
reapTimer: null,
|
|
||||||
|
|
||||||
// Version of the snapshot thats in the database
|
|
||||||
committedVersion: committedVersion != null ? committedVersion : data.v,
|
|
||||||
snapshotWriteLock: false,
|
|
||||||
dbMeta,
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.opQueue = makeOpQueue(docName, doc)
|
|
||||||
|
|
||||||
refreshReapingTimeout(docName)
|
|
||||||
model.emit('add', docName, data)
|
|
||||||
if (callbacks) {
|
|
||||||
for (callback of Array.from(callbacks)) {
|
|
||||||
callback(null, doc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a little helper wrapper around db.getOps. It does two things:
|
|
||||||
//
|
|
||||||
// - If there's no database set, it returns an error to the callback
|
|
||||||
// - It adds version numbers to each op returned from the database
|
|
||||||
// (These can be inferred from context so the DB doesn't store them, but its useful to have them).
|
|
||||||
const getOpsInternal = function (docName, start, end, callback) {
|
|
||||||
if (!db) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('Document does not exist')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.getOps(docName, start, end, function (error, ops) {
|
|
||||||
if (error) {
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let v = start
|
|
||||||
for (const op of Array.from(ops)) {
|
|
||||||
op.v = v++
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof callback === 'function' ? callback(null, ops) : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the named document into the cache. This function is re-entrant.
|
|
||||||
//
|
|
||||||
// The callback is called with (error, doc)
|
|
||||||
const load = function (docName, callback) {
|
|
||||||
if (docs[docName]) {
|
|
||||||
// The document is already loaded. Return immediately.
|
|
||||||
__guardMethod__(options.stats, 'cacheHit', o => o.cacheHit('getSnapshot'))
|
|
||||||
return callback(null, docs[docName])
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're a memory store. If we don't have it, nobody does.
|
|
||||||
if (!db) {
|
|
||||||
return callback('Document does not exist')
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbacks = awaitingGetSnapshot[docName]
|
|
||||||
|
|
||||||
// The document is being loaded already. Add ourselves as a callback.
|
|
||||||
if (callbacks) {
|
|
||||||
return callbacks.push(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
__guardMethod__(options.stats, 'cacheMiss', o1 =>
|
|
||||||
o1.cacheMiss('getSnapshot')
|
|
||||||
)
|
|
||||||
|
|
||||||
// The document isn't loaded and isn't being loaded. Load it.
|
|
||||||
awaitingGetSnapshot[docName] = [callback]
|
|
||||||
return db.getSnapshot(docName, function (error, data, dbMeta) {
|
|
||||||
if (error) {
|
|
||||||
return add(docName, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = types[data.type]
|
|
||||||
if (!type) {
|
|
||||||
console.warn(`Type '${data.type}' missing`)
|
|
||||||
return callback('Type not found')
|
|
||||||
}
|
|
||||||
data.type = type
|
|
||||||
|
|
||||||
const committedVersion = data.v
|
|
||||||
|
|
||||||
// The server can close without saving the most recent document snapshot.
|
|
||||||
// In this case, there are extra ops which need to be applied before
|
|
||||||
// returning the snapshot.
|
|
||||||
return getOpsInternal(docName, data.v, null, function (error, ops) {
|
|
||||||
if (error) {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ops.length > 0) {
|
|
||||||
console.log(`Catchup ${docName} ${data.v} -> ${data.v + ops.length}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const op of Array.from(ops)) {
|
|
||||||
data.snapshot = type.apply(data.snapshot, op.op)
|
|
||||||
data.v++
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// This should never happen - it indicates that whats in the
|
|
||||||
// database is invalid.
|
|
||||||
console.error(`Op data invalid for ${docName}: ${e.stack}`)
|
|
||||||
return callback('Op data invalid')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model.emit('load', docName, data)
|
|
||||||
return add(docName, error, data, committedVersion, ops, dbMeta)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// This makes sure the cache contains a document. If the doc cache doesn't contain
|
|
||||||
// a document, it is loaded from the database and stored.
|
|
||||||
//
|
|
||||||
// Documents are stored so long as either:
|
|
||||||
// - They have been accessed within the past #{PERIOD}
|
|
||||||
// - At least one client has the document open
|
|
||||||
function refreshReapingTimeout(docName) {
|
|
||||||
const doc = docs[docName]
|
|
||||||
if (!doc) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// I want to let the clients list be updated before this is called.
|
|
||||||
return process.nextTick(function () {
|
|
||||||
// This is an awkward way to find out the number of clients on a document. If this
|
|
||||||
// causes performance issues, add a numClients field to the document.
|
|
||||||
//
|
|
||||||
// The first check is because its possible that between refreshReapingTimeout being called and this
|
|
||||||
// event being fired, someone called delete() on the document and hence the doc is something else now.
|
|
||||||
if (
|
|
||||||
doc === docs[docName] &&
|
|
||||||
doc.eventEmitter.listeners('op').length === 0 &&
|
|
||||||
(db || options.forceReaping) &&
|
|
||||||
doc.opQueue.busy === false
|
|
||||||
) {
|
|
||||||
let reapTimer
|
|
||||||
clearTimeout(doc.reapTimer)
|
|
||||||
return (doc.reapTimer = reapTimer =
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
tryWriteSnapshot(docName, function () {
|
|
||||||
// If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
|
|
||||||
// in the middle of applying an operation, don't reap.
|
|
||||||
if (
|
|
||||||
docs[docName].reapTimer === reapTimer &&
|
|
||||||
doc.opQueue.busy === false
|
|
||||||
) {
|
|
||||||
return delete docs[docName]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
options.reapTime
|
|
||||||
))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryWriteSnapshot(docName, callback) {
|
|
||||||
if (!db) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = docs[docName]
|
|
||||||
|
|
||||||
// The doc is closed
|
|
||||||
if (!doc) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// The document is already saved.
|
|
||||||
if (doc.committedVersion === doc.v) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc.snapshotWriteLock) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('Another snapshot write is in progress')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.snapshotWriteLock = true
|
|
||||||
|
|
||||||
__guardMethod__(options.stats, 'writeSnapshot', o => o.writeSnapshot())
|
|
||||||
|
|
||||||
const writeSnapshot =
|
|
||||||
(db != null ? db.writeSnapshot : undefined) ||
|
|
||||||
((docName, docData, dbMeta, callback) => callback())
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
v: doc.v,
|
|
||||||
meta: doc.meta,
|
|
||||||
snapshot: doc.snapshot,
|
|
||||||
// The database doesn't know about object types.
|
|
||||||
type: doc.type.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit snapshot.
|
|
||||||
return writeSnapshot(docName, data, doc.dbMeta, function (error, dbMeta) {
|
|
||||||
doc.snapshotWriteLock = false
|
|
||||||
|
|
||||||
// We have to use data.v here because the version in the doc could
|
|
||||||
// have been updated between the call to writeSnapshot() and now.
|
|
||||||
doc.committedVersion = data.v
|
|
||||||
doc.dbMeta = dbMeta
|
|
||||||
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// *** Model interface methods
|
|
||||||
|
|
||||||
// Create a new document.
|
|
||||||
//
|
|
||||||
// data should be {snapshot, type, [meta]}. The version of a new document is 0.
|
|
||||||
this.create = function (docName, type, meta, callback) {
|
|
||||||
if (typeof meta === 'function') {
|
|
||||||
;[meta, callback] = Array.from([{}, meta])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (docName.match(/\//)) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('Invalid document name')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
if (docs[docName]) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('Document already exists')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof type === 'string') {
|
|
||||||
type = types[type]
|
|
||||||
}
|
|
||||||
if (!type) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('Type not found')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
snapshot: type.create(),
|
|
||||||
type: type.name,
|
|
||||||
meta: meta || {},
|
|
||||||
v: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = function (error, dbMeta) {
|
|
||||||
// dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
|
|
||||||
if (error) {
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// From here on we'll store the object version of the type name.
|
|
||||||
data.type = type
|
|
||||||
add(docName, null, data, 0, [], dbMeta)
|
|
||||||
model.emit('create', docName, data)
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db) {
|
|
||||||
return db.create(docName, data, done)
|
|
||||||
} else {
|
|
||||||
return done()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perminantly deletes the specified document.
|
|
||||||
// If listeners are attached, they are removed.
|
|
||||||
//
|
|
||||||
// The callback is called with (error) if there was an error. If error is null / undefined, the
|
|
||||||
// document was deleted.
|
|
||||||
//
|
|
||||||
// WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
|
|
||||||
// deletion. Subsequent op submissions will fail).
|
|
||||||
this.delete = function (docName, callback) {
|
|
||||||
const doc = docs[docName]
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
clearTimeout(doc.reapTimer)
|
|
||||||
delete docs[docName]
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = function (error) {
|
|
||||||
if (!error) {
|
|
||||||
model.emit('delete', docName)
|
|
||||||
}
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db) {
|
|
||||||
return db.delete(docName, doc != null ? doc.dbMeta : undefined, done)
|
|
||||||
} else {
|
|
||||||
return done(!doc ? 'Document does not exist' : undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This gets all operations from [start...end]. (That is, its not inclusive.)
|
|
||||||
//
|
|
||||||
// end can be null. This means 'get me all ops from start'.
|
|
||||||
//
|
|
||||||
// Each op returned is in the form {op:o, meta:m, v:version}.
|
|
||||||
//
|
|
||||||
// Callback is called with (error, [ops])
|
|
||||||
//
|
|
||||||
// If the document does not exist, getOps doesn't necessarily return an error. This is because
|
|
||||||
// its awkward to figure out whether or not the document exists for things
|
|
||||||
// like the redis database backend. I guess its a bit gross having this inconsistant
|
|
||||||
// with the other DB calls, but its certainly convenient.
|
|
||||||
//
|
|
||||||
// Use getVersion() to determine if a document actually exists, if thats what you're
|
|
||||||
// after.
|
|
||||||
this.getOps = getOps = function (docName, start, end, callback) {
|
|
||||||
// getOps will only use the op cache if its there. It won't fill the op cache in.
|
|
||||||
if (!(start >= 0)) {
|
|
||||||
throw new Error('start must be 0+')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof end === 'function') {
|
|
||||||
;[end, callback] = Array.from([null, end])
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = docs[docName] != null ? docs[docName].ops : undefined
|
|
||||||
|
|
||||||
if (ops) {
|
|
||||||
const version = docs[docName].v
|
|
||||||
|
|
||||||
// Ops contains an array of ops. The last op in the list is the last op applied
|
|
||||||
if (end == null) {
|
|
||||||
end = version
|
|
||||||
}
|
|
||||||
start = Math.min(start, end)
|
|
||||||
|
|
||||||
if (start === end) {
|
|
||||||
return callback(null, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base is the version number of the oldest op we have cached
|
|
||||||
const base = version - ops.length
|
|
||||||
|
|
||||||
// If the database is null, we'll trim to the ops we do have and hope thats enough.
|
|
||||||
if (start >= base || db === null) {
|
|
||||||
refreshReapingTimeout(docName)
|
|
||||||
if (options.stats != null) {
|
|
||||||
options.stats.cacheHit('getOps')
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback(null, ops.slice(start - base, end - base))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.stats != null) {
|
|
||||||
options.stats.cacheMiss('getOps')
|
|
||||||
}
|
|
||||||
|
|
||||||
return getOpsInternal(docName, start, end, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gets the snapshot data for the specified document.
|
|
||||||
// getSnapshot(docName, callback)
|
|
||||||
// Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
|
|
||||||
this.getSnapshot = (docName, callback) =>
|
|
||||||
load(docName, (error, doc) =>
|
|
||||||
callback(
|
|
||||||
error,
|
|
||||||
doc
|
|
||||||
? { v: doc.v, type: doc.type, snapshot: doc.snapshot, meta: doc.meta }
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Gets the latest version # of the document.
|
|
||||||
// getVersion(docName, callback)
|
|
||||||
// callback is called with (error, version).
|
|
||||||
this.getVersion = (docName, callback) =>
|
|
||||||
load(docName, (error, doc) =>
|
|
||||||
callback(error, doc != null ? doc.v : undefined)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Apply an op to the specified document.
|
|
||||||
// The callback is passed (error, applied version #)
|
|
||||||
// opData = {op:op, v:v, meta:metadata}
|
|
||||||
//
|
|
||||||
// Ops are queued before being applied so that the following code applies op C before op B:
|
|
||||||
// model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
|
|
||||||
// model.applyOp 'doc', OPC
|
|
||||||
this.applyOp = (
|
|
||||||
docName,
|
|
||||||
opData,
|
|
||||||
callback // All the logic for this is in makeOpQueue, above.
|
|
||||||
) =>
|
|
||||||
load(docName, function (error, doc) {
|
|
||||||
if (error) {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.nextTick(() =>
|
|
||||||
doc.opQueue(opData, function (error, newVersion) {
|
|
||||||
refreshReapingTimeout(docName)
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback(error, newVersion)
|
|
||||||
: undefined
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: store (some) metadata in DB
|
|
||||||
// TODO: op and meta should be combineable in the op that gets sent
|
|
||||||
this.applyMetaOp = function (docName, metaOpData, callback) {
|
|
||||||
const { path, value } = metaOpData.meta
|
|
||||||
|
|
||||||
if (!isArray(path)) {
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback('path should be an array')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return load(docName, function (error, doc) {
|
|
||||||
if (error != null) {
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
} else {
|
|
||||||
let applied = false
|
|
||||||
switch (path[0]) {
|
|
||||||
case 'shout':
|
|
||||||
doc.eventEmitter.emit('op', metaOpData)
|
|
||||||
applied = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applied) {
|
|
||||||
model.emit('applyMetaOp', docName, path, value)
|
|
||||||
}
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback(null, doc.v)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen to all ops from the specified version. If version is in the past, all
|
|
||||||
// ops since that version are sent immediately to the listener.
|
|
||||||
//
|
|
||||||
// The callback is called once the listener is attached, but before any ops have been passed
|
|
||||||
// to the listener.
|
|
||||||
//
|
|
||||||
// This will _not_ edit the document metadata.
|
|
||||||
//
|
|
||||||
// If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
|
|
||||||
// might change in a future version.
|
|
||||||
//
|
|
||||||
// version is the document version at which the document is opened. It can be left out if you want to open
|
|
||||||
// the document at the most recent version.
|
|
||||||
//
|
|
||||||
// listener is called with (opData) each time an op is applied.
|
|
||||||
//
|
|
||||||
// callback(error, openedVersion)
|
|
||||||
this.listen = function (docName, version, listener, callback) {
|
|
||||||
if (typeof version === 'function') {
|
|
||||||
;[version, listener, callback] = Array.from([null, version, listener])
|
|
||||||
}
|
|
||||||
|
|
||||||
return load(docName, function (error, doc) {
|
|
||||||
if (error) {
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(doc.reapTimer)
|
|
||||||
|
|
||||||
if (version != null) {
|
|
||||||
return getOps(docName, version, null, function (error, data) {
|
|
||||||
if (error) {
|
|
||||||
return typeof callback === 'function' ? callback(error) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.eventEmitter.on('op', listener)
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
callback(null, version)
|
|
||||||
}
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const op of Array.from(data)) {
|
|
||||||
let needle
|
|
||||||
listener(op)
|
|
||||||
|
|
||||||
// The listener may well remove itself during the catchup phase. If this happens, break early.
|
|
||||||
// This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
|
|
||||||
if (
|
|
||||||
((needle = listener),
|
|
||||||
!Array.from(doc.eventEmitter.listeners('op')).includes(needle))
|
|
||||||
) {
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
result.push(undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Version is null / undefined. Just add the listener.
|
|
||||||
doc.eventEmitter.on('op', listener)
|
|
||||||
return typeof callback === 'function'
|
|
||||||
? callback(null, doc.v)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove a listener for a particular document.
|
|
||||||
//
|
|
||||||
// removeListener(docName, listener)
|
|
||||||
//
|
|
||||||
// This is synchronous.
|
|
||||||
this.removeListener = function (docName, listener) {
|
|
||||||
// The document should already be loaded.
|
|
||||||
const doc = docs[docName]
|
|
||||||
if (!doc) {
|
|
||||||
throw new Error('removeListener called but document not loaded')
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.eventEmitter.removeListener('op', listener)
|
|
||||||
return refreshReapingTimeout(docName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
|
|
||||||
// sharejs will happily replay uncommitted ops when documents are re-opened anyway.
|
|
||||||
this.flush = function (callback) {
|
|
||||||
if (!db) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingWrites = 0
|
|
||||||
|
|
||||||
for (const docName in docs) {
|
|
||||||
const doc = docs[docName]
|
|
||||||
if (doc.committedVersion < doc.v) {
|
|
||||||
pendingWrites++
|
|
||||||
// I'm hoping writeSnapshot will always happen in another thread.
|
|
||||||
tryWriteSnapshot(docName, () =>
|
|
||||||
process.nextTick(function () {
|
|
||||||
pendingWrites--
|
|
||||||
if (pendingWrites === 0) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If nothing was queued, terminate immediately.
|
|
||||||
if (pendingWrites === 0) {
|
|
||||||
return typeof callback === 'function' ? callback() : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the database connection. This is needed so nodejs can shut down cleanly.
|
|
||||||
this.closeDb = function () {
|
|
||||||
__guardMethod__(db, 'close', o => o.close())
|
|
||||||
return (db = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model inherits from EventEmitter.
|
|
||||||
Model.prototype = new EventEmitter()
|
|
||||||
|
|
||||||
function __guardMethod__(obj, methodName, transform) {
|
|
||||||
if (
|
|
||||||
typeof obj !== 'undefined' &&
|
|
||||||
obj !== null &&
|
|
||||||
typeof obj[methodName] === 'function'
|
|
||||||
) {
|
|
||||||
return transform(obj, methodName)
|
|
||||||
} else {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Sanity-check the conversion and remove this comment.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// This is a really simple OT type. Its not compiled with the web client, but it could be.
|
|
||||||
//
|
|
||||||
// Its mostly included for demonstration purposes and its used in a lot of unit tests.
|
|
||||||
//
|
|
||||||
// This defines a really simple text OT type which only allows inserts. (No deletes).
|
|
||||||
//
|
|
||||||
// Ops look like:
|
|
||||||
// {position:#, text:"asdf"}
|
|
||||||
//
|
|
||||||
// Document snapshots look like:
|
|
||||||
// {str:string}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
// The name of the OT type. The type is stored in types[type.name]. The name can be
|
|
||||||
// used in place of the actual type in all the API methods.
|
|
||||||
name: 'simple',
|
|
||||||
|
|
||||||
// Create a new document snapshot
|
|
||||||
create() {
|
|
||||||
return { str: '' }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Apply the given op to the document snapshot. Returns the new snapshot.
|
|
||||||
//
|
|
||||||
// The original snapshot should not be modified.
|
|
||||||
apply(snapshot, op) {
|
|
||||||
if (!(op.position >= 0 && op.position <= snapshot.str.length)) {
|
|
||||||
throw new Error('Invalid position')
|
|
||||||
}
|
|
||||||
|
|
||||||
let { str } = snapshot
|
|
||||||
str = str.slice(0, op.position) + op.text + str.slice(op.position)
|
|
||||||
return { str }
|
|
||||||
},
|
|
||||||
|
|
||||||
// transform op1 by op2. Return transformed version of op1.
|
|
||||||
// sym describes the symmetry of the op. Its 'left' or 'right' depending on whether the
|
|
||||||
// op being transformed comes from the client or the server.
|
|
||||||
transform(op1, op2, sym) {
|
|
||||||
let pos = op1.position
|
|
||||||
if (op2.position < pos || (op2.position === pos && sym === 'left')) {
|
|
||||||
pos += op2.text.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return { position: pos, text: op1.text }
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Sanity-check the conversion and remove this comment.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// A synchronous processing queue. The queue calls process on the arguments,
|
|
||||||
// ensuring that process() is only executing once at a time.
|
|
||||||
//
|
|
||||||
// process(data, callback) _MUST_ eventually call its callback.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// queue = require 'syncqueue'
|
|
||||||
//
|
|
||||||
// fn = queue (data, callback) ->
|
|
||||||
// asyncthing data, ->
|
|
||||||
// callback(321)
|
|
||||||
//
|
|
||||||
// fn(1)
|
|
||||||
// fn(2)
|
|
||||||
// fn(3, (result) -> console.log(result))
|
|
||||||
//
|
|
||||||
// ^--- async thing will only be running once at any time.
|
|
||||||
|
|
||||||
module.exports = function (process) {
|
|
||||||
if (typeof process !== 'function') {
|
|
||||||
throw new Error('process is not a function')
|
|
||||||
}
|
|
||||||
const queue = []
|
|
||||||
|
|
||||||
const enqueue = function (data, callback) {
|
|
||||||
queue.push([data, callback])
|
|
||||||
return flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueue.busy = false
|
|
||||||
|
|
||||||
function flush() {
|
|
||||||
if (enqueue.busy || queue.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueue.busy = true
|
|
||||||
const [data, callback] = Array.from(queue.shift())
|
|
||||||
return process(data, function (...result) {
|
|
||||||
// TODO: Make this not use varargs - varargs are really slow.
|
|
||||||
enqueue.busy = false
|
|
||||||
// This is called after busy = false so a user can check if enqueue.busy is set in the callback.
|
|
||||||
if (callback) {
|
|
||||||
callback.apply(null, result)
|
|
||||||
}
|
|
||||||
return flush()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return enqueue
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Sanity-check the conversion and remove this comment.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// Text document API for text
|
|
||||||
|
|
||||||
let text
|
|
||||||
if (typeof WEB === 'undefined') {
|
|
||||||
text = require('./text')
|
|
||||||
}
|
|
||||||
|
|
||||||
text.api = {
|
|
||||||
provides: { text: true },
|
|
||||||
|
|
||||||
// The number of characters in the string
|
|
||||||
getLength() {
|
|
||||||
return this.snapshot.length
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get the text contents of a document
|
|
||||||
getText() {
|
|
||||||
return this.snapshot
|
|
||||||
},
|
|
||||||
|
|
||||||
insert(pos, text, callback) {
|
|
||||||
const op = [{ p: pos, i: text }]
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
del(pos, length, callback) {
|
|
||||||
const op = [{ p: pos, d: this.snapshot.slice(pos, pos + length) }]
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
_register() {
|
|
||||||
return this.on('remoteop', function (op) {
|
|
||||||
return Array.from(op).map(component =>
|
|
||||||
component.i !== undefined
|
|
||||||
? this.emit('insert', component.p, component.i)
|
|
||||||
: this.emit('delete', component.p, component.d)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-undef,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// Text document API for text
|
|
||||||
|
|
||||||
let type
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
type = exports.types['text-composable']
|
|
||||||
} else {
|
|
||||||
type = require('./text-composable')
|
|
||||||
}
|
|
||||||
|
|
||||||
type.api = {
|
|
||||||
provides: { text: true },
|
|
||||||
|
|
||||||
// The number of characters in the string
|
|
||||||
getLength() {
|
|
||||||
return this.snapshot.length
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get the text contents of a document
|
|
||||||
getText() {
|
|
||||||
return this.snapshot
|
|
||||||
},
|
|
||||||
|
|
||||||
insert(pos, text, callback) {
|
|
||||||
const op = type.normalize([pos, { i: text }, this.snapshot.length - pos])
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
del(pos, length, callback) {
|
|
||||||
const op = type.normalize([
|
|
||||||
pos,
|
|
||||||
{ d: this.snapshot.slice(pos, pos + length) },
|
|
||||||
this.snapshot.length - pos - length,
|
|
||||||
])
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
_register() {
|
|
||||||
return this.on('remoteop', function (op) {
|
|
||||||
let pos = 0
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
result.push((pos += component))
|
|
||||||
} else if (component.i !== undefined) {
|
|
||||||
this.emit('insert', pos, component.i)
|
|
||||||
result.push((pos += component.i.length))
|
|
||||||
} else {
|
|
||||||
// delete
|
|
||||||
result.push(this.emit('delete', pos, component.d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// We don't increment pos, because the position
|
|
||||||
// specified is after the delete has happened.
|
|
|
@ -1,401 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-cond-assign,
|
|
||||||
no-return-assign,
|
|
||||||
no-undef,
|
|
||||||
no-unused-vars,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// An alternate composable implementation for text. This is much closer
|
|
||||||
// to the implementation used by google wave.
|
|
||||||
//
|
|
||||||
// Ops are lists of components which iterate over the whole document.
|
|
||||||
// Components are either:
|
|
||||||
// A number N: Skip N characters in the original document
|
|
||||||
// {i:'str'}: Insert 'str' at the current position in the document
|
|
||||||
// {d:'str'}: Delete 'str', which appears at the current position in the document
|
|
||||||
//
|
|
||||||
// Eg: [3, {i:'hi'}, 5, {d:'internet'}]
|
|
||||||
//
|
|
||||||
// Snapshots are strings.
|
|
||||||
|
|
||||||
let makeAppend
|
|
||||||
const p = function () {} // require('util').debug
|
|
||||||
const i = function () {} // require('util').inspect
|
|
||||||
|
|
||||||
const exports = typeof WEB !== 'undefined' && WEB !== null ? {} : module.exports
|
|
||||||
|
|
||||||
exports.name = 'text-composable'
|
|
||||||
|
|
||||||
exports.create = () => ''
|
|
||||||
|
|
||||||
// -------- Utility methods
|
|
||||||
|
|
||||||
const checkOp = function (op) {
|
|
||||||
if (!Array.isArray(op)) {
|
|
||||||
throw new Error('Op must be an array of components')
|
|
||||||
}
|
|
||||||
let last = null
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
if (typeof c === 'object') {
|
|
||||||
if (
|
|
||||||
(c.i == null || !(c.i.length > 0)) &&
|
|
||||||
(c.d == null || !(c.d.length > 0))
|
|
||||||
) {
|
|
||||||
throw new Error(`Invalid op component: ${i(c)}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (typeof c !== 'number') {
|
|
||||||
throw new Error('Op components must be objects or numbers')
|
|
||||||
}
|
|
||||||
if (!(c > 0)) {
|
|
||||||
throw new Error('Skip components must be a positive number')
|
|
||||||
}
|
|
||||||
if (typeof last === 'number') {
|
|
||||||
throw new Error('Adjacent skip components should be added')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push((last = c))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Makes a function for appending components to a given op.
|
|
||||||
// Exported for the randomOpGenerator.
|
|
||||||
exports._makeAppend = makeAppend = op =>
|
|
||||||
function (component) {
|
|
||||||
if (component === 0 || component.i === '' || component.d === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (op.length === 0) {
|
|
||||||
return op.push(component)
|
|
||||||
} else if (
|
|
||||||
typeof component === 'number' &&
|
|
||||||
typeof op[op.length - 1] === 'number'
|
|
||||||
) {
|
|
||||||
return (op[op.length - 1] += component)
|
|
||||||
} else if (component.i != null && op[op.length - 1].i != null) {
|
|
||||||
return (op[op.length - 1].i += component.i)
|
|
||||||
} else if (component.d != null && op[op.length - 1].d != null) {
|
|
||||||
return (op[op.length - 1].d += component.d)
|
|
||||||
} else {
|
|
||||||
return op.push(component)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkOp op
|
|
||||||
|
|
||||||
// Makes 2 functions for taking components from the start of an op, and for peeking
|
|
||||||
// at the next op that could be taken.
|
|
||||||
const makeTake = function (op) {
|
|
||||||
// The index of the next component to take
|
|
||||||
let idx = 0
|
|
||||||
// The offset into the component
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
// Take up to length n from the front of op. If n is null, take the next
|
|
||||||
// op component. If indivisableField == 'd', delete components won't be separated.
|
|
||||||
// If indivisableField == 'i', insert components won't be separated.
|
|
||||||
const take = function (n, indivisableField) {
|
|
||||||
let c
|
|
||||||
if (idx === op.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// assert.notStrictEqual op.length, i, 'The op is too short to traverse the document'
|
|
||||||
|
|
||||||
if (typeof op[idx] === 'number') {
|
|
||||||
if (n == null || op[idx] - offset <= n) {
|
|
||||||
c = op[idx] - offset
|
|
||||||
++idx
|
|
||||||
offset = 0
|
|
||||||
return c
|
|
||||||
} else {
|
|
||||||
offset += n
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Take from the string
|
|
||||||
const field = op[idx].i ? 'i' : 'd'
|
|
||||||
c = {}
|
|
||||||
if (
|
|
||||||
n == null ||
|
|
||||||
op[idx][field].length - offset <= n ||
|
|
||||||
field === indivisableField
|
|
||||||
) {
|
|
||||||
c[field] = op[idx][field].slice(offset)
|
|
||||||
++idx
|
|
||||||
offset = 0
|
|
||||||
} else {
|
|
||||||
c[field] = op[idx][field].slice(offset, offset + n)
|
|
||||||
offset += n
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const peekType = () => op[idx]
|
|
||||||
|
|
||||||
return [take, peekType]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and return the length of an op component
|
|
||||||
const componentLength = function (component) {
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
return component
|
|
||||||
} else if (component.i != null) {
|
|
||||||
return component.i.length
|
|
||||||
} else {
|
|
||||||
return component.d.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
|
|
||||||
// adjacent inserts and deletes.
|
|
||||||
exports.normalize = function (op) {
|
|
||||||
const newOp = []
|
|
||||||
const append = makeAppend(newOp)
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
append(component)
|
|
||||||
}
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the op to the string. Returns the new string.
|
|
||||||
exports.apply = function (str, op) {
|
|
||||||
p(`Applying ${i(op)} to '${str}'`)
|
|
||||||
if (typeof str !== 'string') {
|
|
||||||
throw new Error('Snapshot should be a string')
|
|
||||||
}
|
|
||||||
checkOp(op)
|
|
||||||
|
|
||||||
const pos = 0
|
|
||||||
const newDoc = []
|
|
||||||
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
if (component > str.length) {
|
|
||||||
throw new Error('The op is too long for this document')
|
|
||||||
}
|
|
||||||
newDoc.push(str.slice(0, component))
|
|
||||||
str = str.slice(component)
|
|
||||||
} else if (component.i != null) {
|
|
||||||
newDoc.push(component.i)
|
|
||||||
} else {
|
|
||||||
if (component.d !== str.slice(0, component.d.length)) {
|
|
||||||
throw new Error(
|
|
||||||
`The deleted text '${
|
|
||||||
component.d
|
|
||||||
}' doesn't match the next characters in the document '${str.slice(
|
|
||||||
0,
|
|
||||||
component.d.length
|
|
||||||
)}'`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
str = str.slice(component.d.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str !== '') {
|
|
||||||
throw new Error("The applied op doesn't traverse the entire document")
|
|
||||||
}
|
|
||||||
|
|
||||||
return newDoc.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform op1 by op2. Return transformed version of op1.
|
|
||||||
// op1 and op2 are unchanged by transform.
|
|
||||||
exports.transform = function (op, otherOp, side) {
|
|
||||||
let component
|
|
||||||
if (side !== 'left' && side !== 'right') {
|
|
||||||
throw new Error(`side (${side} must be 'left' or 'right'`)
|
|
||||||
}
|
|
||||||
|
|
||||||
checkOp(op)
|
|
||||||
checkOp(otherOp)
|
|
||||||
const newOp = []
|
|
||||||
|
|
||||||
const append = makeAppend(newOp)
|
|
||||||
const [take, peek] = Array.from(makeTake(op))
|
|
||||||
|
|
||||||
for (component of Array.from(otherOp)) {
|
|
||||||
let chunk, length
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
// Skip
|
|
||||||
length = component
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length, 'i')
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(chunk)
|
|
||||||
if (typeof chunk !== 'object' || chunk.i == null) {
|
|
||||||
length -= componentLength(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (component.i != null) {
|
|
||||||
// Insert
|
|
||||||
if (side === 'left') {
|
|
||||||
// The left insert should go first.
|
|
||||||
const o = peek()
|
|
||||||
if (o != null ? o.i : undefined) {
|
|
||||||
append(take())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, skip the inserted text.
|
|
||||||
append(component.i.length)
|
|
||||||
} else {
|
|
||||||
// Delete.
|
|
||||||
// assert.ok component.d
|
|
||||||
;({ length } = component.d)
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length, 'i')
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof chunk === 'number') {
|
|
||||||
length -= chunk
|
|
||||||
} else if (chunk.i != null) {
|
|
||||||
append(chunk)
|
|
||||||
} else {
|
|
||||||
// assert.ok chunk.d
|
|
||||||
// The delete is unnecessary now.
|
|
||||||
length -= chunk.d.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append extras from op1
|
|
||||||
while ((component = take())) {
|
|
||||||
if ((component != null ? component.i : undefined) == null) {
|
|
||||||
throw new Error(`Remaining fragments in the op: ${i(component)}`)
|
|
||||||
}
|
|
||||||
append(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose 2 ops into 1 op.
|
|
||||||
exports.compose = function (op1, op2) {
|
|
||||||
let component
|
|
||||||
p(`COMPOSE ${i(op1)} + ${i(op2)}`)
|
|
||||||
checkOp(op1)
|
|
||||||
checkOp(op2)
|
|
||||||
|
|
||||||
const result = []
|
|
||||||
|
|
||||||
const append = makeAppend(result)
|
|
||||||
const [take, _] = Array.from(makeTake(op1))
|
|
||||||
|
|
||||||
for (component of Array.from(op2)) {
|
|
||||||
let chunk, length
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
// Skip
|
|
||||||
length = component
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length, 'd')
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(chunk)
|
|
||||||
if (typeof chunk !== 'object' || chunk.d == null) {
|
|
||||||
length -= componentLength(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (component.i != null) {
|
|
||||||
// Insert
|
|
||||||
append({ i: component.i })
|
|
||||||
} else {
|
|
||||||
// Delete
|
|
||||||
let offset = 0
|
|
||||||
while (offset < component.d.length) {
|
|
||||||
chunk = take(component.d.length - offset, 'd')
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If its delete, append it. If its skip, drop it and decrease length. If its insert, check the strings match, drop it and decrease length.
|
|
||||||
if (typeof chunk === 'number') {
|
|
||||||
append({ d: component.d.slice(offset, offset + chunk) })
|
|
||||||
offset += chunk
|
|
||||||
} else if (chunk.i != null) {
|
|
||||||
if (component.d.slice(offset, offset + chunk.i.length) !== chunk.i) {
|
|
||||||
throw new Error("The deleted text doesn't match the inserted text")
|
|
||||||
}
|
|
||||||
offset += chunk.i.length
|
|
||||||
// The ops cancel each other out.
|
|
||||||
} else {
|
|
||||||
// Delete
|
|
||||||
append(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append extras from op1
|
|
||||||
while ((component = take())) {
|
|
||||||
if ((component != null ? component.d : undefined) == null) {
|
|
||||||
throw new Error(`Trailing stuff in op1 ${i(component)}`)
|
|
||||||
}
|
|
||||||
append(component)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const invertComponent = function (c) {
|
|
||||||
if (typeof c === 'number') {
|
|
||||||
return c
|
|
||||||
} else if (c.i != null) {
|
|
||||||
return { d: c.i }
|
|
||||||
} else {
|
|
||||||
return { i: c.d }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invert an op
|
|
||||||
exports.invert = function (op) {
|
|
||||||
const result = []
|
|
||||||
const append = makeAppend(result)
|
|
||||||
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
append(invertComponent(component))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window !== null) {
|
|
||||||
if (!window.ot) {
|
|
||||||
window.ot = {}
|
|
||||||
}
|
|
||||||
if (!window.ot.types) {
|
|
||||||
window.ot.types = {}
|
|
||||||
}
|
|
||||||
window.ot.types.text = exports
|
|
||||||
}
|
|
|
@ -1,133 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-undef,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// Text document API for text-tp2
|
|
||||||
|
|
||||||
let type
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
type = exports.types['text-tp2']
|
|
||||||
} else {
|
|
||||||
type = require('./text-tp2')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { _takeDoc: takeDoc, _append: append } = type
|
|
||||||
|
|
||||||
const appendSkipChars = (op, doc, pos, maxlength) =>
|
|
||||||
(() => {
|
|
||||||
const result = []
|
|
||||||
while (
|
|
||||||
(maxlength === undefined || maxlength > 0) &&
|
|
||||||
pos.index < doc.data.length
|
|
||||||
) {
|
|
||||||
const part = takeDoc(doc, pos, maxlength, true)
|
|
||||||
if (maxlength !== undefined && typeof part === 'string') {
|
|
||||||
maxlength -= part.length
|
|
||||||
}
|
|
||||||
result.push(append(op, part.length || part))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
|
|
||||||
type.api = {
|
|
||||||
provides: { text: true },
|
|
||||||
|
|
||||||
// The number of characters in the string
|
|
||||||
getLength() {
|
|
||||||
return this.snapshot.charLength
|
|
||||||
},
|
|
||||||
|
|
||||||
// Flatten a document into a string
|
|
||||||
getText() {
|
|
||||||
const strings = Array.from(this.snapshot.data).filter(
|
|
||||||
elem => typeof elem === 'string'
|
|
||||||
)
|
|
||||||
return strings.join('')
|
|
||||||
},
|
|
||||||
|
|
||||||
insert(pos, text, callback) {
|
|
||||||
if (pos === undefined) {
|
|
||||||
pos = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const op = []
|
|
||||||
const docPos = { index: 0, offset: 0 }
|
|
||||||
|
|
||||||
appendSkipChars(op, this.snapshot, docPos, pos)
|
|
||||||
append(op, { i: text })
|
|
||||||
appendSkipChars(op, this.snapshot, docPos)
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
del(pos, length, callback) {
|
|
||||||
const op = []
|
|
||||||
const docPos = { index: 0, offset: 0 }
|
|
||||||
|
|
||||||
appendSkipChars(op, this.snapshot, docPos, pos)
|
|
||||||
|
|
||||||
while (length > 0) {
|
|
||||||
const part = takeDoc(this.snapshot, docPos, length, true)
|
|
||||||
if (typeof part === 'string') {
|
|
||||||
append(op, { d: part.length })
|
|
||||||
length -= part.length
|
|
||||||
} else {
|
|
||||||
append(op, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
appendSkipChars(op, this.snapshot, docPos)
|
|
||||||
|
|
||||||
this.submitOp(op, callback)
|
|
||||||
return op
|
|
||||||
},
|
|
||||||
|
|
||||||
_register() {
|
|
||||||
// Interpret recieved ops + generate more detailed events for them
|
|
||||||
return this.on('remoteop', function (op, snapshot) {
|
|
||||||
let textPos = 0
|
|
||||||
const docPos = { index: 0, offset: 0 }
|
|
||||||
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
let part, remainder
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
// Skip
|
|
||||||
remainder = component
|
|
||||||
while (remainder > 0) {
|
|
||||||
part = takeDoc(snapshot, docPos, remainder)
|
|
||||||
if (typeof part === 'string') {
|
|
||||||
textPos += part.length
|
|
||||||
}
|
|
||||||
remainder -= part.length || part
|
|
||||||
}
|
|
||||||
} else if (component.i !== undefined) {
|
|
||||||
// Insert
|
|
||||||
if (typeof component.i === 'string') {
|
|
||||||
this.emit('insert', textPos, component.i)
|
|
||||||
textPos += component.i.length
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Delete
|
|
||||||
remainder = component.d
|
|
||||||
while (remainder > 0) {
|
|
||||||
part = takeDoc(snapshot, docPos, remainder)
|
|
||||||
if (typeof part === 'string') {
|
|
||||||
this.emit('delete', textPos, part)
|
|
||||||
}
|
|
||||||
remainder -= part.length || part
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,499 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-cond-assign,
|
|
||||||
no-return-assign,
|
|
||||||
no-undef,
|
|
||||||
no-unused-vars,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS103: Rewrite code to no longer use __guard__
|
|
||||||
* DS205: Consider reworking code to avoid use of IIFEs
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// A TP2 implementation of text, following this spec:
|
|
||||||
// http://code.google.com/p/lightwave/source/browse/trunk/experimental/ot/README
|
|
||||||
//
|
|
||||||
// A document is made up of a string and a set of tombstones inserted throughout
|
|
||||||
// the string. For example, 'some ', (2 tombstones), 'string'.
|
|
||||||
//
|
|
||||||
// This is encoded in a document as: {s:'some string', t:[5, -2, 6]}
|
|
||||||
//
|
|
||||||
// Ops are lists of components which iterate over the whole document.
|
|
||||||
// Components are either:
|
|
||||||
// N: Skip N characters in the original document
|
|
||||||
// {i:'str'}: Insert 'str' at the current position in the document
|
|
||||||
// {i:N}: Insert N tombstones at the current position in the document
|
|
||||||
// {d:N}: Delete (tombstone) N characters at the current position in the document
|
|
||||||
//
|
|
||||||
// Eg: [3, {i:'hi'}, 5, {d:8}]
|
|
||||||
//
|
|
||||||
// Snapshots are lists with characters and tombstones. Characters are stored in strings
|
|
||||||
// and adjacent tombstones are flattened into numbers.
|
|
||||||
//
|
|
||||||
// Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters)
|
|
||||||
// would be represented by a document snapshot of ['Hello ', 5, 'world']
|
|
||||||
|
|
||||||
let append, appendDoc, takeDoc
|
|
||||||
const type = {
|
|
||||||
name: 'text-tp2',
|
|
||||||
tp2: true,
|
|
||||||
create() {
|
|
||||||
return { charLength: 0, totalLength: 0, positionCache: [], data: [] }
|
|
||||||
},
|
|
||||||
serialize(doc) {
|
|
||||||
if (!doc.data) {
|
|
||||||
throw new Error('invalid doc snapshot')
|
|
||||||
}
|
|
||||||
return doc.data
|
|
||||||
},
|
|
||||||
deserialize(data) {
|
|
||||||
const doc = type.create()
|
|
||||||
doc.data = data
|
|
||||||
|
|
||||||
for (const component of Array.from(data)) {
|
|
||||||
if (typeof component === 'string') {
|
|
||||||
doc.charLength += component.length
|
|
||||||
doc.totalLength += component.length
|
|
||||||
} else {
|
|
||||||
doc.totalLength += component
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkOp = function (op) {
|
|
||||||
if (!Array.isArray(op)) {
|
|
||||||
throw new Error('Op must be an array of components')
|
|
||||||
}
|
|
||||||
let last = null
|
|
||||||
return (() => {
|
|
||||||
const result = []
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
if (typeof c === 'object') {
|
|
||||||
if (c.i !== undefined) {
|
|
||||||
if (
|
|
||||||
(typeof c.i !== 'string' || !(c.i.length > 0)) &&
|
|
||||||
(typeof c.i !== 'number' || !(c.i > 0))
|
|
||||||
) {
|
|
||||||
throw new Error('Inserts must insert a string or a +ive number')
|
|
||||||
}
|
|
||||||
} else if (c.d !== undefined) {
|
|
||||||
if (typeof c.d !== 'number' || !(c.d > 0)) {
|
|
||||||
throw new Error('Deletes must be a +ive number')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Operation component must define .i or .d')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (typeof c !== 'number') {
|
|
||||||
throw new Error('Op components must be objects or numbers')
|
|
||||||
}
|
|
||||||
if (!(c > 0)) {
|
|
||||||
throw new Error('Skip components must be a positive number')
|
|
||||||
}
|
|
||||||
if (typeof last === 'number') {
|
|
||||||
throw new Error('Adjacent skip components should be combined')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push((last = c))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take the next part from the specified position in a document snapshot.
|
|
||||||
// position = {index, offset}. It will be updated.
|
|
||||||
type._takeDoc = takeDoc = function (
|
|
||||||
doc,
|
|
||||||
position,
|
|
||||||
maxlength,
|
|
||||||
tombsIndivisible
|
|
||||||
) {
|
|
||||||
if (position.index >= doc.data.length) {
|
|
||||||
throw new Error('Operation goes past the end of the document')
|
|
||||||
}
|
|
||||||
|
|
||||||
const part = doc.data[position.index]
|
|
||||||
// peel off data[0]
|
|
||||||
const result =
|
|
||||||
typeof part === 'string'
|
|
||||||
? maxlength !== undefined
|
|
||||||
? part.slice(position.offset, position.offset + maxlength)
|
|
||||||
: part.slice(position.offset)
|
|
||||||
: maxlength === undefined || tombsIndivisible
|
|
||||||
? part - position.offset
|
|
||||||
: Math.min(maxlength, part - position.offset)
|
|
||||||
|
|
||||||
const resultLen = result.length || result
|
|
||||||
|
|
||||||
if ((part.length || part) - position.offset > resultLen) {
|
|
||||||
position.offset += resultLen
|
|
||||||
} else {
|
|
||||||
position.index++
|
|
||||||
position.offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append a part to the end of a document
|
|
||||||
type._appendDoc = appendDoc = function (doc, p) {
|
|
||||||
if (p === 0 || p === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof p === 'string') {
|
|
||||||
doc.charLength += p.length
|
|
||||||
doc.totalLength += p.length
|
|
||||||
} else {
|
|
||||||
doc.totalLength += p
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = doc
|
|
||||||
if (data.length === 0) {
|
|
||||||
data.push(p)
|
|
||||||
} else if (typeof data[data.length - 1] === typeof p) {
|
|
||||||
data[data.length - 1] += p
|
|
||||||
} else {
|
|
||||||
data.push(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the op to the document. The document is not modified in the process.
|
|
||||||
type.apply = function (doc, op) {
|
|
||||||
if (
|
|
||||||
doc.totalLength === undefined ||
|
|
||||||
doc.charLength === undefined ||
|
|
||||||
doc.data.length === undefined
|
|
||||||
) {
|
|
||||||
throw new Error('Snapshot is invalid')
|
|
||||||
}
|
|
||||||
|
|
||||||
checkOp(op)
|
|
||||||
|
|
||||||
const newDoc = type.create()
|
|
||||||
const position = { index: 0, offset: 0 }
|
|
||||||
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
let part, remainder
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
remainder = component
|
|
||||||
while (remainder > 0) {
|
|
||||||
part = takeDoc(doc, position, remainder)
|
|
||||||
|
|
||||||
appendDoc(newDoc, part)
|
|
||||||
remainder -= part.length || part
|
|
||||||
}
|
|
||||||
} else if (component.i !== undefined) {
|
|
||||||
appendDoc(newDoc, component.i)
|
|
||||||
} else if (component.d !== undefined) {
|
|
||||||
remainder = component.d
|
|
||||||
while (remainder > 0) {
|
|
||||||
part = takeDoc(doc, position, remainder)
|
|
||||||
remainder -= part.length || part
|
|
||||||
}
|
|
||||||
appendDoc(newDoc, component.d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newDoc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append an op component to the end of the specified op.
|
|
||||||
// Exported for the randomOpGenerator.
|
|
||||||
type._append = append = function (op, component) {
|
|
||||||
if (
|
|
||||||
component === 0 ||
|
|
||||||
component.i === '' ||
|
|
||||||
component.i === 0 ||
|
|
||||||
component.d === 0
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (op.length === 0) {
|
|
||||||
return op.push(component)
|
|
||||||
} else {
|
|
||||||
const last = op[op.length - 1]
|
|
||||||
if (typeof component === 'number' && typeof last === 'number') {
|
|
||||||
return (op[op.length - 1] += component)
|
|
||||||
} else if (
|
|
||||||
component.i !== undefined &&
|
|
||||||
last.i != null &&
|
|
||||||
typeof last.i === typeof component.i
|
|
||||||
) {
|
|
||||||
return (last.i += component.i)
|
|
||||||
} else if (component.d !== undefined && last.d != null) {
|
|
||||||
return (last.d += component.d)
|
|
||||||
} else {
|
|
||||||
return op.push(component)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Makes 2 functions for taking components from the start of an op, and for peeking
|
|
||||||
// at the next op that could be taken.
|
|
||||||
const makeTake = function (op) {
|
|
||||||
// The index of the next component to take
|
|
||||||
let index = 0
|
|
||||||
// The offset into the component
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
// Take up to length maxlength from the op. If maxlength is not defined, there is no max.
|
|
||||||
// If insertsIndivisible is true, inserts (& insert tombstones) won't be separated.
|
|
||||||
//
|
|
||||||
// Returns null when op is fully consumed.
|
|
||||||
const take = function (maxlength, insertsIndivisible) {
|
|
||||||
let current
|
|
||||||
if (index === op.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const e = op[index]
|
|
||||||
if (
|
|
||||||
typeof (current = e) === 'number' ||
|
|
||||||
typeof (current = e.i) === 'number' ||
|
|
||||||
(current = e.d) !== undefined
|
|
||||||
) {
|
|
||||||
let c
|
|
||||||
if (
|
|
||||||
maxlength == null ||
|
|
||||||
current - offset <= maxlength ||
|
|
||||||
(insertsIndivisible && e.i !== undefined)
|
|
||||||
) {
|
|
||||||
// Return the rest of the current element.
|
|
||||||
c = current - offset
|
|
||||||
++index
|
|
||||||
offset = 0
|
|
||||||
} else {
|
|
||||||
offset += maxlength
|
|
||||||
c = maxlength
|
|
||||||
}
|
|
||||||
if (e.i !== undefined) {
|
|
||||||
return { i: c }
|
|
||||||
} else if (e.d !== undefined) {
|
|
||||||
return { d: c }
|
|
||||||
} else {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Take from the inserted string
|
|
||||||
let result
|
|
||||||
if (
|
|
||||||
maxlength == null ||
|
|
||||||
e.i.length - offset <= maxlength ||
|
|
||||||
insertsIndivisible
|
|
||||||
) {
|
|
||||||
result = { i: e.i.slice(offset) }
|
|
||||||
++index
|
|
||||||
offset = 0
|
|
||||||
} else {
|
|
||||||
result = { i: e.i.slice(offset, offset + maxlength) }
|
|
||||||
offset += maxlength
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const peekType = () => op[index]
|
|
||||||
|
|
||||||
return [take, peekType]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and return the length of an op component
|
|
||||||
const componentLength = function (component) {
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
return component
|
|
||||||
} else if (typeof component.i === 'string') {
|
|
||||||
return component.i.length
|
|
||||||
} else {
|
|
||||||
// This should work because c.d and c.i must be +ive.
|
|
||||||
return component.d || component.i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
|
|
||||||
// adjacent inserts and deletes.
|
|
||||||
type.normalize = function (op) {
|
|
||||||
const newOp = []
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
append(newOp, component)
|
|
||||||
}
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a helper method to transform and prune. goForwards is true for transform, false for prune.
|
|
||||||
const transformer = function (op, otherOp, goForwards, side) {
|
|
||||||
let component
|
|
||||||
checkOp(op)
|
|
||||||
checkOp(otherOp)
|
|
||||||
const newOp = []
|
|
||||||
|
|
||||||
const [take, peek] = Array.from(makeTake(op))
|
|
||||||
|
|
||||||
for (component of Array.from(otherOp)) {
|
|
||||||
let chunk
|
|
||||||
let length = componentLength(component)
|
|
||||||
|
|
||||||
if (component.i !== undefined) {
|
|
||||||
// Insert text or tombs
|
|
||||||
if (goForwards) {
|
|
||||||
// transform - insert skips over inserted parts
|
|
||||||
if (side === 'left') {
|
|
||||||
// The left insert should go first.
|
|
||||||
while (__guard__(peek(), x => x.i) !== undefined) {
|
|
||||||
append(newOp, take())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In any case, skip the inserted text.
|
|
||||||
append(newOp, length)
|
|
||||||
} else {
|
|
||||||
// Prune. Remove skips for inserts.
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length, true)
|
|
||||||
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error('The transformed op is invalid')
|
|
||||||
}
|
|
||||||
if (chunk.d !== undefined) {
|
|
||||||
throw new Error(
|
|
||||||
'The transformed op deletes locally inserted characters - it cannot be purged of the insert.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof chunk === 'number') {
|
|
||||||
length -= chunk
|
|
||||||
} else {
|
|
||||||
append(newOp, chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Skip or delete
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length, true)
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(newOp, chunk)
|
|
||||||
if (!chunk.i) {
|
|
||||||
length -= componentLength(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append extras from op1
|
|
||||||
while ((component = take())) {
|
|
||||||
if (component.i === undefined) {
|
|
||||||
throw new Error(`Remaining fragments in the op: ${component}`)
|
|
||||||
}
|
|
||||||
append(newOp, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// transform op1 by op2. Return transformed version of op1.
|
|
||||||
// op1 and op2 are unchanged by transform.
|
|
||||||
// side should be 'left' or 'right', depending on if op1.id <> op2.id. 'left' == client op.
|
|
||||||
type.transform = function (op, otherOp, side) {
|
|
||||||
if (side !== 'left' && side !== 'right') {
|
|
||||||
throw new Error(`side (${side}) should be 'left' or 'right'`)
|
|
||||||
}
|
|
||||||
return transformer(op, otherOp, true, side)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune is the inverse of transform.
|
|
||||||
type.prune = (op, otherOp) => transformer(op, otherOp, false)
|
|
||||||
|
|
||||||
// Compose 2 ops into 1 op.
|
|
||||||
type.compose = function (op1, op2) {
|
|
||||||
let component
|
|
||||||
if (op1 === null || op1 === undefined) {
|
|
||||||
return op2
|
|
||||||
}
|
|
||||||
|
|
||||||
checkOp(op1)
|
|
||||||
checkOp(op2)
|
|
||||||
|
|
||||||
const result = []
|
|
||||||
|
|
||||||
const [take, _] = Array.from(makeTake(op1))
|
|
||||||
|
|
||||||
for (component of Array.from(op2)) {
|
|
||||||
let chunk, length
|
|
||||||
if (typeof component === 'number') {
|
|
||||||
// Skip
|
|
||||||
// Just copy from op1.
|
|
||||||
length = component
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length)
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(result, chunk)
|
|
||||||
length -= componentLength(chunk)
|
|
||||||
}
|
|
||||||
} else if (component.i !== undefined) {
|
|
||||||
// Insert
|
|
||||||
append(result, { i: component.i })
|
|
||||||
} else {
|
|
||||||
// Delete
|
|
||||||
length = component.d
|
|
||||||
while (length > 0) {
|
|
||||||
chunk = take(length)
|
|
||||||
if (chunk === null) {
|
|
||||||
throw new Error(
|
|
||||||
'The op traverses more elements than the document has'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkLength = componentLength(chunk)
|
|
||||||
if (chunk.i !== undefined) {
|
|
||||||
append(result, { i: chunkLength })
|
|
||||||
} else {
|
|
||||||
append(result, { d: chunkLength })
|
|
||||||
}
|
|
||||||
|
|
||||||
length -= chunkLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append extras from op1
|
|
||||||
while ((component = take())) {
|
|
||||||
if (component.i === undefined) {
|
|
||||||
throw new Error(`Remaining fragments in op1: ${component}`)
|
|
||||||
}
|
|
||||||
append(result, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
exports.types['text-tp2'] = type
|
|
||||||
} else {
|
|
||||||
module.exports = type
|
|
||||||
}
|
|
||||||
|
|
||||||
function __guard__(value, transform) {
|
|
||||||
return typeof value !== 'undefined' && value !== null
|
|
||||||
? transform(value)
|
|
||||||
: undefined
|
|
||||||
}
|
|
|
@ -1,313 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-return-assign,
|
|
||||||
no-undef,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
/*
|
|
||||||
* decaffeinate suggestions:
|
|
||||||
* DS101: Remove unnecessary use of Array.from
|
|
||||||
* DS102: Remove unnecessary code created because of implicit returns
|
|
||||||
* DS207: Consider shorter variations of null checks
|
|
||||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
||||||
*/
|
|
||||||
// A simple text implementation
|
|
||||||
//
|
|
||||||
// Operations are lists of components.
|
|
||||||
// Each component either inserts or deletes at a specified position in the document.
|
|
||||||
//
|
|
||||||
// Components are either:
|
|
||||||
// {i:'str', p:100}: Insert 'str' at position 100 in the document
|
|
||||||
// {d:'str', p:100}: Delete 'str' at position 100 in the document
|
|
||||||
//
|
|
||||||
// Components in an operation are executed sequentially, so the position of components
|
|
||||||
// assumes previous components have already executed.
|
|
||||||
//
|
|
||||||
// Eg: This op:
|
|
||||||
// [{i:'abc', p:0}]
|
|
||||||
// is equivalent to this op:
|
|
||||||
// [{i:'a', p:0}, {i:'b', p:1}, {i:'c', p:2}]
|
|
||||||
|
|
||||||
// NOTE: The global scope here is shared with other sharejs files when built with closure.
|
|
||||||
// Be careful what ends up in your namespace.
|
|
||||||
|
|
||||||
let append, transformComponent
|
|
||||||
const text = {}
|
|
||||||
|
|
||||||
text.name = 'text'
|
|
||||||
|
|
||||||
text.create = () => ''
|
|
||||||
|
|
||||||
const strInject = (s1, pos, s2) => s1.slice(0, pos) + s2 + s1.slice(pos)
|
|
||||||
|
|
||||||
const checkValidComponent = function (c) {
|
|
||||||
if (typeof c.p !== 'number') {
|
|
||||||
throw new Error('component missing position field')
|
|
||||||
}
|
|
||||||
|
|
||||||
const iType = typeof c.i
|
|
||||||
const dType = typeof c.d
|
|
||||||
if (!((iType === 'string') ^ (dType === 'string'))) {
|
|
||||||
throw new Error('component needs an i or d field')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(c.p >= 0)) {
|
|
||||||
throw new Error('position cannot be negative')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkValidOp = function (op) {
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
checkValidComponent(c)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
text.apply = function (snapshot, op) {
|
|
||||||
checkValidOp(op)
|
|
||||||
for (const component of Array.from(op)) {
|
|
||||||
if (component.i != null) {
|
|
||||||
snapshot = strInject(snapshot, component.p, component.i)
|
|
||||||
} else {
|
|
||||||
const deleted = snapshot.slice(
|
|
||||||
component.p,
|
|
||||||
component.p + component.d.length
|
|
||||||
)
|
|
||||||
if (component.d !== deleted) {
|
|
||||||
throw new Error(
|
|
||||||
`Delete component '${component.d}' does not match deleted text '${deleted}'`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
snapshot =
|
|
||||||
snapshot.slice(0, component.p) +
|
|
||||||
snapshot.slice(component.p + component.d.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for use by the random op generator.
|
|
||||||
//
|
|
||||||
// For simplicity, this version of append does not compress adjacent inserts and deletes of
|
|
||||||
// the same text. It would be nice to change that at some stage.
|
|
||||||
text._append = append = function (newOp, c) {
|
|
||||||
if (c.i === '' || c.d === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newOp.length === 0) {
|
|
||||||
return newOp.push(c)
|
|
||||||
} else {
|
|
||||||
const last = newOp[newOp.length - 1]
|
|
||||||
|
|
||||||
// Compose the insert into the previous insert if possible
|
|
||||||
if (
|
|
||||||
last.i != null &&
|
|
||||||
c.i != null &&
|
|
||||||
last.p <= c.p &&
|
|
||||||
c.p <= last.p + last.i.length
|
|
||||||
) {
|
|
||||||
return (newOp[newOp.length - 1] = {
|
|
||||||
i: strInject(last.i, c.p - last.p, c.i),
|
|
||||||
p: last.p,
|
|
||||||
})
|
|
||||||
} else if (
|
|
||||||
last.d != null &&
|
|
||||||
c.d != null &&
|
|
||||||
c.p <= last.p &&
|
|
||||||
last.p <= c.p + c.d.length
|
|
||||||
) {
|
|
||||||
return (newOp[newOp.length - 1] = {
|
|
||||||
d: strInject(c.d, last.p - c.p, last.d),
|
|
||||||
p: c.p,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return newOp.push(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text.compose = function (op1, op2) {
|
|
||||||
checkValidOp(op1)
|
|
||||||
checkValidOp(op2)
|
|
||||||
|
|
||||||
const newOp = op1.slice()
|
|
||||||
for (const c of Array.from(op2)) {
|
|
||||||
append(newOp, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to compress the op components together 'as much as possible'.
|
|
||||||
// This implementation preserves order and preserves create/delete pairs.
|
|
||||||
text.compress = op => text.compose([], op)
|
|
||||||
|
|
||||||
text.normalize = function (op) {
|
|
||||||
const newOp = []
|
|
||||||
|
|
||||||
// Normalize should allow ops which are a single (unwrapped) component:
|
|
||||||
// {i:'asdf', p:23}.
|
|
||||||
// There's no good way to test if something is an array:
|
|
||||||
// http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
|
|
||||||
// so this is probably the least bad solution.
|
|
||||||
if (op.i != null || op.p != null) {
|
|
||||||
op = [op]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
if (c.p == null) {
|
|
||||||
c.p = 0
|
|
||||||
}
|
|
||||||
append(newOp, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newOp
|
|
||||||
}
|
|
||||||
|
|
||||||
// This helper method transforms a position by an op component.
|
|
||||||
//
|
|
||||||
// If c is an insert, insertAfter specifies whether the transform
|
|
||||||
// is pushed after the insert (true) or before it (false).
|
|
||||||
//
|
|
||||||
// insertAfter is optional for deletes.
|
|
||||||
const transformPosition = function (pos, c, insertAfter) {
|
|
||||||
if (c.i != null) {
|
|
||||||
if (c.p < pos || (c.p === pos && insertAfter)) {
|
|
||||||
return pos + c.i.length
|
|
||||||
} else {
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
|
|
||||||
// but I think its harder to read that way, and it compiles using ternary operators anyway
|
|
||||||
// so its no slower written like this.
|
|
||||||
if (pos <= c.p) {
|
|
||||||
return pos
|
|
||||||
} else if (pos <= c.p + c.d.length) {
|
|
||||||
return c.p
|
|
||||||
} else {
|
|
||||||
return pos - c.d.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to transform a cursor position as a result of an op.
|
|
||||||
//
|
|
||||||
// Like transformPosition above, if c is an insert, insertAfter specifies whether the cursor position
|
|
||||||
// is pushed after an insert (true) or before it (false).
|
|
||||||
text.transformCursor = function (position, op, side) {
|
|
||||||
const insertAfter = side === 'right'
|
|
||||||
for (const c of Array.from(op)) {
|
|
||||||
position = transformPosition(position, c, insertAfter)
|
|
||||||
}
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform an op component by another op component. Asymmetric.
|
|
||||||
// The result will be appended to destination.
|
|
||||||
//
|
|
||||||
// exported for use in JSON type
|
|
||||||
text._tc = transformComponent = function (dest, c, otherC, side) {
|
|
||||||
checkValidOp([c])
|
|
||||||
checkValidOp([otherC])
|
|
||||||
|
|
||||||
if (c.i != null) {
|
|
||||||
append(dest, {
|
|
||||||
i: c.i,
|
|
||||||
p: transformPosition(c.p, otherC, side === 'right'),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Delete
|
|
||||||
if (otherC.i != null) {
|
|
||||||
// delete vs insert
|
|
||||||
let s = c.d
|
|
||||||
if (c.p < otherC.p) {
|
|
||||||
append(dest, { d: s.slice(0, otherC.p - c.p), p: c.p })
|
|
||||||
s = s.slice(otherC.p - c.p)
|
|
||||||
}
|
|
||||||
if (s !== '') {
|
|
||||||
append(dest, { d: s, p: c.p + otherC.i.length })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Delete vs delete
|
|
||||||
if (c.p >= otherC.p + otherC.d.length) {
|
|
||||||
append(dest, { d: c.d, p: c.p - otherC.d.length })
|
|
||||||
} else if (c.p + c.d.length <= otherC.p) {
|
|
||||||
append(dest, c)
|
|
||||||
} else {
|
|
||||||
// They overlap somewhere.
|
|
||||||
const newC = { d: '', p: c.p }
|
|
||||||
if (c.p < otherC.p) {
|
|
||||||
newC.d = c.d.slice(0, otherC.p - c.p)
|
|
||||||
}
|
|
||||||
if (c.p + c.d.length > otherC.p + otherC.d.length) {
|
|
||||||
newC.d += c.d.slice(otherC.p + otherC.d.length - c.p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is entirely optional - just for a check that the deleted
|
|
||||||
// text in the two ops matches
|
|
||||||
const intersectStart = Math.max(c.p, otherC.p)
|
|
||||||
const intersectEnd = Math.min(
|
|
||||||
c.p + c.d.length,
|
|
||||||
otherC.p + otherC.d.length
|
|
||||||
)
|
|
||||||
const cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p)
|
|
||||||
const otherIntersect = otherC.d.slice(
|
|
||||||
intersectStart - otherC.p,
|
|
||||||
intersectEnd - otherC.p
|
|
||||||
)
|
|
||||||
if (cIntersect !== otherIntersect) {
|
|
||||||
throw new Error(
|
|
||||||
'Delete ops delete different text in the same region of the document'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newC.d !== '') {
|
|
||||||
// This could be rewritten similarly to insert v delete, above.
|
|
||||||
newC.p = transformPosition(newC.p, otherC)
|
|
||||||
append(dest, newC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
|
|
||||||
const invertComponent = function (c) {
|
|
||||||
if (c.i != null) {
|
|
||||||
return { d: c.i, p: c.p }
|
|
||||||
} else {
|
|
||||||
return { i: c.d, p: c.p }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No need to use append for invert, because the components won't be able to
|
|
||||||
// cancel with one another.
|
|
||||||
text.invert = op =>
|
|
||||||
Array.from(op.slice().reverse()).map(c => invertComponent(c))
|
|
||||||
|
|
||||||
if (typeof WEB !== 'undefined' && WEB !== null) {
|
|
||||||
if (!exports.types) {
|
|
||||||
exports.types = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is kind of awful - come up with a better way to hook this helper code up.
|
|
||||||
bootstrapTransform(text, transformComponent, checkValidOp, append)
|
|
||||||
|
|
||||||
// [] is used to prevent closure from renaming types.text
|
|
||||||
exports.types.text = text
|
|
||||||
} else {
|
|
||||||
module.exports = text
|
|
||||||
|
|
||||||
// The text type really shouldn't need this - it should be possible to define
|
|
||||||
// an efficient transform function by making a sort of transform map and passing each
|
|
||||||
// op component through it.
|
|
||||||
require('./helpers').bootstrapTransform(
|
|
||||||
text,
|
|
||||||
transformComponent,
|
|
||||||
checkValidOp,
|
|
||||||
append
|
|
||||||
)
|
|
||||||
}
|
|
48
services/document-updater/app/js/sharejs/types/README.md
Normal file
48
services/document-updater/app/js/sharejs/types/README.md
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
This directory contains all the operational transform code. Each file defines a type.
|
||||||
|
|
||||||
|
Most of the types in here are for testing or demonstration. The only types which are sent to the webclient
|
||||||
|
are `text` and `json`.
|
||||||
|
|
||||||
|
|
||||||
|
# An OT type
|
||||||
|
|
||||||
|
All OT types have the following fields:
|
||||||
|
|
||||||
|
`name`: _(string)_ Name of the type. Should match the filename.
|
||||||
|
`create() -> snapshot`: Function which creates and returns a new document snapshot
|
||||||
|
|
||||||
|
`apply(snapshot, op) -> snapshot`: A function which creates a new document snapshot with the op applied
|
||||||
|
`transform(op1, op2, side) -> op1'`: OT transform function.
|
||||||
|
|
||||||
|
Given op1, op2, `apply(s, op2, transform(op1, op2, 'left')) == apply(s, op1, transform(op2, op1, 'right'))`.
|
||||||
|
|
||||||
|
Transform and apply must never modify their arguments.
|
||||||
|
|
||||||
|
|
||||||
|
Optional properties:
|
||||||
|
|
||||||
|
`tp2`: _(bool)_ True if the transform function supports TP2. This allows p2p architectures to work.
|
||||||
|
`compose(op1, op2) -> op`: Create and return a new op which has the same effect as op1 + op2.
|
||||||
|
`serialize(snapshot) -> JSON object`: Serialize a document to something we can JSON.stringify()
|
||||||
|
`deserialize(object) -> snapshot`: Deserialize a JSON object into the document's internal snapshot format
|
||||||
|
`prune(op1', op2, side) -> op1`: Inserse transform function. Only required for TP2 types.
|
||||||
|
`normalize(op) -> op`: Fix up an op to make it valid. Eg, remove skips of size zero.
|
||||||
|
`api`: _(object)_ Set of helper methods which will be mixed in to the client document object for manipulating documents. See below.
|
||||||
|
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
`count` and `simple` are two trivial OT type definitions if you want to take a look. JSON defines
|
||||||
|
the ot-for-JSON type (see the wiki for documentation) and all the text types define different text
|
||||||
|
implementations. (I still have no idea which one I like the most, and they're fun to write!)
|
||||||
|
|
||||||
|
|
||||||
|
# API
|
||||||
|
|
||||||
|
Types can also define API functions. These methods are mixed into the client's Doc object when a document is created.
|
||||||
|
You can use them to help construct ops programatically (so users don't need to understand how ops are structured).
|
||||||
|
|
||||||
|
For example, the three text types defined here (text, text-composable and text-tp2) all provide the text API, supplying
|
||||||
|
`.insert()`, `.del()`, `.getLength` and `.getText` methods.
|
||||||
|
|
||||||
|
See text-api.coffee for an example.
|
|
@ -1,14 +0,0 @@
|
||||||
/* eslint-disable
|
|
||||||
no-unused-vars,
|
|
||||||
*/
|
|
||||||
// TODO: This file was created by bulk-decaffeinate.
|
|
||||||
// Fix any style issues and re-enable lint.
|
|
||||||
// This is included at the top of each compiled type file for the web.
|
|
||||||
|
|
||||||
/**
|
|
||||||
@const
|
|
||||||
@type {boolean}
|
|
||||||
*/
|
|
||||||
const WEB = true
|
|
||||||
|
|
||||||
const exports = window.sharejs
|
|
Loading…
Reference in a new issue