Avoid duplicating a math-closing dollar sign (#11227)
GitOrigin-RevId: ef2ef77e26df59d1af3df6dc664e284d3c70102d
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,5 @@
|
||||
/coverage
|
||||
/node_modules
|
||||
|
||||
# managed by monorepo$ bin/update_build_scripts
|
||||
.npmrc
|
||||
@@ -0,0 +1 @@
|
||||
16.17.1
|
||||
@@ -0,0 +1,9 @@
|
||||
overleaf-editor-core
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--is-library=True
|
||||
--node-version=16.17.1
|
||||
--public-repo=False
|
||||
--script-version=4.1.0
|
||||
@@ -0,0 +1,26 @@
|
||||
exports.Author = require('./lib/author')
|
||||
exports.AuthorList = require('./lib/author_list')
|
||||
exports.Blob = require('./lib/blob')
|
||||
exports.Change = require('./lib/change')
|
||||
exports.ChangeRequest = require('./lib/change_request')
|
||||
exports.ChangeNote = require('./lib/change_note')
|
||||
exports.Chunk = require('./lib/chunk')
|
||||
exports.ChunkResponse = require('./lib/chunk_response')
|
||||
exports.File = require('./lib/file')
|
||||
exports.FileMap = require('./lib/file_map')
|
||||
exports.History = require('./lib/history')
|
||||
exports.Label = require('./lib/label')
|
||||
exports.AddFileOperation = require('./lib/operation/add_file_operation')
|
||||
exports.MoveFileOperation = require('./lib/operation/move_file_operation')
|
||||
exports.EditFileOperation = require('./lib/operation/edit_file_operation')
|
||||
exports.SetFileMetadataOperation = require('./lib/operation/set_file_metadata_operation')
|
||||
exports.NoOperation = require('./lib/operation/no_operation')
|
||||
exports.Operation = require('./lib/operation')
|
||||
exports.RestoreOrigin = require('./lib/origin/restore_origin')
|
||||
exports.Origin = require('./lib/origin')
|
||||
exports.OtClient = require('./lib/ot_client')
|
||||
exports.TextOperation = require('./lib/operation/text_operation')
|
||||
exports.safePathname = require('./lib/safe_pathname')
|
||||
exports.Snapshot = require('./lib/snapshot')
|
||||
exports.util = require('./lib/util')
|
||||
exports.V2DocVersions = require('./lib/v2_doc_versions')
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} id
|
||||
* @param {string} email
|
||||
* @param {string} name
|
||||
* @classdesc
|
||||
* An author of a {@link Change}. We want to store user IDs, and then fill in
|
||||
* the other properties (which the user can change over time) when changes are
|
||||
* loaded.
|
||||
*
|
||||
* At present, we're assuming that all authors have a user ID; we may need to
|
||||
* generalise this to cover users for whom we only have a name and email, e.g.
|
||||
* from git. For now, though, this seems to do what we need.
|
||||
*/
|
||||
function Author(id, email, name) {
|
||||
assert.number(id, 'bad id')
|
||||
assert.string(email, 'bad email')
|
||||
assert.string(name, 'bad name')
|
||||
|
||||
this.id = id
|
||||
this.email = email
|
||||
this.name = name
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Author from its raw form.
|
||||
*
|
||||
* @param {Object} [raw]
|
||||
* @return {Author | null}
|
||||
*/
|
||||
Author.fromRaw = function authorFromRaw(raw) {
|
||||
if (!raw) return null
|
||||
return new Author(raw.id, raw.email, raw.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Author to raw form for storage or transmission.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
Author.prototype.toRaw = function authorToRaw() {
|
||||
return { id: this.id, email: this.email, name: this.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
Author.prototype.getId = function () {
|
||||
return this.id
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
Author.prototype.getEmail = function () {
|
||||
return this.email
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
Author.prototype.getName = function () {
|
||||
return this.name
|
||||
}
|
||||
|
||||
module.exports = Author
|
||||
@@ -0,0 +1,45 @@
|
||||
/** @module */
|
||||
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const check = require('check-types')
|
||||
|
||||
const Author = require('./author')
|
||||
|
||||
/**
|
||||
* Check that every member of the list is a number or every member is
|
||||
* an Author value, disregarding null or undefined values.
|
||||
*
|
||||
* @param {Array.<number|Author>} authors author list
|
||||
* @param {string} msg
|
||||
*/
|
||||
function assertV1(authors, msg) {
|
||||
const authors_ = authors.filter(function (a) {
|
||||
return a !== null && a !== undefined
|
||||
})
|
||||
|
||||
if (authors_.length > 0) {
|
||||
const checker = check.integer(authors_[0])
|
||||
? check.assert.integer
|
||||
: _.partial(check.assert.instance, _, Author)
|
||||
_.each(authors_, function (author) {
|
||||
checker(author, msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that every member of the list is a v2 author ID, disregarding
|
||||
* null or undefined values.
|
||||
*
|
||||
* @param {Array.<string>} authors author list
|
||||
* @param {string} msg
|
||||
*/
|
||||
function assertV2(authors, msg) {
|
||||
_.each(authors, function (author) {
|
||||
check.assert.maybe.match(author, /^[0-9a-f]{24}$/, msg)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { assertV1: assertV1, assertV2: assertV2 }
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
const TextOperation = require('./operation/text_operation')
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @classdesc
|
||||
* Metadata record for the content of a file.
|
||||
*/
|
||||
function Blob(hash, byteLength, stringLength) {
|
||||
this.setHash(hash)
|
||||
this.setByteLength(byteLength)
|
||||
this.setStringLength(stringLength)
|
||||
}
|
||||
|
||||
class NotFoundError extends OError {
|
||||
constructor(hash) {
|
||||
super(`blob ${hash} not found`, { hash })
|
||||
this.hash = hash
|
||||
}
|
||||
}
|
||||
Blob.NotFoundError = NotFoundError
|
||||
|
||||
Blob.HEX_HASH_RX_STRING = '^[0-9a-f]{40,40}$'
|
||||
Blob.HEX_HASH_RX = new RegExp(Blob.HEX_HASH_RX_STRING)
|
||||
|
||||
module.exports = Blob
|
||||
|
||||
Blob.fromRaw = function blobFromRaw(raw) {
|
||||
if (raw) {
|
||||
return new Blob(raw.hash, raw.byteLength, raw.stringLength)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Blob.prototype.toRaw = function blobToRaw() {
|
||||
return {
|
||||
hash: this.hash,
|
||||
byteLength: this.byteLength,
|
||||
stringLength: this.stringLength,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex hash.
|
||||
* @return {?String}
|
||||
*/
|
||||
Blob.prototype.getHash = function () {
|
||||
return this.hash
|
||||
}
|
||||
Blob.prototype.setHash = function (hash) {
|
||||
assert.maybe.match(hash, Blob.HEX_HASH_RX, 'bad hash')
|
||||
this.hash = hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the blob in bytes.
|
||||
* @return {number}
|
||||
*/
|
||||
Blob.prototype.getByteLength = function () {
|
||||
return this.byteLength
|
||||
}
|
||||
Blob.prototype.setByteLength = function (byteLength) {
|
||||
assert.maybe.integer(byteLength, 'bad byteLength')
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Utf-8 length of the blob content, if it appears to be valid UTF-8.
|
||||
* @return {?number}
|
||||
*/
|
||||
Blob.prototype.getStringLength = function () {
|
||||
return this.stringLength
|
||||
}
|
||||
Blob.prototype.setStringLength = function (stringLength) {
|
||||
assert.maybe.integer(stringLength, 'bad stringLength')
|
||||
this.stringLength = stringLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of the largest file that we'll read to determine whether we can edit it
|
||||
* or not, in bytes. The final decision on whether a file is editable or not is
|
||||
* based on the number of characters it contains, but we need to read the file
|
||||
* in to determine that; so it is useful to have an upper bound on the byte
|
||||
* length of a file that might be editable.
|
||||
*
|
||||
* The reason for the factor of 3 is as follows. We cannot currently edit files
|
||||
* that contain characters outside of the basic multilingual plane, so we're
|
||||
* limited to characters that can be represented in a single, two-byte UCS-2
|
||||
* code unit. Encoding the largest such value, 0xFFFF (which is not actually
|
||||
* a valid character), takes three bytes in UTF-8: 0xEF 0xBF 0xBF. A file
|
||||
* composed entirely of three-byte UTF-8 codepoints is the worst case; in
|
||||
* practice, this is a very conservative upper bound.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND = 3 * TextOperation.MAX_STRING_LENGTH
|
||||
@@ -0,0 +1,333 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const AuthorList = require('./author_list')
|
||||
const Operation = require('./operation')
|
||||
const Origin = require('./origin')
|
||||
const Snapshot = require('./snapshot')
|
||||
const FileMap = require('./file_map')
|
||||
const V2DocVersions = require('./v2_doc_versions')
|
||||
|
||||
/**
|
||||
* @typedef {import("./author")} Author
|
||||
* @typedef {import("./types").BlobStore} BlobStore
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* A Change is a list of {@link Operation}s applied atomically by given
|
||||
* {@link Author}(s) at a given time.
|
||||
*/
|
||||
class Change {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Array.<Operation>} operations
|
||||
* @param {Date} timestamp
|
||||
* @param {number[] | Author[]} [authors]
|
||||
* @param {Origin} [origin]
|
||||
* @param {string[]} [v2Authors]
|
||||
* @param {string} [projectVersion]
|
||||
* @param {V2DocVersions} [v2DocVersions]
|
||||
*/
|
||||
constructor(
|
||||
operations,
|
||||
timestamp,
|
||||
authors,
|
||||
origin,
|
||||
v2Authors,
|
||||
projectVersion,
|
||||
v2DocVersions
|
||||
) {
|
||||
this.setOperations(operations)
|
||||
this.setTimestamp(timestamp)
|
||||
this.setAuthors(authors || [])
|
||||
this.setOrigin(origin)
|
||||
this.setV2Authors(v2Authors || [])
|
||||
this.setProjectVersion(projectVersion)
|
||||
this.setV2DocVersions(v2DocVersions)
|
||||
}
|
||||
|
||||
/**
|
||||
* For serialization.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
toRaw() {
|
||||
function toRaw(object) {
|
||||
return object.toRaw()
|
||||
}
|
||||
const raw = {
|
||||
operations: this.operations.map(toRaw),
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
authors: this.authors,
|
||||
}
|
||||
if (this.v2Authors) raw.v2Authors = this.v2Authors
|
||||
if (this.origin) raw.origin = this.origin.toRaw()
|
||||
if (this.projectVersion) raw.projectVersion = this.projectVersion
|
||||
if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw()
|
||||
return raw
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
if (!raw) return null
|
||||
assert.array.of.object(raw.operations, 'bad raw.operations')
|
||||
assert.nonEmptyString(raw.timestamp, 'bad raw.timestamp')
|
||||
|
||||
// Hack to clean up bad data where author id of some changes was 0, instead of
|
||||
// null. The root cause of the bug is fixed in
|
||||
// https://github.com/overleaf/write_latex/pull/3804 but the bad data persists
|
||||
// on S3
|
||||
let authors
|
||||
if (raw.authors) {
|
||||
authors = raw.authors.map(
|
||||
// Null represents an anonymous author
|
||||
author => (author === 0 ? null : author)
|
||||
)
|
||||
}
|
||||
|
||||
return new Change(
|
||||
raw.operations.map(Operation.fromRaw),
|
||||
new Date(raw.timestamp),
|
||||
authors,
|
||||
raw.origin && Origin.fromRaw(raw.origin),
|
||||
raw.v2Authors,
|
||||
raw.projectVersion,
|
||||
raw.v2DocVersions && V2DocVersions.fromRaw(raw.v2DocVersions)
|
||||
)
|
||||
}
|
||||
|
||||
getOperations() {
|
||||
return this.operations
|
||||
}
|
||||
|
||||
setOperations(operations) {
|
||||
assert.array.of.object(operations, 'Change: bad operations')
|
||||
this.operations = operations
|
||||
}
|
||||
|
||||
getTimestamp() {
|
||||
return this.timestamp
|
||||
}
|
||||
|
||||
setTimestamp(timestamp) {
|
||||
assert.date(timestamp, 'Change: bad timestamp')
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array.<Author>} zero or more
|
||||
*/
|
||||
getAuthors() {
|
||||
return this.authors
|
||||
}
|
||||
|
||||
setAuthors(authors) {
|
||||
assert.array(authors, 'Change: bad author ids array')
|
||||
if (authors.length > 1) {
|
||||
assert.maybe.emptyArray(
|
||||
this.v2Authors,
|
||||
'Change: cannot set v1 authors if v2 authors is set'
|
||||
)
|
||||
}
|
||||
AuthorList.assertV1(authors, 'Change: bad author ids')
|
||||
|
||||
this.authors = authors
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array.<Author>} zero or more
|
||||
*/
|
||||
getV2Authors() {
|
||||
return this.v2Authors
|
||||
}
|
||||
|
||||
setV2Authors(v2Authors) {
|
||||
assert.array(v2Authors, 'Change: bad v2 author ids array')
|
||||
if (v2Authors.length > 1) {
|
||||
assert.maybe.emptyArray(
|
||||
this.authors,
|
||||
'Change: cannot set v2 authors if v1 authors is set'
|
||||
)
|
||||
}
|
||||
AuthorList.assertV2(v2Authors, 'Change: not a v2 author id')
|
||||
this.v2Authors = v2Authors
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Origin | null | undefined}
|
||||
*/
|
||||
getOrigin() {
|
||||
return this.origin
|
||||
}
|
||||
|
||||
setOrigin(origin) {
|
||||
assert.maybe.instance(origin, Origin, 'Change: bad origin')
|
||||
this.origin = origin
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
getProjectVersion() {
|
||||
return this.projectVersion
|
||||
}
|
||||
|
||||
setProjectVersion(projectVersion) {
|
||||
assert.maybe.match(
|
||||
projectVersion,
|
||||
Change.PROJECT_VERSION_RX,
|
||||
'Change: bad projectVersion'
|
||||
)
|
||||
this.projectVersion = projectVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {V2DocVersions | null | undefined}
|
||||
*/
|
||||
getV2DocVersions() {
|
||||
return this.v2DocVersions
|
||||
}
|
||||
|
||||
setV2DocVersions(v2DocVersions) {
|
||||
assert.maybe.instance(
|
||||
v2DocVersions,
|
||||
V2DocVersions,
|
||||
'Change: bad v2DocVersions'
|
||||
)
|
||||
this.v2DocVersions = v2DocVersions
|
||||
}
|
||||
|
||||
/**
|
||||
* If this Change references blob hashes, add them to the given set.
|
||||
*
|
||||
* @param {Set.<String>} blobHashes
|
||||
*/
|
||||
findBlobHashes(blobHashes) {
|
||||
for (const operation of this.operations) {
|
||||
operation.findBlobHashes(blobHashes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If this Change contains any File objects, load them.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadFiles(kind, blobStore) {
|
||||
return BPromise.each(this.operations, operation =>
|
||||
operation.loadFiles(kind, blobStore)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an operation to the end of the operations list.
|
||||
*
|
||||
* @param {Operation} operation
|
||||
* @return {this}
|
||||
*/
|
||||
pushOperation(operation) {
|
||||
this.getOperations().push(operation)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply this change to a snapshot. All operations are applied, and then the
|
||||
* snapshot version is increased.
|
||||
*
|
||||
* Recoverable errors (caused by historical bad data) are ignored unless
|
||||
* opts.strict is true
|
||||
*
|
||||
* @param {Snapshot} snapshot modified in place
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.strict] - Do not ignore recoverable errors
|
||||
*/
|
||||
applyTo(snapshot, opts = {}) {
|
||||
assert.object(snapshot, 'bad snapshot')
|
||||
|
||||
for (const operation of this.operations) {
|
||||
try {
|
||||
operation.applyTo(snapshot, opts)
|
||||
} catch (err) {
|
||||
const recoverable =
|
||||
err instanceof Snapshot.EditMissingFileError ||
|
||||
err instanceof FileMap.FileNotFoundError
|
||||
if (!recoverable || opts.strict) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update project version if present in change
|
||||
if (this.projectVersion) {
|
||||
snapshot.setProjectVersion(this.projectVersion)
|
||||
}
|
||||
|
||||
// update doc versions
|
||||
if (this.v2DocVersions) {
|
||||
snapshot.updateV2DocVersions(this.v2DocVersions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform this change to account for the fact that the other change occurred
|
||||
* simultaneously and was applied first.
|
||||
*
|
||||
* This change is modified in place (by transforming its operations).
|
||||
*
|
||||
* @param {Change} other
|
||||
*/
|
||||
transformAfter(other) {
|
||||
assert.object(other, 'bad other')
|
||||
|
||||
const thisOperations = this.getOperations()
|
||||
const otherOperations = other.getOperations()
|
||||
for (let i = 0; i < otherOperations.length; ++i) {
|
||||
for (let j = 0; j < thisOperations.length; ++j) {
|
||||
thisOperations[j] = Operation.transform(
|
||||
thisOperations[j],
|
||||
otherOperations[i]
|
||||
)[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
return Change.fromRaw(this.toRaw())
|
||||
}
|
||||
|
||||
store(blobStore, concurrency) {
|
||||
assert.maybe.number(concurrency, 'bad concurrency')
|
||||
|
||||
const raw = this.toRaw()
|
||||
raw.authors = _.uniq(raw.authors)
|
||||
|
||||
return BPromise.map(
|
||||
this.operations,
|
||||
operation => operation.store(blobStore),
|
||||
{ concurrency: concurrency || 1 }
|
||||
).then(rawOperations => {
|
||||
raw.operations = rawOperations
|
||||
return raw
|
||||
})
|
||||
}
|
||||
|
||||
canBeComposedWith(other) {
|
||||
const operations = this.getOperations()
|
||||
const otherOperations = other.getOperations()
|
||||
|
||||
// We ignore complex changes with more than 1 operation
|
||||
if (operations.length > 1 || otherOperations.length > 1) return false
|
||||
|
||||
return operations[0].canBeComposedWith(otherOperations[0])
|
||||
}
|
||||
}
|
||||
|
||||
Change.PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$'
|
||||
Change.PROJECT_VERSION_RX = new RegExp(Change.PROJECT_VERSION_RX_STRING)
|
||||
|
||||
module.exports = Change
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Change = require('./change')
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} baseVersion the new base version for the change
|
||||
* @param {?Change} change
|
||||
* @classdesc
|
||||
* A `ChangeNote` is returned when the server has applied a {@link Change}.
|
||||
*/
|
||||
function ChangeNote(baseVersion, change) {
|
||||
assert.integer(baseVersion, 'bad baseVersion')
|
||||
assert.maybe.instance(change, Change, 'bad change')
|
||||
|
||||
this.baseVersion = baseVersion
|
||||
this.change = change
|
||||
}
|
||||
|
||||
module.exports = ChangeNote
|
||||
|
||||
/**
|
||||
* For serialization.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
ChangeNote.prototype.toRaw = function changeNoteToRaw() {
|
||||
return {
|
||||
baseVersion: this.baseVersion,
|
||||
change: this.change.toRaw(),
|
||||
}
|
||||
}
|
||||
|
||||
ChangeNote.prototype.toRawWithoutChange =
|
||||
function changeNoteToRawWithoutChange() {
|
||||
return {
|
||||
baseVersion: this.baseVersion,
|
||||
}
|
||||
}
|
||||
|
||||
ChangeNote.fromRaw = function changeNoteFromRaw(raw) {
|
||||
assert.integer(raw.baseVersion, 'bad raw.baseVersion')
|
||||
assert.maybe.object(raw.change, 'bad raw.changes')
|
||||
|
||||
return new ChangeNote(raw.baseVersion, Change.fromRaw(raw.change))
|
||||
}
|
||||
|
||||
ChangeNote.prototype.getBaseVersion = function () {
|
||||
return this.baseVersion
|
||||
}
|
||||
|
||||
ChangeNote.prototype.getResultVersion = function () {
|
||||
return this.baseVersion + 1
|
||||
}
|
||||
|
||||
ChangeNote.prototype.getChange = function () {
|
||||
return this.change
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const AuthorList = require('./author_list')
|
||||
const Change = require('./change')
|
||||
const Operation = require('./operation')
|
||||
|
||||
/**
|
||||
* @typedef {import("./author")} Author
|
||||
*/
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} baseVersion
|
||||
* @param {Array.<Operation>} operations
|
||||
* @param {boolean} [untransformable]
|
||||
* @param {number[] | Author[]} [authors]
|
||||
* @classdesc
|
||||
* A `ChangeRequest` is a list of {@link Operation}s that the server can apply
|
||||
* as a {@link Change}.
|
||||
*
|
||||
* If the change is marked as `untransformable`, then the server will not
|
||||
* attempt to transform it if it is out of date (i.e. if the baseVersion no
|
||||
* longer matches the project's latest version). For example, if the client
|
||||
* needs to ensure that a metadata property is set on exactly one file, it can't
|
||||
* do that reliably if there's a chance that other clients will also change the
|
||||
* metadata at the same time. The expectation is that if the change is rejected,
|
||||
* the client will retry on a later version.
|
||||
*/
|
||||
function ChangeRequest(baseVersion, operations, untransformable, authors) {
|
||||
assert.integer(baseVersion, 'bad baseVersion')
|
||||
assert.array.of.object(operations, 'bad operations')
|
||||
assert.maybe.boolean(untransformable, 'ChangeRequest: bad untransformable')
|
||||
// TODO remove authors once we have JWTs working --- pass as parameter to
|
||||
// makeChange instead
|
||||
authors = authors || []
|
||||
|
||||
// check all are the same type
|
||||
AuthorList.assertV1(authors, 'bad authors')
|
||||
|
||||
this.authors = authors
|
||||
this.baseVersion = baseVersion
|
||||
this.operations = operations
|
||||
this.untransformable = untransformable || false
|
||||
}
|
||||
|
||||
module.exports = ChangeRequest
|
||||
|
||||
/**
|
||||
* For serialization.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
ChangeRequest.prototype.toRaw = function changeRequestToRaw() {
|
||||
function operationToRaw(operation) {
|
||||
return operation.toRaw()
|
||||
}
|
||||
|
||||
return {
|
||||
baseVersion: this.baseVersion,
|
||||
operations: this.operations.map(operationToRaw),
|
||||
untransformable: this.untransformable,
|
||||
authors: this.authors,
|
||||
}
|
||||
}
|
||||
|
||||
ChangeRequest.fromRaw = function changeRequestFromRaw(raw) {
|
||||
assert.array.of.object(raw.operations, 'bad raw.operations')
|
||||
return new ChangeRequest(
|
||||
raw.baseVersion,
|
||||
raw.operations.map(Operation.fromRaw),
|
||||
raw.untransformable,
|
||||
raw.authors
|
||||
)
|
||||
}
|
||||
|
||||
ChangeRequest.prototype.getBaseVersion = function () {
|
||||
return this.baseVersion
|
||||
}
|
||||
|
||||
ChangeRequest.prototype.isUntransformable = function () {
|
||||
return this.untransformable
|
||||
}
|
||||
|
||||
ChangeRequest.prototype.makeChange = function changeRequestMakeChange(
|
||||
timestamp
|
||||
) {
|
||||
return new Change(this.operations, timestamp, this.authors)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
const History = require('./history')
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").BlobStore} BlobStore
|
||||
* @typedef {import("./change")} Change
|
||||
* @typedef {import("./snapshot")} Snapshot
|
||||
*/
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {History} history
|
||||
* @param {number} startVersion
|
||||
*
|
||||
* @classdesc
|
||||
* A Chunk is a {@link History} that is part of a project's overall history. It
|
||||
* has a start and an end version that place its History in context.
|
||||
*/
|
||||
function Chunk(history, startVersion) {
|
||||
assert.instance(history, History, 'bad history')
|
||||
assert.integer(startVersion, 'bad startVersion')
|
||||
|
||||
this.history = history
|
||||
this.startVersion = startVersion
|
||||
}
|
||||
|
||||
class ConflictingEndVersion extends OError {
|
||||
constructor(clientEndVersion, latestEndVersion) {
|
||||
const message =
|
||||
'client sent updates with end_version ' +
|
||||
clientEndVersion +
|
||||
' but latest chunk has end_version ' +
|
||||
latestEndVersion
|
||||
super(message, { clientEndVersion, latestEndVersion })
|
||||
this.clientEndVersion = clientEndVersion
|
||||
this.latestEndVersion = latestEndVersion
|
||||
}
|
||||
}
|
||||
Chunk.ConflictingEndVersion = ConflictingEndVersion
|
||||
|
||||
class NotFoundError extends OError {
|
||||
// `message` and `info` optional arguments allow children classes to override
|
||||
// these values, ensuring backwards compatibility with previous implementation
|
||||
// based on the `overleaf-error-type` library
|
||||
constructor(projectId, message, info) {
|
||||
const errorMessage = message || `no chunks for project ${projectId}`
|
||||
const errorInfo = info || { projectId }
|
||||
super(errorMessage, errorInfo)
|
||||
this.projectId = projectId
|
||||
}
|
||||
}
|
||||
Chunk.NotFoundError = NotFoundError
|
||||
|
||||
class VersionNotFoundError extends NotFoundError {
|
||||
constructor(projectId, version) {
|
||||
super(projectId, `chunk for ${projectId} v ${version} not found`, {
|
||||
projectId,
|
||||
version,
|
||||
})
|
||||
this.projectId = projectId
|
||||
this.version = version
|
||||
}
|
||||
}
|
||||
Chunk.VersionNotFoundError = VersionNotFoundError
|
||||
|
||||
class BeforeTimestampNotFoundError extends NotFoundError {
|
||||
constructor(projectId, timestamp) {
|
||||
super(projectId, `chunk for ${projectId} timestamp ${timestamp} not found`)
|
||||
this.projectId = projectId
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
Chunk.BeforeTimestampNotFoundError = BeforeTimestampNotFoundError
|
||||
|
||||
class NotPersistedError extends NotFoundError {
|
||||
constructor(projectId) {
|
||||
super(projectId, `chunk for ${projectId} not persisted yet`)
|
||||
this.projectId = projectId
|
||||
}
|
||||
}
|
||||
Chunk.NotPersistedError = NotPersistedError
|
||||
|
||||
Chunk.fromRaw = function chunkFromRaw(raw) {
|
||||
return new Chunk(History.fromRaw(raw.history), raw.startVersion)
|
||||
}
|
||||
|
||||
Chunk.prototype.toRaw = function chunkToRaw() {
|
||||
return { history: this.history.toRaw(), startVersion: this.startVersion }
|
||||
}
|
||||
|
||||
/**
|
||||
* The history for this chunk.
|
||||
*
|
||||
* @return {History}
|
||||
*/
|
||||
Chunk.prototype.getHistory = function () {
|
||||
return this.history
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see History#getSnapshot}
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
Chunk.prototype.getSnapshot = function () {
|
||||
return this.history.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see History#getChanges}
|
||||
* @return {Array.<Change>}
|
||||
*/
|
||||
Chunk.prototype.getChanges = function () {
|
||||
return this.history.getChanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see History#pushChanges}
|
||||
* @param {Array.<Change>} changes
|
||||
*/
|
||||
Chunk.prototype.pushChanges = function chunkPushChanges(changes) {
|
||||
this.history.pushChanges(changes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of the project after applying all changes in this chunk.
|
||||
*
|
||||
* @return {number} non-negative, greater than or equal to start version
|
||||
*/
|
||||
Chunk.prototype.getEndVersion = function chunkGetEndVersion() {
|
||||
return this.startVersion + this.history.countChanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* The timestamp of the last change in this chunk
|
||||
*/
|
||||
|
||||
Chunk.prototype.getEndTimestamp = function getEndTimestamp() {
|
||||
if (!this.history.countChanges()) return null
|
||||
return this.history.getChanges().slice(-1)[0].getTimestamp()
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of the project before applying all changes in this chunk.
|
||||
*
|
||||
* @return {number} non-negative, less than or equal to end version
|
||||
*/
|
||||
Chunk.prototype.getStartVersion = function () {
|
||||
return this.startVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* {@see History#loadFiles}
|
||||
*
|
||||
* @param {string} kind
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise}
|
||||
*/
|
||||
Chunk.prototype.loadFiles = function chunkLoadFiles(kind, blobStore) {
|
||||
return this.history.loadFiles(kind, blobStore)
|
||||
}
|
||||
|
||||
module.exports = Chunk
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const Chunk = require('./chunk')
|
||||
|
||||
//
|
||||
// The ChunkResponse allows for additional data to be sent back with the chunk
|
||||
// at present there are no extra data to send.
|
||||
//
|
||||
|
||||
function ChunkResponse(chunk) {
|
||||
assert.instance(chunk, Chunk)
|
||||
this.chunk = chunk
|
||||
}
|
||||
|
||||
ChunkResponse.prototype.toRaw = function chunkResponseToRaw() {
|
||||
return {
|
||||
chunk: this.chunk.toRaw(),
|
||||
}
|
||||
}
|
||||
|
||||
ChunkResponse.fromRaw = function chunkResponseFromRaw(raw) {
|
||||
if (!raw) return null
|
||||
|
||||
return new ChunkResponse(Chunk.fromRaw(raw.chunk))
|
||||
}
|
||||
|
||||
ChunkResponse.prototype.getChunk = function () {
|
||||
return this.chunk
|
||||
}
|
||||
|
||||
module.exports = ChunkResponse
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const OError = require('@overleaf/o-error')
|
||||
const FileData = require('./file_data')
|
||||
const HashFileData = require('./file_data/hash_file_data')
|
||||
const StringFileData = require('./file_data/string_file_data')
|
||||
|
||||
/**
|
||||
* @typedef {import("./blob")} Blob
|
||||
* @typedef {import("./types").BlobStore} BlobStore
|
||||
* @typedef {import("./types").StringFileRawData} StringFileRawData
|
||||
* @typedef {import("./operation/text_operation")} TextOperation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("bluebird")<T>} BPromise
|
||||
*/
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {FileData} data
|
||||
* @param {Object} [metadata]
|
||||
*
|
||||
* @classdesc
|
||||
* A file in a {@link Snapshot}. A file has both data and metadata. There
|
||||
* are several classes of data that represent the various types of file
|
||||
* data that are supported, namely text and binary, and also the various
|
||||
* states that a file's data can be in, namely:
|
||||
*
|
||||
* 1. Hash only: all we know is the file's hash; this is how we encode file
|
||||
* content in long term storage.
|
||||
* 1. Lazily loaded: the hash of the file, its length, and its type are known,
|
||||
* but its content is not loaded. Operations are cached for application
|
||||
* later.
|
||||
* 1. Eagerly loaded: the content of a text file is fully loaded into memory
|
||||
* as a string.
|
||||
* 1. Hollow: only the byte and/or UTF-8 length of the file are known; this is
|
||||
* used to allow for validation of operations when editing collaboratively
|
||||
* without having to keep file data in memory on the server.
|
||||
*/
|
||||
function File(data, metadata) {
|
||||
assert.instance(data, FileData, 'File: bad data')
|
||||
|
||||
this.data = data
|
||||
this.setMetadata(metadata || {})
|
||||
}
|
||||
|
||||
File.fromRaw = function fileFromRaw(raw) {
|
||||
if (!raw) return null
|
||||
return new File(FileData.fromRaw(raw), raw.metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @param {Object} [metadata]
|
||||
* @return {File}
|
||||
*/
|
||||
File.fromHash = function fileFromHash(hash, metadata) {
|
||||
return new File(new HashFileData(hash), metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} string
|
||||
* @param {Object} [metadata]
|
||||
* @return {File}
|
||||
*/
|
||||
File.fromString = function fileFromString(string, metadata) {
|
||||
return new File(new StringFileData(string), metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [byteLength]
|
||||
* @param {number} [stringLength]
|
||||
* @param {Object} [metadata]
|
||||
* @return {File}
|
||||
*/
|
||||
File.createHollow = function fileCreateHollow(
|
||||
byteLength,
|
||||
stringLength,
|
||||
metadata
|
||||
) {
|
||||
return new File(FileData.createHollow(byteLength, stringLength), metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob} blob
|
||||
* @param {Object} [metadata]
|
||||
* @return {File}
|
||||
*/
|
||||
File.createLazyFromBlob = function fileCreateLazyFromBlob(blob, metadata) {
|
||||
return new File(FileData.createLazyFromBlob(blob), metadata)
|
||||
}
|
||||
|
||||
function storeRawMetadata(metadata, raw) {
|
||||
if (!_.isEmpty(metadata)) {
|
||||
raw.metadata = _.cloneDeep(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
File.prototype.toRaw = function () {
|
||||
const rawFileData = this.data.toRaw()
|
||||
storeRawMetadata(this.metadata, rawFileData)
|
||||
return rawFileData
|
||||
}
|
||||
|
||||
/**
|
||||
* Hexadecimal SHA-1 hash of the file's content, if known.
|
||||
*
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
File.prototype.getHash = function () {
|
||||
return this.data.getHash()
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the file, if it is known and if this file has UTF-8 encoded
|
||||
* content.
|
||||
*
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
File.prototype.getContent = function () {
|
||||
return this.data.getContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this file has string content and is small enough to be edited using
|
||||
* {@link TextOperation}s.
|
||||
*
|
||||
* @return {boolean | null | undefined} null if it is not currently known
|
||||
*/
|
||||
File.prototype.isEditable = function () {
|
||||
return this.data.isEditable()
|
||||
}
|
||||
|
||||
/**
|
||||
* The length of the file's content in bytes, if known.
|
||||
*
|
||||
* @return {number | null | undefined}
|
||||
*/
|
||||
File.prototype.getByteLength = function () {
|
||||
return this.data.getByteLength()
|
||||
}
|
||||
|
||||
/**
|
||||
* The length of the file's content in characters, if known.
|
||||
*
|
||||
* @return {number | null | undefined}
|
||||
*/
|
||||
File.prototype.getStringLength = function () {
|
||||
return this.data.getStringLength()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the metadata object for this file.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
File.prototype.getMetadata = function () {
|
||||
return this.metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the metadata object for this file.
|
||||
*
|
||||
* @param {Object} metadata
|
||||
*/
|
||||
File.prototype.setMetadata = function (metadata) {
|
||||
assert.object(metadata, 'File: bad metadata')
|
||||
this.metadata = metadata
|
||||
}
|
||||
|
||||
class NotEditableError extends OError {
|
||||
constructor() {
|
||||
super('File is not editable')
|
||||
}
|
||||
}
|
||||
|
||||
File.NotEditableError = NotEditableError
|
||||
|
||||
/**
|
||||
* Edit this file, if possible.
|
||||
*
|
||||
* @param {TextOperation} textOperation
|
||||
*/
|
||||
File.prototype.edit = function (textOperation) {
|
||||
if (!this.data.isEditable()) throw new File.NotEditableError()
|
||||
this.data.edit(textOperation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a file.
|
||||
*
|
||||
* @return {File} a new object of the same type
|
||||
*/
|
||||
File.prototype.clone = function fileClone() {
|
||||
return File.fromRaw(this.toRaw())
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this file's data to the given kind. This may require us to load file
|
||||
* size or content from the given blob store, so this is an asynchronous
|
||||
* operation.
|
||||
*
|
||||
* @param {string} kind
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise.<File>} for this
|
||||
*/
|
||||
File.prototype.load = function (kind, blobStore) {
|
||||
return this.data.load(kind, blobStore).then(data => {
|
||||
this.data = data
|
||||
return this
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the file's content in the blob store and return a raw file with
|
||||
* the corresponding hash. As a side effect, make this object consistent with
|
||||
* the hash.
|
||||
*
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<Object>} a raw HashFile
|
||||
*/
|
||||
File.prototype.store = function (blobStore) {
|
||||
return this.data.store(blobStore).then(raw => {
|
||||
storeRawMetadata(this.metadata, raw)
|
||||
return raw
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob hash for an empty file.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
File.EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
|
||||
|
||||
module.exports = File
|
||||
@@ -0,0 +1,71 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
|
||||
class BinaryFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} hash
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash, byteLength) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX, 'BinaryFileData: bad hash')
|
||||
assert.integer(byteLength, 'BinaryFileData: bad byteLength')
|
||||
assert.greaterOrEqual(byteLength, 0, 'BinaryFileData: low byteLength')
|
||||
|
||||
this.hash = hash
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new BinaryFileData(raw.hash, raw.byteLength)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return { hash: this.hash, byteLength: this.byteLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getHash() {
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return false
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return this.byteLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toEager() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toLazy() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow() {
|
||||
return BPromise.try(() => FileData.createHollow(this.byteLength, null))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
store() {
|
||||
return BPromise.resolve({ hash: this.hash })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BinaryFileData
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
|
||||
class HashFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} hash
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX, 'HashFileData: bad hash')
|
||||
this.hash = hash
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new HashFileData(raw.hash)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return { hash: this.hash }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getHash() {
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toEager(blobStore) {
|
||||
return this.toLazy(blobStore).then(lazyFileData =>
|
||||
lazyFileData.toEager(blobStore)
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toLazy(blobStore) {
|
||||
return blobStore.getBlob(this.hash).then(blob => {
|
||||
if (!blob) throw new Error('blob not found: ' + this.hash)
|
||||
return FileData.createLazyFromBlob(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow(blobStore) {
|
||||
return blobStore.getBlob(this.hash).then(function (blob) {
|
||||
return FileData.createHollow(blob.getByteLength(), blob.getStringLength())
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
store() {
|
||||
return BPromise.resolve({ hash: this.hash })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HashFileData
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const FileData = require('./')
|
||||
|
||||
class HollowBinaryFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(byteLength) {
|
||||
super()
|
||||
assert.integer(byteLength, 'HollowBinaryFileData: bad byteLength')
|
||||
assert.greaterOrEqual(byteLength, 0, 'HollowBinaryFileData: low byteLength')
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new HollowBinaryFileData(raw.byteLength)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return { byteLength: this.byteLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return this.byteLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return false
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HollowBinaryFileData
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const FileData = require('./')
|
||||
|
||||
class HollowStringFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} stringLength
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(stringLength) {
|
||||
super()
|
||||
assert.integer(stringLength, 'HollowStringFileData: bad stringLength')
|
||||
assert.greaterOrEqual(
|
||||
stringLength,
|
||||
0,
|
||||
'HollowStringFileData: low stringLength'
|
||||
)
|
||||
this.stringLength = stringLength
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new HollowStringFileData(raw.stringLength)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return { stringLength: this.stringLength }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
edit(textOperation) {
|
||||
this.stringLength = textOperation.applyToLength(this.stringLength)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HollowStringFileData
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const Blob = require('../blob')
|
||||
|
||||
// Dependencies are loaded at the bottom of the file to mitigate circular
|
||||
// dependency
|
||||
let BinaryFileData = null
|
||||
let HashFileData = null
|
||||
let HollowBinaryFileData = null
|
||||
let HollowStringFileData = null
|
||||
let LazyStringFileData = null
|
||||
let StringFileData = null
|
||||
|
||||
/**
|
||||
* @typedef {import("../types").BlobStore} BlobStore
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* Helper to represent the content of a file. This class and its subclasses
|
||||
* should be used only through {@link File}.
|
||||
*/
|
||||
class FileData {
|
||||
/** @see File.fromRaw */
|
||||
static fromRaw(raw) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'hash')) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
|
||||
return BinaryFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
|
||||
return LazyStringFileData.fromRaw(raw)
|
||||
return HashFileData.fromRaw(raw)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'byteLength'))
|
||||
return HollowBinaryFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'stringLength'))
|
||||
return HollowStringFileData.fromRaw(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'content'))
|
||||
return StringFileData.fromRaw(raw)
|
||||
throw new Error('FileData: bad raw object ' + JSON.stringify(raw))
|
||||
}
|
||||
|
||||
/** @see File.createHollow */
|
||||
static createHollow(byteLength, stringLength) {
|
||||
if (stringLength == null) {
|
||||
return new HollowBinaryFileData(byteLength)
|
||||
}
|
||||
return new HollowStringFileData(stringLength)
|
||||
}
|
||||
|
||||
/** @see File.createLazyFromBlob */
|
||||
static createLazyFromBlob(blob) {
|
||||
assert.instance(blob, Blob, 'FileData: bad blob')
|
||||
if (blob.getStringLength() == null) {
|
||||
return new BinaryFileData(blob.getHash(), blob.getByteLength())
|
||||
}
|
||||
return new LazyStringFileData(blob.getHash(), blob.getStringLength())
|
||||
}
|
||||
|
||||
toRaw() {
|
||||
throw new Error('FileData: toRaw not implemented')
|
||||
}
|
||||
|
||||
/** @see File#getHash */
|
||||
getHash() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** @see File#getContent */
|
||||
getContent() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** @see File#isEditable */
|
||||
isEditable() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** @see File#getByteLength */
|
||||
getByteLength() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** @see File#getStringLength */
|
||||
getStringLength() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** @see File#edit */
|
||||
edit(textOperation) {
|
||||
throw new Error('edit not implemented for ' + JSON.stringify(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
toEager(blobStore) {
|
||||
return BPromise.reject(
|
||||
new Error('toEager not implemented for ' + JSON.stringify(this))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
toLazy(blobStore) {
|
||||
return BPromise.reject(
|
||||
new Error('toLazy not implemented for ' + JSON.stringify(this))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<FileData>}
|
||||
* @abstract
|
||||
* @see FileData#load
|
||||
*/
|
||||
toHollow(blobStore) {
|
||||
return BPromise.reject(
|
||||
new Error('toHollow not implemented for ' + JSON.stringify(this))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#load
|
||||
* @param {string} kind
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<FileData>}
|
||||
*/
|
||||
load(kind, blobStore) {
|
||||
if (kind === 'eager') return this.toEager(blobStore)
|
||||
if (kind === 'lazy') return this.toLazy(blobStore)
|
||||
if (kind === 'hollow') return this.toHollow(blobStore)
|
||||
throw new Error('bad file data load kind: ' + kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see File#store
|
||||
* @function
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {BPromise<Object>} a raw HashFile
|
||||
* @abstract
|
||||
*/
|
||||
store(blobStore) {
|
||||
return BPromise.reject(
|
||||
new Error('store not implemented for ' + JSON.stringify(this))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FileData
|
||||
|
||||
BinaryFileData = require('./binary_file_data')
|
||||
HashFileData = require('./hash_file_data')
|
||||
HollowBinaryFileData = require('./hollow_binary_file_data')
|
||||
HollowStringFileData = require('./hollow_string_file_data')
|
||||
LazyStringFileData = require('./lazy_string_file_data')
|
||||
StringFileData = require('./string_file_data')
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const Blob = require('../blob')
|
||||
const FileData = require('./')
|
||||
const EagerStringFileData = require('./string_file_data')
|
||||
const TextOperation = require('../operation/text_operation')
|
||||
|
||||
class LazyStringFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} hash
|
||||
* @param {number} stringLength
|
||||
* @param {Array.<TextOperation>} [textOperations]
|
||||
* @see FileData
|
||||
*/
|
||||
constructor(hash, stringLength, textOperations) {
|
||||
super()
|
||||
assert.match(hash, Blob.HEX_HASH_RX)
|
||||
assert.greaterOrEqual(stringLength, 0)
|
||||
assert.maybe.array.of.instance(textOperations, TextOperation)
|
||||
|
||||
this.hash = hash
|
||||
this.stringLength = stringLength
|
||||
this.textOperations = textOperations || []
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new LazyStringFileData(
|
||||
raw.hash,
|
||||
raw.stringLength,
|
||||
raw.textOperations && _.map(raw.textOperations, TextOperation.fromJSON)
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
const raw = { hash: this.hash, stringLength: this.stringLength }
|
||||
if (this.textOperations.length) {
|
||||
raw.textOperations = _.map(this.textOperations, function (textOperation) {
|
||||
return textOperation.toJSON()
|
||||
})
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getHash() {
|
||||
if (this.textOperations.length) return null
|
||||
return this.hash
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* For project quota checking, we approximate the byte length by the UTF-8
|
||||
* length for hollow files. This isn't strictly speaking correct; it is an
|
||||
* underestimate of byte length.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
getByteLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.stringLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached text operations that are to be applied to this file to get
|
||||
* from the content with its last known hash to its latest content.
|
||||
*
|
||||
* @return {Array.<TextOperation>}
|
||||
*/
|
||||
getTextOperations() {
|
||||
return this.textOperations
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toEager(blobStore) {
|
||||
return blobStore.getString(this.hash).then(content => {
|
||||
return new EagerStringFileData(
|
||||
computeContent(this.textOperations, content)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toLazy() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow() {
|
||||
return BPromise.try(() => FileData.createHollow(null, this.stringLength))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
edit(textOperation) {
|
||||
this.stringLength = textOperation.applyToLength(this.stringLength)
|
||||
this.textOperations.push(textOperation)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
store(blobStore) {
|
||||
if (this.textOperations.length === 0)
|
||||
return BPromise.resolve({ hash: this.hash })
|
||||
return blobStore
|
||||
.getString(this.hash)
|
||||
.then(content => {
|
||||
return blobStore.putString(computeContent(this.textOperations, content))
|
||||
})
|
||||
.then(blob => {
|
||||
this.hash = blob.getHash()
|
||||
this.stringLength = blob.getStringLength()
|
||||
this.textOperations.length = 0
|
||||
return { hash: this.hash }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function computeContent(textOperations, initialFile) {
|
||||
function applyTextOperation(content, textOperation) {
|
||||
return textOperation.apply(content)
|
||||
}
|
||||
return _.reduce(textOperations, applyTextOperation, initialFile)
|
||||
}
|
||||
|
||||
module.exports = LazyStringFileData
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const FileData = require('./')
|
||||
|
||||
/**
|
||||
* @typedef {import("../types").StringFileRawData} StringFileRawData
|
||||
*/
|
||||
|
||||
class StringFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} content
|
||||
*/
|
||||
constructor(content) {
|
||||
super()
|
||||
assert.string(content)
|
||||
this.content = content
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new StringFileData(raw.content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {StringFileRawData}
|
||||
*/
|
||||
toRaw() {
|
||||
return { content: this.content }
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
isEditable() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getContent() {
|
||||
return this.content
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return Buffer.byteLength(this.content)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getStringLength() {
|
||||
return this.content.length
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
edit(textOperation) {
|
||||
this.content = textOperation.apply(this.content)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toEager() {
|
||||
return BPromise.resolve(this)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toHollow() {
|
||||
return BPromise.try(() =>
|
||||
FileData.createHollow(this.getByteLength(), this.getStringLength())
|
||||
)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
store(blobStore) {
|
||||
return blobStore.putString(this.content).then(function (blob) {
|
||||
return { hash: blob.getHash() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StringFileData
|
||||
@@ -0,0 +1,317 @@
|
||||
'use strict'
|
||||
|
||||
const BPromise = require('bluebird')
|
||||
const _ = require('lodash')
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
const File = require('./file')
|
||||
const safePathname = require('./safe_pathname')
|
||||
|
||||
/**
|
||||
* A set of {@link File}s. Several properties are enforced on the pathnames:
|
||||
*
|
||||
* 1. File names and paths are case sensitive and can differ by case alone. This
|
||||
* is consistent with most Linux file systems, but it is not consistent with
|
||||
* Windows or OS X. Ideally, we would be case-preserving and case insensitive,
|
||||
* like they are. And we used to be, but it caused too many incompatibilities
|
||||
* with the old system, which was case sensitive. See
|
||||
* https://github.com/overleaf/overleaf-ot-prototype/blob/
|
||||
* 19ed046c09f5a4d14fa12b3ea813ce0d977af88a/editor/core/lib/file_map.js
|
||||
* for an implementation of this map with those properties.
|
||||
*
|
||||
* 2. Uniqueness: No two pathnames are the same.
|
||||
*
|
||||
* 3. No type conflicts: A pathname cannot refer to both a file and a directory
|
||||
* within the same snapshot. That is, you can't have pathnames `a` and `a/b` in
|
||||
* the same file map; {@see FileMap#wouldConflict}.
|
||||
*
|
||||
* @param {Object.<String, File>} files
|
||||
*/
|
||||
class FileMap {
|
||||
constructor(files) {
|
||||
// create bare object for use as Map
|
||||
// http://ryanmorr.com/true-hash-maps-in-javascript/
|
||||
this.files = Object.create(null)
|
||||
_.assign(this.files, files)
|
||||
checkPathnamesAreUnique(this.files)
|
||||
checkPathnamesDoNotConflict(this)
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
assert.object(raw, 'bad raw files')
|
||||
return new FileMap(_.mapValues(raw, File.fromRaw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to raw object for serialization.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
toRaw() {
|
||||
function fileToRaw(file) {
|
||||
return file.toRaw()
|
||||
}
|
||||
return _.mapValues(this.files, fileToRaw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the given file.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {File} file
|
||||
*/
|
||||
addFile(pathname, file) {
|
||||
checkPathname(pathname)
|
||||
assert.object(file, 'bad file')
|
||||
checkNewPathnameDoesNotConflict(this, pathname)
|
||||
addFile(this.files, pathname, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given file.
|
||||
*
|
||||
* @param {string} pathname
|
||||
*/
|
||||
removeFile(pathname) {
|
||||
checkPathname(pathname)
|
||||
|
||||
const key = findPathnameKey(this.files, pathname)
|
||||
if (!key) {
|
||||
throw new FileMap.FileNotFoundError(pathname)
|
||||
}
|
||||
delete this.files[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Move or remove a file. If the origin file does not exist, or if the old
|
||||
* and new paths are identical, this has no effect.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {string} newPathname if a blank string, {@link FileMap#removeFile}
|
||||
*/
|
||||
moveFile(pathname, newPathname) {
|
||||
if (pathname === newPathname) return
|
||||
if (newPathname === '') return this.removeFile(pathname)
|
||||
checkPathname(pathname)
|
||||
checkPathname(newPathname)
|
||||
checkNewPathnameDoesNotConflict(this, newPathname, pathname)
|
||||
|
||||
const key = findPathnameKey(this.files, pathname)
|
||||
if (!key) {
|
||||
throw new FileMap.FileNotFoundError(pathname)
|
||||
}
|
||||
const file = this.files[key]
|
||||
delete this.files[key]
|
||||
|
||||
addFile(this.files, newPathname, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of files in the file map.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
countFiles() {
|
||||
return _.size(this.files)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file by its pathname.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @return {File | null | undefined}
|
||||
*/
|
||||
getFile(pathname) {
|
||||
const key = findPathnameKey(this.files, pathname)
|
||||
return key && this.files[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given pathname conflicts with any file in the map.
|
||||
*
|
||||
* Paths conflict in type if one path is a strict prefix of the other path. For
|
||||
* example, 'a/b' conflicts with 'a', because in the former case 'a' is a
|
||||
* folder, but in the latter case it is a file. Similarly, the pathname 'a/b/c'
|
||||
* conflicts with 'a' and 'a/b', but it does not conflict with 'a/b/c', 'a/x',
|
||||
* or 'a/b/x'. (In our case, identical paths don't conflict, because AddFile
|
||||
* and MoveFile overwrite existing files.)
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {string} [ignoredPathname] pretend this pathname does not exist
|
||||
*/
|
||||
wouldConflict(pathname, ignoredPathname) {
|
||||
checkPathname(pathname)
|
||||
assert.maybe.string(ignoredPathname)
|
||||
const pathnames = this.getPathnames()
|
||||
const dirname = pathname + '/'
|
||||
// Check the filemap to see whether the supplied pathname is a
|
||||
// parent of any entry, or any entry is a parent of the pathname.
|
||||
for (let i = 0; i < pathnames.length; i++) {
|
||||
// First check if pathname is a strict prefix of pathnames[i] (and that
|
||||
// pathnames[i] is not ignored)
|
||||
if (
|
||||
pathnames[i].startsWith(dirname) &&
|
||||
!pathnamesEqual(pathnames[i], ignoredPathname)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Now make the reverse check, whether pathnames[i] is a strict prefix of
|
||||
// pathname. To avoid expensive string concatenation on each pathname we
|
||||
// first perform a partial check with a.startsWith(b), and then do the
|
||||
// full check for a subsequent '/' if this passes. This saves about 25%
|
||||
// of the runtime. Again only return a conflict if pathnames[i] is not
|
||||
// ignored.
|
||||
if (
|
||||
pathname.startsWith(pathnames[i]) &&
|
||||
pathname.length > pathnames[i].length &&
|
||||
pathname[pathnames[i].length] === '/' &&
|
||||
!pathnamesEqual(pathnames[i], ignoredPathname)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// No conflicts - after excluding ignoredPathname, there were no entries
|
||||
// which were a strict prefix of pathname, and pathname was not a strict
|
||||
// prefix of any entry.
|
||||
return false
|
||||
}
|
||||
|
||||
/** @see Snapshot#getFilePathnames */
|
||||
getPathnames() {
|
||||
return _.keys(this.files)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the files in this map to new values.
|
||||
* @param {function} iteratee
|
||||
* @return {Object}
|
||||
*/
|
||||
map(iteratee) {
|
||||
return _.mapValues(this.files, iteratee)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the files in this map to new values asynchronously, with an optional
|
||||
* limit on concurrency.
|
||||
* @param {function} iteratee like for _.mapValues
|
||||
* @param {number} [concurrency] as for BPromise.map
|
||||
* @return {Object}
|
||||
*/
|
||||
mapAsync(iteratee, concurrency) {
|
||||
assert.maybe.number(concurrency, 'bad concurrency')
|
||||
|
||||
const pathnames = this.getPathnames()
|
||||
return BPromise.map(
|
||||
pathnames,
|
||||
file => {
|
||||
return iteratee(this.getFile(file), file, pathnames)
|
||||
},
|
||||
{ concurrency: concurrency || 1 }
|
||||
).then(files => {
|
||||
return _.zipObject(pathnames, files)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class PathnameError extends OError {}
|
||||
FileMap.PathnameError = PathnameError
|
||||
|
||||
class NonUniquePathnameError extends PathnameError {
|
||||
constructor(pathnames) {
|
||||
super('pathnames are not unique: ' + pathnames, { pathnames })
|
||||
this.pathnames = pathnames
|
||||
}
|
||||
}
|
||||
FileMap.NonUniquePathnameError = NonUniquePathnameError
|
||||
|
||||
class BadPathnameError extends PathnameError {
|
||||
constructor(pathname) {
|
||||
super(pathname + ' is not a valid pathname', { pathname })
|
||||
this.pathname = pathname
|
||||
}
|
||||
}
|
||||
FileMap.BadPathnameError = BadPathnameError
|
||||
|
||||
class PathnameConflictError extends PathnameError {
|
||||
constructor(pathname) {
|
||||
super(`pathname '${pathname}' conflicts with another file`, { pathname })
|
||||
this.pathname = pathname
|
||||
}
|
||||
}
|
||||
FileMap.PathnameConflictError = PathnameConflictError
|
||||
|
||||
class FileNotFoundError extends PathnameError {
|
||||
constructor(pathname) {
|
||||
super(`file ${pathname} does not exist`, { pathname })
|
||||
this.pathname = pathname
|
||||
}
|
||||
}
|
||||
FileMap.FileNotFoundError = FileNotFoundError
|
||||
|
||||
function pathnamesEqual(pathname0, pathname1) {
|
||||
return pathname0 === pathname1
|
||||
}
|
||||
|
||||
function pathnamesAreUnique(files) {
|
||||
const keys = _.keys(files)
|
||||
return _.uniqWith(keys, pathnamesEqual).length === keys.length
|
||||
}
|
||||
|
||||
function checkPathnamesAreUnique(files) {
|
||||
if (pathnamesAreUnique(files)) return
|
||||
throw new FileMap.NonUniquePathnameError(_.keys(files))
|
||||
}
|
||||
|
||||
function checkPathname(pathname) {
|
||||
assert.nonEmptyString(pathname, 'bad pathname')
|
||||
if (safePathname.isClean(pathname)) return
|
||||
throw new FileMap.BadPathnameError(pathname)
|
||||
}
|
||||
|
||||
function checkNewPathnameDoesNotConflict(fileMap, pathname, ignoredPathname) {
|
||||
if (fileMap.wouldConflict(pathname, ignoredPathname)) {
|
||||
throw new FileMap.PathnameConflictError(pathname)
|
||||
}
|
||||
}
|
||||
|
||||
function checkPathnamesDoNotConflict(fileMap) {
|
||||
const pathnames = fileMap.getPathnames()
|
||||
// check pathnames for validity first
|
||||
pathnames.forEach(checkPathname)
|
||||
// convert pathnames to candidate directory names
|
||||
const dirnames = []
|
||||
for (let i = 0; i < pathnames.length; i++) {
|
||||
dirnames[i] = pathnames[i] + '/'
|
||||
}
|
||||
// sort in lexical order and check if one directory contains another
|
||||
dirnames.sort()
|
||||
for (let i = 0; i < dirnames.length - 1; i++) {
|
||||
if (dirnames[i + 1].startsWith(dirnames[i])) {
|
||||
// strip trailing slash to get original pathname
|
||||
const conflictPathname = dirnames[i + 1].substr(0, -1)
|
||||
throw new FileMap.PathnameConflictError(conflictPathname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// This function is somewhat vestigial: it was used when this map used
|
||||
// case-insensitive pathname comparison. We could probably simplify some of the
|
||||
// logic in the callers, but in the hope that we will one day return to
|
||||
// case-insensitive semantics, we've just left things as-is for now.
|
||||
//
|
||||
function findPathnameKey(files, pathname) {
|
||||
// we can check for the key without worrying about properties
|
||||
// in the prototype because we are now using a bare object/
|
||||
if (pathname in files) return pathname
|
||||
}
|
||||
|
||||
function addFile(files, pathname, file) {
|
||||
const key = findPathnameKey(files, pathname)
|
||||
if (key) delete files[key]
|
||||
files[pathname] = file
|
||||
}
|
||||
|
||||
module.exports = FileMap
|
||||
@@ -0,0 +1,125 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const Change = require('./change')
|
||||
const Snapshot = require('./snapshot')
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").BlobStore} BlobStore
|
||||
*/
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {Array.<Change>} changes
|
||||
*
|
||||
* @classdesc
|
||||
* A History is a {@link Snapshot} and a sequence of {@link Change}s that can
|
||||
* be applied to produce a new snapshot.
|
||||
*/
|
||||
function History(snapshot, changes) {
|
||||
assert.instance(snapshot, Snapshot, 'bad snapshot')
|
||||
assert.maybe.array.of.instance(changes, Change, 'bad changes')
|
||||
|
||||
this.snapshot = snapshot
|
||||
this.changes = changes || []
|
||||
}
|
||||
|
||||
History.fromRaw = function historyFromRaw(raw) {
|
||||
return new History(
|
||||
Snapshot.fromRaw(raw.snapshot),
|
||||
raw.changes.map(Change.fromRaw)
|
||||
)
|
||||
}
|
||||
|
||||
History.prototype.toRaw = function historyToRaw() {
|
||||
function changeToRaw(change) {
|
||||
return change.toRaw()
|
||||
}
|
||||
return {
|
||||
snapshot: this.snapshot.toRaw(),
|
||||
changes: this.changes.map(changeToRaw),
|
||||
}
|
||||
}
|
||||
|
||||
History.prototype.getSnapshot = function () {
|
||||
return this.snapshot
|
||||
}
|
||||
History.prototype.getChanges = function () {
|
||||
return this.changes
|
||||
}
|
||||
|
||||
History.prototype.countChanges = function historyCountChanges() {
|
||||
return this.changes.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Add changes to this history.
|
||||
*
|
||||
* @param {Array.<Change>} changes
|
||||
*/
|
||||
History.prototype.pushChanges = function historyPushChanges(changes) {
|
||||
this.changes.push.apply(this.changes, changes)
|
||||
}
|
||||
|
||||
/**
|
||||
* If this History references blob hashes, either in the Snapshot or the
|
||||
* Changes, add them to the given set.
|
||||
*
|
||||
* @param {Set.<String>} blobHashes
|
||||
*/
|
||||
History.prototype.findBlobHashes = function historyFindBlobHashes(blobHashes) {
|
||||
function findChangeBlobHashes(change) {
|
||||
change.findBlobHashes(blobHashes)
|
||||
}
|
||||
this.snapshot.findBlobHashes(blobHashes)
|
||||
this.changes.forEach(findChangeBlobHashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* If this History contains any File objects, load them.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise}
|
||||
*/
|
||||
History.prototype.loadFiles = function historyLoadFiles(kind, blobStore) {
|
||||
function loadChangeFiles(change) {
|
||||
return change.loadFiles(kind, blobStore)
|
||||
}
|
||||
return BPromise.join(
|
||||
this.snapshot.loadFiles(kind, blobStore),
|
||||
BPromise.each(this.changes, loadChangeFiles)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a version of this history that is suitable for long term storage.
|
||||
* This requires that we store the content of file objects in the provided
|
||||
* blobStore.
|
||||
*
|
||||
* @param {BlobStore} blobStore
|
||||
* @param {number} [concurrency] applies separately to files, changes and
|
||||
* operations
|
||||
* @return {Promise.<Object>}
|
||||
*/
|
||||
History.prototype.store = function historyStoreFunc(blobStore, concurrency) {
|
||||
assert.maybe.number(concurrency, 'bad concurrency')
|
||||
|
||||
function storeChange(change) {
|
||||
return change.store(blobStore, concurrency)
|
||||
}
|
||||
return BPromise.join(
|
||||
this.snapshot.store(blobStore, concurrency),
|
||||
BPromise.map(this.changes, storeChange, { concurrency: concurrency || 1 })
|
||||
).then(([rawSnapshot, rawChanges]) => {
|
||||
return {
|
||||
snapshot: rawSnapshot,
|
||||
changes: rawChanges,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = History
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} text
|
||||
* @classdesc
|
||||
* A user-configurable label that can be attached to a specific change. Labels
|
||||
* are not versioned, and they are not stored alongside the Changes in Chunks.
|
||||
* They are instead intended to provide external markers into the history of the
|
||||
* project.
|
||||
*/
|
||||
function Label(text, authorId, timestamp, version) {
|
||||
assert.string(text, 'bad text')
|
||||
assert.maybe.integer(authorId, 'bad author id')
|
||||
assert.date(timestamp, 'bad timestamp')
|
||||
assert.integer(version, 'bad version')
|
||||
|
||||
this.text = text
|
||||
this.authorId = authorId
|
||||
this.timestamp = timestamp
|
||||
this.version = version
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Label from its raw form.
|
||||
*
|
||||
* @param {Object} raw
|
||||
* @return {Label}
|
||||
*/
|
||||
Label.fromRaw = function labelFromRaw(raw) {
|
||||
return new Label(raw.text, raw.authorId, new Date(raw.timestamp), raw.version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Label to raw form for transmission.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
Label.prototype.toRaw = function labelToRaw() {
|
||||
return {
|
||||
text: this.text,
|
||||
authorId: this.authorId,
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
version: this.version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
Label.prototype.getText = function () {
|
||||
return this.text
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the author, if any. Note that we now require all saved versions to
|
||||
* have an author, but this was not always the case, so we have to allow nulls
|
||||
* here for historical reasons.
|
||||
*
|
||||
* @return {number | null | undefined}
|
||||
*/
|
||||
Label.prototype.getAuthorId = function () {
|
||||
return this.authorId
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Date}
|
||||
*/
|
||||
Label.prototype.getTimestamp = function () {
|
||||
return this.timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number | undefined}
|
||||
*/
|
||||
Label.prototype.getVersion = function () {
|
||||
return this.version
|
||||
}
|
||||
|
||||
module.exports = Label
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const File = require('../file')
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* Adds a new file to a project.
|
||||
*/
|
||||
class AddFileOperation extends Operation {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} pathname
|
||||
* @param {File} file
|
||||
*/
|
||||
constructor(pathname, file) {
|
||||
super()
|
||||
assert.string(pathname, 'bad pathname')
|
||||
assert.object(file, 'bad file')
|
||||
|
||||
this.pathname = pathname
|
||||
this.file = file
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {String}
|
||||
*/
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* @param {Object} raw
|
||||
* @return {AddFileOperation}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new AddFileOperation(raw.pathname, File.fromRaw(raw.file))
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return { pathname: this.pathname, file: this.file.toRaw() }
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
getFile() {
|
||||
return this.file
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
findBlobHashes(blobHashes) {
|
||||
const hash = this.file.getHash()
|
||||
if (hash) blobHashes.add(hash)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
loadFiles(kind, blobStore) {
|
||||
return this.file.load(kind, blobStore)
|
||||
}
|
||||
|
||||
store(blobStore) {
|
||||
return this.file.store(blobStore).then(rawFile => {
|
||||
return { pathname: this.pathname, file: rawFile }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
snapshot.addFile(this.pathname, this.file.clone())
|
||||
}
|
||||
}
|
||||
module.exports = AddFileOperation
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict'
|
||||
|
||||
const Operation = require('./')
|
||||
const TextOperation = require('./text_operation')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* Edit a file in place. It is a wrapper around a single TextOperation.
|
||||
*/
|
||||
class EditFileOperation extends Operation {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} pathname
|
||||
* @param {TextOperation} textOperation
|
||||
*/
|
||||
constructor(pathname, textOperation) {
|
||||
super()
|
||||
this.pathname = pathname
|
||||
this.textOperation = textOperation
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
textOperation: this.textOperation.toJSON(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize an EditFileOperation.
|
||||
*
|
||||
* @param {Object} raw
|
||||
* @return {EditFileOperation}
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
return new EditFileOperation(
|
||||
raw.pathname,
|
||||
TextOperation.fromJSON(raw.textOperation)
|
||||
)
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getTextOperation() {
|
||||
return this.textOperation
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
snapshot.editFile(this.pathname, this.textOperation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
return (
|
||||
this.canBeComposedWith(other) &&
|
||||
this.textOperation.canBeComposedWithForUndo(other.textOperation)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
// Ensure that other operation is an edit file operation
|
||||
if (!(other instanceof EditFileOperation)) return false
|
||||
// Ensure that both operations are editing the same file
|
||||
if (this.getPathname() !== other.getPathname()) return false
|
||||
|
||||
return this.textOperation.canBeComposedWith(other.textOperation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
compose(other) {
|
||||
return new EditFileOperation(
|
||||
this.pathname,
|
||||
this.textOperation.compose(other.textOperation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EditFileOperation
|
||||
@@ -0,0 +1,463 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const TextOperation = require('./text_operation')
|
||||
|
||||
// Dependencies are loaded at the bottom of the file to mitigate circular
|
||||
// dependency
|
||||
let NoOperation = null
|
||||
let AddFileOperation = null
|
||||
let MoveFileOperation = null
|
||||
let EditFileOperation = null
|
||||
let SetFileMetadataOperation = null
|
||||
|
||||
/**
|
||||
* @typedef {import("../types").BlobStore} BlobStore
|
||||
* @typedef {import("../snapshot")} Snapshot
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* An `Operation` changes a `Snapshot` when it is applied. See the
|
||||
* {@tutorial OT} tutorial for background.
|
||||
*/
|
||||
class Operation {
|
||||
/**
|
||||
* Deserialize an Operation.
|
||||
*
|
||||
* @param {Object} raw
|
||||
* @return {Operation} one of the subclasses
|
||||
*/
|
||||
static fromRaw(raw) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'file')) {
|
||||
return AddFileOperation.fromRaw(raw)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'textOperation')) {
|
||||
return EditFileOperation.fromRaw(raw)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'newPathname')) {
|
||||
return new MoveFileOperation(raw.pathname, raw.newPathname)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'metadata')) {
|
||||
return new SetFileMetadataOperation(raw.pathname, raw.metadata)
|
||||
}
|
||||
if (_.isEmpty(raw)) {
|
||||
return new NoOperation()
|
||||
}
|
||||
throw new Error('invalid raw operation ' + JSON.stringify(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an Operation.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
toRaw() {
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation does nothing when applied.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isNoOp() {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* If this Operation references blob hashes, add them to the given Set.
|
||||
*
|
||||
* @param {Set.<String>} blobHashes
|
||||
*/
|
||||
findBlobHashes(blobHashes) {}
|
||||
|
||||
/**
|
||||
* If this operation references any files, load the files.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadFiles(kind, blobStore) {
|
||||
return BPromise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a version of this operation that is suitable for long term storage.
|
||||
* In most cases, we just need to convert the operation to raw form, but if
|
||||
* the operation involves File objects, we may need to store their content.
|
||||
*
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise.<Object>}
|
||||
*/
|
||||
store(blobStore) {
|
||||
return BPromise.try(() => this.toRaw())
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply this Operation to a snapshot.
|
||||
*
|
||||
* The snapshot is modified in place.
|
||||
*
|
||||
* @param {Snapshot} snapshot
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
assert.object(snapshot, 'bad snapshot')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation can be composed with another operation to produce a
|
||||
* single operation of the same type as this one, while keeping the composed
|
||||
* operation small and logical enough to be used in the undo stack.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Boolean}
|
||||
*/
|
||||
canBeComposedWithForUndo(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation can be composed with another operation to produce a
|
||||
* single operation of the same type as this one.
|
||||
*
|
||||
* TODO Moves can be composed. For example, if you rename a to b and then decide
|
||||
* shortly after that actually you want to call it c, we could compose the two
|
||||
* to get a -> c). Edits can also be composed --- see rules in TextOperation.
|
||||
* We also need to consider the Change --- we will need to consider both time
|
||||
* and author(s) when composing changes. I guess that AddFile can also be
|
||||
* composed in some cases --- if you upload a file and then decide it was the
|
||||
* wrong one and upload a new one, we could drop the one in the middle, but
|
||||
* that seems like a pretty rare case.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Boolean}
|
||||
*/
|
||||
canBeComposedWith(other) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose this operation with another operation to produce a single operation
|
||||
* of the same type as this one.
|
||||
*
|
||||
* @param {Operation} other
|
||||
* @return {Operation}
|
||||
*/
|
||||
compose(other) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform takes two operations A and B that happened concurrently and
|
||||
* produces two operations A' and B' (in an array) such that
|
||||
* `apply(apply(S, A), B') = apply(apply(S, B), A')`.
|
||||
*
|
||||
* That is, if one client applies A and then B', they get the same result as
|
||||
* another client who applies B and then A'.
|
||||
*
|
||||
* @param {Operation} a
|
||||
* @param {Operation} b
|
||||
* @return {Operation[]} operations `[a', b']`
|
||||
*/
|
||||
static transform(a, b) {
|
||||
if (a.isNoOp() || b.isNoOp()) return [b, a]
|
||||
|
||||
function transpose(transformer) {
|
||||
return transformer(b, a).reverse()
|
||||
}
|
||||
|
||||
const bIsAddFile = b instanceof AddFileOperation
|
||||
const bIsEditFile = b instanceof EditFileOperation
|
||||
const bIsMoveFile = b instanceof MoveFileOperation
|
||||
const bIsSetFileMetadata = b instanceof SetFileMetadataOperation
|
||||
|
||||
if (a instanceof AddFileOperation) {
|
||||
if (bIsAddFile) return transformAddFileAddFile(a, b)
|
||||
if (bIsMoveFile) return transformAddFileMoveFile(a, b)
|
||||
if (bIsEditFile) return transformAddFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof MoveFileOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileMoveFile)
|
||||
if (bIsMoveFile) return transformMoveFileMoveFile(a, b)
|
||||
if (bIsEditFile) return transformMoveFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof EditFileOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileEditFile)
|
||||
if (bIsMoveFile) return transpose(transformMoveFileEditFile)
|
||||
if (bIsEditFile) return transformEditFileEditFile(a, b)
|
||||
if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
if (a instanceof SetFileMetadataOperation) {
|
||||
if (bIsAddFile) return transpose(transformAddFileSetFileMetadata)
|
||||
if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata)
|
||||
if (bIsEditFile) return transpose(transformEditFileSetFileMetadata)
|
||||
if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b)
|
||||
throw new Error('bad op b')
|
||||
}
|
||||
throw new Error('bad op a')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform each operation in `a` by each operation in `b` and save the primed
|
||||
* operations in place.
|
||||
*
|
||||
* @param {Array.<Operation>} as - modified in place
|
||||
* @param {Array.<Operation>} bs - modified in place
|
||||
*/
|
||||
static transformMultiple(as, bs) {
|
||||
for (let i = 0; i < as.length; ++i) {
|
||||
for (let j = 0; j < bs.length; ++j) {
|
||||
const primes = Operation.transform(as[i], bs[j])
|
||||
as[i] = primes[0]
|
||||
bs[j] = primes[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static addFile(pathname, file) {
|
||||
return new AddFileOperation(pathname, file)
|
||||
}
|
||||
|
||||
static editFile(pathname, textOperation) {
|
||||
return new EditFileOperation(pathname, textOperation)
|
||||
}
|
||||
|
||||
static moveFile(pathname, newPathname) {
|
||||
return new MoveFileOperation(pathname, newPathname)
|
||||
}
|
||||
|
||||
static removeFile(pathname) {
|
||||
return new MoveFileOperation(pathname, '')
|
||||
}
|
||||
|
||||
static setFileMetadata(pathname, metadata) {
|
||||
return new SetFileMetadataOperation(pathname, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Transform
|
||||
//
|
||||
// The way to read these transform functions is that
|
||||
// 1. return_value[0] is the op to be applied after arguments[1], and
|
||||
// 2. return_value[1] is the op to be applied after arguments[0],
|
||||
// in order to arrive at the same project state.
|
||||
//
|
||||
|
||||
function transformAddFileAddFile(add1, add2) {
|
||||
if (add1.getPathname() === add2.getPathname()) {
|
||||
return [Operation.NO_OP, add2] // add2 wins
|
||||
}
|
||||
|
||||
return [add1, add2]
|
||||
}
|
||||
|
||||
function transformAddFileMoveFile(add, move) {
|
||||
function relocateAddFile() {
|
||||
return new AddFileOperation(move.getNewPathname(), add.getFile().clone())
|
||||
}
|
||||
|
||||
if (add.getPathname() === move.getPathname()) {
|
||||
if (move.isRemoveFile()) {
|
||||
return [add, Operation.NO_OP]
|
||||
}
|
||||
return [
|
||||
relocateAddFile(),
|
||||
new MoveFileOperation(add.getPathname(), move.getNewPathname()),
|
||||
]
|
||||
}
|
||||
|
||||
if (add.getPathname() === move.getNewPathname()) {
|
||||
return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')]
|
||||
}
|
||||
|
||||
return [add, move]
|
||||
}
|
||||
|
||||
function transformAddFileEditFile(add, edit) {
|
||||
if (add.getPathname() === edit.getPathname()) {
|
||||
return [add, Operation.NO_OP] // the add wins
|
||||
}
|
||||
|
||||
return [add, edit]
|
||||
}
|
||||
|
||||
function transformAddFileSetFileMetadata(add, set) {
|
||||
if (add.getPathname() === set.getPathname()) {
|
||||
const newFile = add.getFile().clone()
|
||||
newFile.setMetadata(set.getMetadata())
|
||||
return [new AddFileOperation(add.getPathname(), newFile), set]
|
||||
}
|
||||
|
||||
return [add, set]
|
||||
}
|
||||
|
||||
//
|
||||
// This is one of the trickier ones. There are 15 possible equivalence
|
||||
// relationships between our four variables:
|
||||
//
|
||||
// path1, newPath1, path2, newPath2 --- "same move" (all equal)
|
||||
//
|
||||
// path1, newPath1, path2 | newPath2 --- "no-ops" (1)
|
||||
// path1, newPath1, newPath2 | path2 --- "no-ops" (1)
|
||||
// path1, path2, newPath2 | newPath1 --- "no-ops" (2)
|
||||
// newPath1, path2, newPath2 | path1 --- "no-ops" (2)
|
||||
//
|
||||
// path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2)
|
||||
// path1, path2 | newPath1, newPath2 --- "same move"
|
||||
// path1, newPath2 | newPath1, path2 --- "opposite moves"
|
||||
//
|
||||
// path1, newPath1 | path2 | newPath2 --- "no-ops" (1)
|
||||
// path1, path2 | newPath1 | newPath2 --- "divergent moves"
|
||||
// path1, newPath2 | newPath1 | path2 --- "transitive move"
|
||||
// newPath1, path2 | path1 | newPath2 --- "transitive move"
|
||||
// newPath1, newPath2 | path1 | path2 --- "convergent move"
|
||||
// path2, newPath2 | path1 | newPath1 --- "no-ops" (2)
|
||||
//
|
||||
// path1 | newPath1 | path2 | newPath2 --- "no conflict"
|
||||
//
|
||||
function transformMoveFileMoveFile(move1, move2) {
|
||||
const path1 = move1.getPathname()
|
||||
const path2 = move2.getPathname()
|
||||
const newPath1 = move1.getNewPathname()
|
||||
const newPath2 = move2.getNewPathname()
|
||||
|
||||
// the same move
|
||||
if (path1 === path2 && newPath1 === newPath2) {
|
||||
return [Operation.NO_OP, Operation.NO_OP]
|
||||
}
|
||||
|
||||
// no-ops
|
||||
if (path1 === newPath1 && path2 === newPath2) {
|
||||
return [Operation.NO_OP, Operation.NO_OP]
|
||||
}
|
||||
if (path1 === newPath1) {
|
||||
return [Operation.NO_OP, move2]
|
||||
}
|
||||
if (path2 === newPath2) {
|
||||
return [move1, Operation.NO_OP]
|
||||
}
|
||||
|
||||
// opposite moves (foo -> bar, bar -> foo)
|
||||
if (path1 === newPath2 && path2 === newPath1) {
|
||||
// We can't handle this very well: if we wanted move2 (say) to win, move2'
|
||||
// would have to be addFile(foo) with the content of bar, but we don't have
|
||||
// the content of bar available here. So, we just destroy both files.
|
||||
return [Operation.removeFile(path1), Operation.removeFile(path2)]
|
||||
}
|
||||
|
||||
// divergent moves (foo -> bar, foo -> baz); convention: move2 wins
|
||||
if (path1 === path2 && newPath1 !== newPath2) {
|
||||
return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)]
|
||||
}
|
||||
|
||||
// convergent move (foo -> baz, bar -> baz); convention: move2 wins
|
||||
if (newPath1 === newPath2 && path1 !== path2) {
|
||||
return [Operation.removeFile(path1), move2]
|
||||
}
|
||||
|
||||
// transitive move:
|
||||
// 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or
|
||||
// 1: foo -> bar, 2: bar -> baz (result: foo -> baz)
|
||||
if (path1 === newPath2 && newPath1 !== path2) {
|
||||
return [
|
||||
Operation.moveFile(newPath2, newPath1),
|
||||
Operation.moveFile(path2, newPath1),
|
||||
]
|
||||
}
|
||||
if (newPath1 === path2 && path1 !== newPath2) {
|
||||
return [
|
||||
Operation.moveFile(path1, newPath2),
|
||||
Operation.moveFile(newPath1, newPath2),
|
||||
]
|
||||
}
|
||||
|
||||
// no conflict
|
||||
return [move1, move2]
|
||||
}
|
||||
|
||||
function transformMoveFileEditFile(move, edit) {
|
||||
if (move.getPathname() === edit.getPathname()) {
|
||||
if (move.isRemoveFile()) {
|
||||
// let the remove win
|
||||
return [move, Operation.NO_OP]
|
||||
}
|
||||
return [
|
||||
move,
|
||||
Operation.editFile(move.getNewPathname(), edit.getTextOperation()),
|
||||
]
|
||||
}
|
||||
|
||||
if (move.getNewPathname() === edit.getPathname()) {
|
||||
// let the move win
|
||||
return [move, Operation.NO_OP]
|
||||
}
|
||||
|
||||
return [move, edit]
|
||||
}
|
||||
|
||||
function transformMoveFileSetFileMetadata(move, set) {
|
||||
if (move.getPathname() === set.getPathname()) {
|
||||
return [
|
||||
move,
|
||||
Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()),
|
||||
]
|
||||
}
|
||||
// A: mv foo -> bar
|
||||
// B: set bar.x
|
||||
//
|
||||
// A': mv foo -> bar
|
||||
// B': nothing
|
||||
if (move.getNewPathname() === set.getPathname()) {
|
||||
return [move, Operation.NO_OP] // let the move win
|
||||
}
|
||||
return [move, set]
|
||||
}
|
||||
|
||||
function transformEditFileEditFile(edit1, edit2) {
|
||||
if (edit1.getPathname() === edit2.getPathname()) {
|
||||
const primeTextOps = TextOperation.transform(
|
||||
edit1.getTextOperation(),
|
||||
edit2.getTextOperation()
|
||||
)
|
||||
return [
|
||||
Operation.editFile(edit1.getPathname(), primeTextOps[0]),
|
||||
Operation.editFile(edit2.getPathname(), primeTextOps[1]),
|
||||
]
|
||||
}
|
||||
|
||||
return [edit1, edit2]
|
||||
}
|
||||
|
||||
function transformEditFileSetFileMetadata(edit, set) {
|
||||
// There is no conflict.
|
||||
return [edit, set]
|
||||
}
|
||||
|
||||
function transformSetFileMetadatas(set1, set2) {
|
||||
if (set1.getPathname() === set2.getPathname()) {
|
||||
return [Operation.NO_OP, set2] // set2 wins
|
||||
}
|
||||
return [set1, set2]
|
||||
}
|
||||
|
||||
module.exports = Operation
|
||||
|
||||
// Work around circular import
|
||||
NoOperation = require('./no_operation')
|
||||
AddFileOperation = require('./add_file_operation')
|
||||
MoveFileOperation = require('./move_file_operation')
|
||||
EditFileOperation = require('./edit_file_operation')
|
||||
SetFileMetadataOperation = require('./set_file_metadata_operation')
|
||||
|
||||
Operation.NO_OP = new NoOperation()
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* Moves or removes a file from a project.
|
||||
*/
|
||||
class MoveFileOperation extends Operation {
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {string} newPathname
|
||||
*/
|
||||
constructor(pathname, newPathname) {
|
||||
super()
|
||||
this.pathname = pathname
|
||||
this.newPathname = newPathname
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
newPathname: this.newPathname,
|
||||
}
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getNewPathname() {
|
||||
return this.newPathname
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this operation is a MoveFile operation that deletes the file.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isRemoveFile() {
|
||||
return this.getNewPathname() === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
snapshot.moveFile(this.getPathname(), this.getNewPathname())
|
||||
}
|
||||
}
|
||||
module.exports = MoveFileOperation
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict'
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* An explicit no-operation.
|
||||
*
|
||||
* There are several no-ops, such as moving a file to itself, but it's useful
|
||||
* to have a generic no-op as well.
|
||||
*/
|
||||
class NoOperation extends Operation {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
isNoOp() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NoOperation
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Operation = require('./')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* Moves or removes a file from a project.
|
||||
*/
|
||||
class SetFileMetadataOperation extends Operation {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} pathname
|
||||
* @param {Object} metadata
|
||||
*/
|
||||
constructor(pathname, metadata) {
|
||||
super()
|
||||
assert.string(pathname, 'SetFileMetadataOperation: bad pathname')
|
||||
assert.object(metadata, 'SetFileMetadataOperation: bad metadata')
|
||||
|
||||
this.pathname = pathname
|
||||
this.metadata = metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
toRaw() {
|
||||
return {
|
||||
pathname: this.pathname,
|
||||
metadata: _.cloneDeep(this.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
return this.pathname
|
||||
}
|
||||
|
||||
getMetadata() {
|
||||
return this.metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
applyTo(snapshot) {
|
||||
const file = snapshot.getFile(this.pathname)
|
||||
if (!file) return
|
||||
file.setMetadata(this.metadata)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SetFileMetadataOperation
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* The text operation from OT.js with some minor cosmetic changes.
|
||||
*
|
||||
* Specifically, this is based on
|
||||
* https://github.com/Operational-Transformation/ot.js/
|
||||
* blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
|
||||
* from 18 Mar 2013.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const containsNonBmpChars = require('../util').containsNonBmpChars
|
||||
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
/**
|
||||
* Create an empty text operation.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
function TextOperation() {
|
||||
// When an operation is applied to an input string, you can think of this as
|
||||
// if an imaginary cursor runs over the entire string and skips over some
|
||||
// parts, removes some parts and inserts characters at some positions. These
|
||||
// actions (skip/remove/insert) are stored as an array in the "ops" property.
|
||||
this.ops = []
|
||||
// An operation's baseLength is the length of every string the operation
|
||||
// can be applied to.
|
||||
this.baseLength = 0
|
||||
// The targetLength is the length of every string that results from applying
|
||||
// the operation on a valid input string.
|
||||
this.targetLength = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the longest file that we'll attempt to edit, in characters.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
TextOperation.MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
|
||||
|
||||
TextOperation.prototype.equals = function (other) {
|
||||
if (this.baseLength !== other.baseLength) {
|
||||
return false
|
||||
}
|
||||
if (this.targetLength !== other.targetLength) {
|
||||
return false
|
||||
}
|
||||
if (this.ops.length !== other.ops.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < this.ops.length; i++) {
|
||||
if (this.ops[i] !== other.ops[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class UnprocessableError extends OError {}
|
||||
TextOperation.UnprocessableError = UnprocessableError
|
||||
|
||||
class ApplyError extends UnprocessableError {
|
||||
constructor(message, operation, operand) {
|
||||
super(message, { operation, operand })
|
||||
this.operation = operation
|
||||
this.operand = operand
|
||||
}
|
||||
}
|
||||
TextOperation.ApplyError = ApplyError
|
||||
|
||||
class InvalidInsertionError extends UnprocessableError {
|
||||
constructor(str, operation) {
|
||||
super('inserted text contains non BMP characters', { str, operation })
|
||||
this.str = str
|
||||
this.operation = operation
|
||||
}
|
||||
}
|
||||
TextOperation.InvalidInsertionError = InvalidInsertionError
|
||||
|
||||
class TooLongError extends UnprocessableError {
|
||||
constructor(operation, resultLength) {
|
||||
super(`resulting string would be too long: ${resultLength}`, {
|
||||
operation,
|
||||
resultLength,
|
||||
})
|
||||
this.operation = operation
|
||||
this.resultLength = resultLength
|
||||
}
|
||||
}
|
||||
TextOperation.TooLongError = TooLongError
|
||||
|
||||
// Operation are essentially lists of ops. There are three types of ops:
|
||||
//
|
||||
// * Retain ops: Advance the cursor position by a given number of characters.
|
||||
// Represented by positive ints.
|
||||
// * Insert ops: Insert a given string at the current cursor position.
|
||||
// Represented by strings.
|
||||
// * Remove ops: Remove the next n characters. Represented by negative ints.
|
||||
|
||||
const isRetain = (TextOperation.isRetain = function (op) {
|
||||
return typeof op === 'number' && op > 0
|
||||
})
|
||||
|
||||
const isInsert = (TextOperation.isInsert = function (op) {
|
||||
return typeof op === 'string'
|
||||
})
|
||||
|
||||
const isRemove = (TextOperation.isRemove = function (op) {
|
||||
return typeof op === 'number' && op < 0
|
||||
})
|
||||
|
||||
// After an operation is constructed, the user of the library can specify the
|
||||
// actions of an operation (skip/insert/remove) with these three builder
|
||||
// methods. They all return the operation for convenient chaining.
|
||||
|
||||
// Skip over a given number of characters.
|
||||
TextOperation.prototype.retain = function (n) {
|
||||
if (typeof n !== 'number') {
|
||||
throw new Error('retain expects an integer')
|
||||
}
|
||||
if (n === 0) {
|
||||
return this
|
||||
}
|
||||
this.baseLength += n
|
||||
this.targetLength += n
|
||||
if (isRetain(this.ops[this.ops.length - 1])) {
|
||||
// The last op is a retain op => we can merge them into one op.
|
||||
this.ops[this.ops.length - 1] += n
|
||||
} else {
|
||||
// Create a new op.
|
||||
this.ops.push(n)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
// Insert a string at the current position.
|
||||
TextOperation.prototype.insert = function (str) {
|
||||
if (typeof str !== 'string') {
|
||||
throw new Error('insert expects a string')
|
||||
}
|
||||
if (containsNonBmpChars(str)) {
|
||||
throw new TextOperation.InvalidInsertionError(str)
|
||||
}
|
||||
if (str === '') {
|
||||
return this
|
||||
}
|
||||
this.targetLength += str.length
|
||||
const ops = this.ops
|
||||
if (isInsert(ops[ops.length - 1])) {
|
||||
// Merge insert op.
|
||||
ops[ops.length - 1] += str
|
||||
} else if (isRemove(ops[ops.length - 1])) {
|
||||
// It doesn't matter when an operation is applied whether the operation
|
||||
// is remove(3), insert("something") or insert("something"), remove(3).
|
||||
// Here we enforce that in this case, the insert op always comes first.
|
||||
// This makes all operations that have the same effect when applied to
|
||||
// a document of the right length equal in respect to the `equals` method.
|
||||
if (isInsert(ops[ops.length - 2])) {
|
||||
ops[ops.length - 2] += str
|
||||
} else {
|
||||
ops[ops.length] = ops[ops.length - 1]
|
||||
ops[ops.length - 2] = str
|
||||
}
|
||||
} else {
|
||||
ops.push(str)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
// Remove a string at the current position.
|
||||
TextOperation.prototype.remove = function (n) {
|
||||
if (typeof n === 'string') {
|
||||
n = n.length
|
||||
}
|
||||
if (typeof n !== 'number') {
|
||||
throw new Error('remove expects an integer or a string')
|
||||
}
|
||||
if (n === 0) {
|
||||
return this
|
||||
}
|
||||
if (n > 0) {
|
||||
n = -n
|
||||
}
|
||||
this.baseLength -= n
|
||||
if (isRemove(this.ops[this.ops.length - 1])) {
|
||||
this.ops[this.ops.length - 1] += n
|
||||
} else {
|
||||
this.ops.push(n)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
// Tests whether this operation has no effect.
|
||||
TextOperation.prototype.isNoop = function () {
|
||||
return (
|
||||
this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0]))
|
||||
)
|
||||
}
|
||||
|
||||
// Pretty printing.
|
||||
TextOperation.prototype.toString = function () {
|
||||
return this.ops
|
||||
.map(op => {
|
||||
if (isRetain(op)) {
|
||||
return 'retain ' + op
|
||||
} else if (isInsert(op)) {
|
||||
return "insert '" + op + "'"
|
||||
} else {
|
||||
return 'remove ' + -op
|
||||
}
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
// Converts operation into a JSON value.
|
||||
TextOperation.prototype.toJSON = function () {
|
||||
return this.ops
|
||||
}
|
||||
|
||||
// Converts a plain JS object into an operation and validates it.
|
||||
TextOperation.fromJSON = function (ops) {
|
||||
const o = new TextOperation()
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
o.retain(op)
|
||||
} else if (isInsert(op)) {
|
||||
o.insert(op)
|
||||
} else if (isRemove(op)) {
|
||||
o.remove(op)
|
||||
} else {
|
||||
throw new Error(
|
||||
'unknown operation: ' +
|
||||
JSON.stringify(op) +
|
||||
' in ' +
|
||||
JSON.stringify(ops)
|
||||
)
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Apply an operation to a string, returning a new string. Throws an error if
|
||||
// there's a mismatch between the input string and the operation.
|
||||
TextOperation.prototype.apply = function (str) {
|
||||
const operation = this
|
||||
if (containsNonBmpChars(str)) {
|
||||
throw new TextOperation.ApplyError(
|
||||
'The string contains non BMP characters.',
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
if (str.length !== operation.baseLength) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation's base length must be equal to the string's length.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
|
||||
// Build up the result string directly by concatenation (which is actually
|
||||
// faster than joining arrays because it is optimised in v8).
|
||||
let result = ''
|
||||
let strIndex = 0
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
if (strIndex + op > str.length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
// Copy skipped part of the old string.
|
||||
result += str.slice(strIndex, strIndex + op)
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
if (containsNonBmpChars(op)) {
|
||||
throw new TextOperation.InvalidInsertionError(str, operation)
|
||||
}
|
||||
// Insert string.
|
||||
result += op
|
||||
} else {
|
||||
// remove op
|
||||
strIndex -= op
|
||||
}
|
||||
}
|
||||
if (strIndex !== str.length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation didn't operate on the whole string.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
}
|
||||
|
||||
if (result.length > TextOperation.MAX_STRING_LENGTH) {
|
||||
throw new TextOperation.TooLongError(operation, result.length)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the effect of this operation on the length of the text.
|
||||
*
|
||||
* NB: This is an Overleaf addition to the original TextOperation.
|
||||
*
|
||||
* @param {number} length of the original string; non-negative
|
||||
* @return {number} length of the new string; non-negative
|
||||
*/
|
||||
TextOperation.prototype.applyToLength = function (length) {
|
||||
const operation = this
|
||||
if (length !== operation.baseLength) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation's base length must be equal to the string's length.",
|
||||
operation,
|
||||
length
|
||||
)
|
||||
}
|
||||
let newLength = 0
|
||||
let strIndex = 0
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
if (strIndex + op > length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"Operation can't retain more chars than are left in the string.",
|
||||
operation,
|
||||
length
|
||||
)
|
||||
}
|
||||
// Copy skipped part of the old string.
|
||||
newLength += op
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
// Insert string.
|
||||
newLength += op.length
|
||||
} else {
|
||||
// remove op
|
||||
strIndex -= op
|
||||
}
|
||||
}
|
||||
if (strIndex !== length) {
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation didn't operate on the whole string.",
|
||||
operation,
|
||||
length
|
||||
)
|
||||
}
|
||||
if (newLength > TextOperation.MAX_STRING_LENGTH) {
|
||||
throw new TextOperation.TooLongError(operation, newLength)
|
||||
}
|
||||
return newLength
|
||||
}
|
||||
|
||||
// Computes the inverse of an operation. The inverse of an operation is the
|
||||
// operation that reverts the effects of the operation, e.g. when you have an
|
||||
// operation 'insert("hello "); skip(6);' then the inverse is 'remove("hello ");
|
||||
// skip(6);'. The inverse should be used for implementing undo.
|
||||
TextOperation.prototype.invert = function (str) {
|
||||
let strIndex = 0
|
||||
const inverse = new TextOperation()
|
||||
const ops = this.ops
|
||||
for (let i = 0, l = ops.length; i < l; i++) {
|
||||
const op = ops[i]
|
||||
if (isRetain(op)) {
|
||||
inverse.retain(op)
|
||||
strIndex += op
|
||||
} else if (isInsert(op)) {
|
||||
inverse.remove(op.length)
|
||||
} else {
|
||||
// remove op
|
||||
inverse.insert(str.slice(strIndex, strIndex - op))
|
||||
strIndex -= op
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
}
|
||||
|
||||
// When you use ctrl-z to undo your latest changes, you expect the program not
|
||||
// to undo every single keystroke but to undo your last sentence you wrote at
|
||||
// a stretch or the deletion you did by holding the backspace key down. This
|
||||
// This can be implemented by composing operations on the undo stack. This
|
||||
// method can help decide whether two operations should be composed. It
|
||||
// returns true if the operations are consecutive insert operations or both
|
||||
// operations delete text at the same position. You may want to include other
|
||||
// factors like the time since the last change in your decision.
|
||||
TextOperation.prototype.canBeComposedWithForUndo = function (other) {
|
||||
if (this.isNoop() || other.isNoop()) {
|
||||
return true
|
||||
}
|
||||
|
||||
const startA = getStartIndex(this)
|
||||
const startB = getStartIndex(other)
|
||||
const simpleA = getSimpleOp(this)
|
||||
const simpleB = getSimpleOp(other)
|
||||
if (!simpleA || !simpleB) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isInsert(simpleA) && isInsert(simpleB)) {
|
||||
return startA + simpleA.length === startB
|
||||
}
|
||||
|
||||
if (isRemove(simpleA) && isRemove(simpleB)) {
|
||||
// there are two possibilities to delete: with backspace and with the
|
||||
// delete key.
|
||||
return startB - simpleB === startA || startA === startB
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
TextOperation.prototype.canBeComposedWith = function (other) {
|
||||
return this.targetLength === other.baseLength
|
||||
}
|
||||
|
||||
// Compose merges two consecutive operations into one operation, that
|
||||
// preserves the changes of both. Or, in other words, for each input string S
|
||||
// and a pair of consecutive operations A and B,
|
||||
// apply(apply(S, A), B) = apply(S, compose(A, B)) must hold.
|
||||
TextOperation.prototype.compose = function (operation2) {
|
||||
const operation1 = this
|
||||
if (operation1.targetLength !== operation2.baseLength) {
|
||||
throw new Error(
|
||||
'The base length of the second operation has to be the ' +
|
||||
'target length of the first operation'
|
||||
)
|
||||
}
|
||||
|
||||
const operation = new TextOperation() // the combined operation
|
||||
const ops1 = operation1.ops
|
||||
const ops2 = operation2.ops // for fast access
|
||||
let i1 = 0
|
||||
let i2 = 0 // current index into ops1 respectively ops2
|
||||
let op1 = ops1[i1++]
|
||||
let op2 = ops2[i2++] // current ops
|
||||
for (;;) {
|
||||
// Dispatch on the type of op1 and op2
|
||||
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
|
||||
// end condition: both ops1 and ops2 have been processed
|
||||
break
|
||||
}
|
||||
|
||||
if (isRemove(op1)) {
|
||||
operation.remove(op1)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
if (isInsert(op2)) {
|
||||
operation.insert(op2)
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof op1 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too short.'
|
||||
)
|
||||
}
|
||||
if (typeof op2 === 'undefined') {
|
||||
throw new Error('Cannot compose operations: first operation is too long.')
|
||||
}
|
||||
|
||||
if (isRetain(op1) && isRetain(op2)) {
|
||||
if (op1 > op2) {
|
||||
operation.retain(op2)
|
||||
op1 = op1 - op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
operation.retain(op1)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.retain(op1)
|
||||
op2 = op2 - op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isInsert(op1) && isRemove(op2)) {
|
||||
if (op1.length > -op2) {
|
||||
op1 = op1.slice(-op2)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === -op2) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = op2 + op1.length
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isInsert(op1) && isRetain(op2)) {
|
||||
if (op1.length > op2) {
|
||||
operation.insert(op1.slice(0, op2))
|
||||
op1 = op1.slice(op2)
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1.length === op2) {
|
||||
operation.insert(op1)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.insert(op1)
|
||||
op2 = op2 - op1.length
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else if (isRetain(op1) && isRemove(op2)) {
|
||||
if (op1 > -op2) {
|
||||
operation.remove(op2)
|
||||
op1 = op1 + op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === -op2) {
|
||||
operation.remove(op2)
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
operation.remove(op1)
|
||||
op2 = op2 + op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"This shouldn't happen: op1: " +
|
||||
JSON.stringify(op1) +
|
||||
', op2: ' +
|
||||
JSON.stringify(op2)
|
||||
)
|
||||
}
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
function getSimpleOp(operation, fn) {
|
||||
const ops = operation.ops
|
||||
switch (ops.length) {
|
||||
case 1:
|
||||
return ops[0]
|
||||
case 2:
|
||||
return isRetain(ops[0]) ? ops[1] : isRetain(ops[1]) ? ops[0] : null
|
||||
case 3:
|
||||
if (isRetain(ops[0]) && isRetain(ops[2])) {
|
||||
return ops[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getStartIndex(operation) {
|
||||
if (isRetain(operation.ops[0])) {
|
||||
return operation.ops[0]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Transform takes two operations A and B that happened concurrently and
|
||||
// produces two operations A' and B' (in an array) such that
|
||||
// `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
|
||||
// heart of OT.
|
||||
TextOperation.transform = function (operation1, operation2) {
|
||||
if (operation1.baseLength !== operation2.baseLength) {
|
||||
throw new Error('Both operations have to have the same base length')
|
||||
}
|
||||
|
||||
const operation1prime = new TextOperation()
|
||||
const operation2prime = new TextOperation()
|
||||
const ops1 = operation1.ops
|
||||
const ops2 = operation2.ops
|
||||
let i1 = 0
|
||||
let i2 = 0
|
||||
let op1 = ops1[i1++]
|
||||
let op2 = ops2[i2++]
|
||||
for (;;) {
|
||||
// At every iteration of the loop, the imaginary cursor that both
|
||||
// operation1 and operation2 have that operates on the input string must
|
||||
// have the same position in the input string.
|
||||
|
||||
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
|
||||
// end condition: both ops1 and ops2 have been processed
|
||||
break
|
||||
}
|
||||
|
||||
// next two cases: one or both ops are insert ops
|
||||
// => insert the string in the corresponding prime operation, skip it in
|
||||
// the other one. If both op1 and op2 are insert ops, prefer op1.
|
||||
if (isInsert(op1)) {
|
||||
operation1prime.insert(op1)
|
||||
operation2prime.retain(op1.length)
|
||||
op1 = ops1[i1++]
|
||||
continue
|
||||
}
|
||||
if (isInsert(op2)) {
|
||||
operation1prime.retain(op2.length)
|
||||
operation2prime.insert(op2)
|
||||
op2 = ops2[i2++]
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof op1 === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot compose operations: first operation is too short.'
|
||||
)
|
||||
}
|
||||
if (typeof op2 === 'undefined') {
|
||||
throw new Error('Cannot compose operations: first operation is too long.')
|
||||
}
|
||||
|
||||
let minl
|
||||
if (isRetain(op1) && isRetain(op2)) {
|
||||
// Simple case: retain/retain
|
||||
if (op1 > op2) {
|
||||
minl = op2
|
||||
op1 = op1 - op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
minl = op2
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1
|
||||
op2 = op2 - op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.retain(minl)
|
||||
operation2prime.retain(minl)
|
||||
} else if (isRemove(op1) && isRemove(op2)) {
|
||||
// Both operations remove the same string at the same position. We don't
|
||||
// need to produce any operations, we just skip over the remove ops and
|
||||
// handle the case that one operation removes more than the other.
|
||||
if (-op1 > -op2) {
|
||||
op1 = op1 - op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === op2) {
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
op2 = op2 - op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
// next two cases: remove/retain and retain/remove
|
||||
} else if (isRemove(op1) && isRetain(op2)) {
|
||||
if (-op1 > op2) {
|
||||
minl = op2
|
||||
op1 = op1 + op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (-op1 === op2) {
|
||||
minl = op2
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = -op1
|
||||
op2 = op2 + op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation1prime.remove(minl)
|
||||
} else if (isRetain(op1) && isRemove(op2)) {
|
||||
if (op1 > -op2) {
|
||||
minl = -op2
|
||||
op1 = op1 + op2
|
||||
op2 = ops2[i2++]
|
||||
} else if (op1 === -op2) {
|
||||
minl = op1
|
||||
op1 = ops1[i1++]
|
||||
op2 = ops2[i2++]
|
||||
} else {
|
||||
minl = op1
|
||||
op2 = op2 + op1
|
||||
op1 = ops1[i1++]
|
||||
}
|
||||
operation2prime.remove(minl)
|
||||
} else {
|
||||
throw new Error("The two operations aren't compatible")
|
||||
}
|
||||
}
|
||||
|
||||
return [operation1prime, operation2prime]
|
||||
}
|
||||
|
||||
module.exports = TextOperation
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
// Dependencies are loaded at the bottom of the file to mitigate circular
|
||||
// dependency
|
||||
let RestoreOrigin = null
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} kind
|
||||
* @classdesc
|
||||
* An Origin records where a {@link Change} came from. The Origin class handles
|
||||
* simple tag origins, like "it came from rich text mode", or "it came from
|
||||
* uploading files". Its subclasses record more detailed data for Changes such
|
||||
* as restoring a version.
|
||||
*/
|
||||
function Origin(kind) {
|
||||
assert.string(kind, 'Origin: bad kind')
|
||||
|
||||
this.kind = kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Origin from its raw form.
|
||||
*
|
||||
* @param {Object} [raw]
|
||||
* @return {Origin | null}
|
||||
*/
|
||||
Origin.fromRaw = function originFromRaw(raw) {
|
||||
if (!raw) return null
|
||||
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
||||
return new Origin(raw.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Origin to raw form for storage or transmission.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
Origin.prototype.toRaw = function originToRaw() {
|
||||
return { kind: this.kind }
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
Origin.prototype.getKind = function () {
|
||||
return this.kind
|
||||
}
|
||||
|
||||
module.exports = Origin
|
||||
|
||||
RestoreOrigin = require('./restore_origin')
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Origin = require('./')
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* When a {@link Change} is generated by restoring a previous version, this
|
||||
* records the original version. We also store the timestamp of the restored
|
||||
* version for display; technically, this is redundant, because we could
|
||||
* recover it using the version ID. However, it would be very expensive to
|
||||
* recover all referenced versions, and it is also possible that the change
|
||||
* for the restored version will no longer exist, either because it was merged
|
||||
* with other changes or was deleted.
|
||||
*
|
||||
* @see Origin
|
||||
*/
|
||||
class RestoreOrigin extends Origin {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} version that was restored
|
||||
* @param {Date} timestamp from the restored version
|
||||
*/
|
||||
constructor(version, timestamp) {
|
||||
assert.integer(version, 'RestoreOrigin: bad version')
|
||||
assert.date(timestamp, 'RestoreOrigin: bad timestamp')
|
||||
|
||||
super(RestoreOrigin.KIND)
|
||||
this.version = version
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new RestoreOrigin(raw.version, new Date(raw.timestamp))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return {
|
||||
kind: RestoreOrigin.KIND,
|
||||
version: this.version,
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getVersion() {
|
||||
return this.version
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Date}
|
||||
*/
|
||||
getTimestamp() {
|
||||
return this.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
RestoreOrigin.KIND = 'restore'
|
||||
|
||||
module.exports = RestoreOrigin
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const BPromise = require('bluebird')
|
||||
|
||||
const ChangeNote = require('./change_note')
|
||||
const ChangeRequest = require('./change_request')
|
||||
const Chunk = require('./chunk')
|
||||
const Operation = require('./operation')
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @classdesc
|
||||
* Operational Transformation client.
|
||||
*
|
||||
* See OT.md for explanation.
|
||||
*/
|
||||
function OtClient(_projectId, _editor, _blobStore, _socket) {
|
||||
const STATE_DISCONNECTED = 0
|
||||
const STATE_LOADING = 1
|
||||
const STATE_READY = 2
|
||||
const STATE_WAITING = 3
|
||||
|
||||
let _version = null
|
||||
let _state = STATE_DISCONNECTED
|
||||
const _buffer = []
|
||||
let _ackVersion = null
|
||||
let _outstanding = []
|
||||
let _pending = []
|
||||
const _waiting = []
|
||||
|
||||
this.connect = function otClientConnect() {
|
||||
switch (_state) {
|
||||
case STATE_DISCONNECTED:
|
||||
_state = STATE_LOADING
|
||||
_socket.emit('authenticate', {
|
||||
projectId: _projectId,
|
||||
token: 'letmein',
|
||||
})
|
||||
break
|
||||
default:
|
||||
throw new Error('connect in state ' + _state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest project version number for which the client can construct the
|
||||
* project content.
|
||||
*
|
||||
* @return {number} non-negative
|
||||
*/
|
||||
this.getVersion = function () {
|
||||
return _version
|
||||
}
|
||||
|
||||
_socket.on('load', function otClientOnLoad(data) {
|
||||
switch (_state) {
|
||||
case STATE_LOADING: {
|
||||
const chunk = Chunk.fromRaw(data)
|
||||
const snapshot = chunk.getSnapshot()
|
||||
snapshot.applyAll(chunk.getChanges(), { strict: true })
|
||||
_version = chunk.getEndVersion()
|
||||
// TODO: we can get remote changes here, so it's not correct to wait for
|
||||
// the editor to load before transitioning to the READY state
|
||||
_editor.load(snapshot).then(function () {
|
||||
_state = STATE_READY
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error('loaded in state ' + _state)
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// Local Operations
|
||||
//
|
||||
|
||||
function sendOutstandingChange() {
|
||||
const changeRequest = new ChangeRequest(_version, _outstanding)
|
||||
_socket.emit('change', changeRequest.toRaw())
|
||||
_state = STATE_WAITING
|
||||
}
|
||||
|
||||
function sendLocalOperation(operation) {
|
||||
_outstanding.push(operation)
|
||||
sendOutstandingChange()
|
||||
}
|
||||
|
||||
function queueLocalOperation(operation) {
|
||||
_pending.push(operation)
|
||||
}
|
||||
|
||||
this.handleLocalOperation = function otClientHandleLocalOperation(operation) {
|
||||
switch (_state) {
|
||||
case STATE_READY:
|
||||
sendLocalOperation(operation)
|
||||
break
|
||||
case STATE_WAITING:
|
||||
queueLocalOperation(operation)
|
||||
break
|
||||
default:
|
||||
throw new Error('local operation in state ' + _state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A promise that resolves when the project reaches the given version.
|
||||
*
|
||||
* @param {number} version non-negative
|
||||
* @return {Promise}
|
||||
*/
|
||||
this.waitForVersion = function otClientWaitForVersion(version) {
|
||||
if (!_waiting[version]) _waiting[version] = []
|
||||
return new BPromise(function (resolve, reject) {
|
||||
_waiting[version].push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveWaitingPromises() {
|
||||
for (const version in _waiting) {
|
||||
if (!Object.prototype.hasOwnProperty.call(_waiting, version)) continue
|
||||
if (version > _version) continue
|
||||
_waiting[version].forEach(function (resolve) {
|
||||
resolve()
|
||||
})
|
||||
delete _waiting[version]
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Messages from Server
|
||||
//
|
||||
|
||||
function advanceIfReady() {
|
||||
if (_ackVersion !== null && _version === _ackVersion) {
|
||||
_version += 1
|
||||
_ackVersion = null
|
||||
handleAckReady()
|
||||
advanceIfReady()
|
||||
return
|
||||
}
|
||||
const changeNotes = _.remove(_buffer, function (changeNote) {
|
||||
return changeNote.getBaseVersion() === _version
|
||||
})
|
||||
if (changeNotes.length === 1) {
|
||||
handleRemoteChangeReady(changeNotes[0].getChange())
|
||||
_version += 1
|
||||
advanceIfReady()
|
||||
return
|
||||
}
|
||||
if (changeNotes.length !== 0) {
|
||||
throw new Error('multiple remote changes in client version ' + _version)
|
||||
}
|
||||
}
|
||||
|
||||
function bufferRemoteChangeNote(changeNote) {
|
||||
const version = changeNote.getBaseVersion()
|
||||
if (_.find(_buffer, 'baseVersion', version)) {
|
||||
throw new Error('multiple changes in version ' + version)
|
||||
}
|
||||
if (version === _ackVersion) {
|
||||
throw new Error('received change that was acked in ' + _ackVersion)
|
||||
}
|
||||
_buffer.push(changeNote)
|
||||
}
|
||||
|
||||
function handleAckReady() {
|
||||
// console.log('handleAckReady')
|
||||
if (_outstanding.length === 0) {
|
||||
throw new Error('ack complete without outstanding change')
|
||||
}
|
||||
if (_state !== STATE_WAITING) {
|
||||
throw new Error('ack complete in state ' + _state)
|
||||
}
|
||||
_editor.handleChangeAcknowledged()
|
||||
resolveWaitingPromises()
|
||||
if (_pending.length > 0) {
|
||||
_outstanding = _pending
|
||||
_pending = []
|
||||
sendOutstandingChange()
|
||||
} else {
|
||||
_outstanding = []
|
||||
_state = STATE_READY
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoteChangeReady(change) {
|
||||
if (_pending.length > 0) {
|
||||
if (_outstanding.length === 0) {
|
||||
throw new Error('pending change without outstanding change')
|
||||
}
|
||||
}
|
||||
|
||||
Operation.transformMultiple(_outstanding, change.getOperations())
|
||||
Operation.transformMultiple(_pending, change.getOperations())
|
||||
|
||||
_editor.applyRemoteChange(change)
|
||||
}
|
||||
|
||||
_socket.on('ack', function otClientOnAck(data) {
|
||||
switch (_state) {
|
||||
case STATE_WAITING: {
|
||||
const changeNote = ChangeNote.fromRaw(data)
|
||||
_ackVersion = changeNote.getBaseVersion()
|
||||
advanceIfReady()
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error('ack in state ' + _state)
|
||||
}
|
||||
})
|
||||
|
||||
_socket.on('change', function otClientOnChange(data) {
|
||||
switch (_state) {
|
||||
case STATE_READY:
|
||||
case STATE_WAITING:
|
||||
bufferRemoteChangeNote(ChangeNote.fromRaw(data))
|
||||
advanceIfReady()
|
||||
break
|
||||
default:
|
||||
throw new Error('remote change in state ' + _state)
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// Connection State
|
||||
// TODO: socket.io error handling
|
||||
//
|
||||
|
||||
_socket.on('disconnect', function () {
|
||||
_state = STATE_DISCONNECTED
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('disconnected') // TODO: how do we handle disconnect?
|
||||
})
|
||||
}
|
||||
module.exports = OtClient
|
||||
@@ -0,0 +1,91 @@
|
||||
/** @module */
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* Regular expressions for Overleaf v2 taken from
|
||||
* https://github.com/sharelatex/web-sharelatex/blob/master/app/coffee/Features/Project/SafePath.coffee
|
||||
*/
|
||||
|
||||
//
|
||||
// Regex of characters that are invalid in filenames
|
||||
//
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const BAD_CHAR_RX = /[/*\u0000-\u001F\u007F\u0080-\u009F\uD800-\uDFFF]/g
|
||||
|
||||
//
|
||||
// Regex of filename patterns that are invalid ("." ".." and leading/trailing
|
||||
// whitespace)
|
||||
//
|
||||
const BAD_FILE_RX = /(^\.$)|(^\.\.$)|(^\s+)|(\s+$)/g
|
||||
|
||||
//
|
||||
// Put a block on filenames which match javascript property names, as they
|
||||
// can cause exceptions where the code puts filenames into a hash. This is a
|
||||
// temporary workaround until the code in other places is made safe against
|
||||
// property names.
|
||||
//
|
||||
// See https://github.com/overleaf/write_latex/wiki/Using-javascript-Objects-as-Maps
|
||||
//
|
||||
const BLOCKED_FILE_RX =
|
||||
/^(prototype|constructor|toString|toLocaleString|valueOf|hasOwnProperty|isPrototypeOf|propertyIsEnumerable|__defineGetter__|__lookupGetter__|__defineSetter__|__lookupSetter__|__proto__)$/
|
||||
|
||||
//
|
||||
// Maximum path length, in characters. This is fairly arbitrary.
|
||||
//
|
||||
const MAX_PATH = 1024
|
||||
|
||||
/**
|
||||
* Replace invalid characters and filename patterns in a filename with
|
||||
* underscores.
|
||||
*/
|
||||
function cleanPart(filename) {
|
||||
filename = filename.replace(BAD_CHAR_RX, '_')
|
||||
filename = filename.replace(BAD_FILE_RX, function (match) {
|
||||
return new Array(match.length + 1).join('_')
|
||||
})
|
||||
return filename
|
||||
}
|
||||
|
||||
/**
|
||||
* All pathnames in a Snapshot must be clean. We want pathnames that:
|
||||
*
|
||||
* 1. are unambiguous (e.g. no `.`s or redundant path separators)
|
||||
* 2. do not allow directory traversal attacks (e.g. no `..`s or absolute paths)
|
||||
* 3. do not contain leading/trailing space
|
||||
* 4. do not contain the character '*' in filenames
|
||||
*
|
||||
* We normalise the pathname, split it by the separator and then clean each part
|
||||
* as a filename
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @return {String}
|
||||
*/
|
||||
exports.clean = function (pathname) {
|
||||
pathname = path.normalize(pathname)
|
||||
pathname = pathname.replace(/\\/g, '/') // workaround for IE
|
||||
pathname = pathname.replace(/\/+/g, '/') // no multiple slashes
|
||||
pathname = pathname.replace(/^(\/.*)$/, '_$1') // no leading /
|
||||
pathname = pathname.replace(/^(.+)\/$/, '$1') // no trailing /
|
||||
pathname = pathname.replace(/^ *(.*)$/, '$1') // no leading spaces
|
||||
pathname = pathname.replace(/^(.*[^ ]) *$/, '$1') // no trailing spaces
|
||||
if (pathname.length === 0) pathname = '_'
|
||||
pathname = pathname.split('/').map(cleanPart).join('/')
|
||||
pathname = pathname.replace(BLOCKED_FILE_RX, '@$1')
|
||||
return pathname
|
||||
}
|
||||
|
||||
/**
|
||||
* A pathname is clean (see clean) and not too long.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @return {Boolean}
|
||||
*/
|
||||
exports.isClean = function pathnameIsClean(pathname) {
|
||||
return (
|
||||
exports.clean(pathname) === pathname &&
|
||||
pathname.length <= MAX_PATH &&
|
||||
pathname.length > 0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
const BPromise = require('bluebird')
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
const FileMap = require('./file_map')
|
||||
const V2DocVersions = require('./v2_doc_versions')
|
||||
|
||||
/**
|
||||
* @typedef {import("./types").BlobStore} BlobStore
|
||||
* @typedef {import("./change")} Change
|
||||
* @typedef {import("./operation/text_operation")} TextOperation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc A Snapshot represents the state of a {@link Project} at a
|
||||
* particular version.
|
||||
*/
|
||||
class Snapshot {
|
||||
static fromRaw(raw) {
|
||||
assert.object(raw.files, 'bad raw.files')
|
||||
return new Snapshot(
|
||||
FileMap.fromRaw(raw.files),
|
||||
raw.projectVersion,
|
||||
V2DocVersions.fromRaw(raw.v2DocVersions)
|
||||
)
|
||||
}
|
||||
|
||||
toRaw() {
|
||||
const raw = {
|
||||
files: this.fileMap.toRaw(),
|
||||
}
|
||||
if (this.projectVersion) raw.projectVersion = this.projectVersion
|
||||
if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw()
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {FileMap} [fileMap]
|
||||
* @param {string} [projectVersion]
|
||||
* @param {V2DocVersions} [v2DocVersions]
|
||||
*/
|
||||
constructor(fileMap, projectVersion, v2DocVersions) {
|
||||
assert.maybe.instance(fileMap, FileMap, 'bad fileMap')
|
||||
|
||||
this.fileMap = fileMap || new FileMap({})
|
||||
this.projectVersion = projectVersion
|
||||
this.v2DocVersions = v2DocVersions
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string | null | undefined}
|
||||
*/
|
||||
getProjectVersion() {
|
||||
return this.projectVersion
|
||||
}
|
||||
|
||||
setProjectVersion(projectVersion) {
|
||||
assert.maybe.match(
|
||||
projectVersion,
|
||||
Snapshot.PROJECT_VERSION_RX,
|
||||
'Snapshot: bad projectVersion'
|
||||
)
|
||||
this.projectVersion = projectVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {V2DocVersions | null | undefined}
|
||||
*/
|
||||
getV2DocVersions() {
|
||||
return this.v2DocVersions
|
||||
}
|
||||
|
||||
setV2DocVersions(v2DocVersions) {
|
||||
assert.maybe.instance(
|
||||
v2DocVersions,
|
||||
V2DocVersions,
|
||||
'Snapshot: bad v2DocVersions'
|
||||
)
|
||||
this.v2DocVersions = v2DocVersions
|
||||
}
|
||||
|
||||
updateV2DocVersions(v2DocVersions) {
|
||||
// merge new v2DocVersions into this.v2DocVersions
|
||||
v2DocVersions.applyTo(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* The underlying file map.
|
||||
* @return {FileMap}
|
||||
*/
|
||||
getFileMap() {
|
||||
return this.fileMap
|
||||
}
|
||||
|
||||
/**
|
||||
* The pathnames of all of the files.
|
||||
*
|
||||
* @return {Array.<string>} in no particular order
|
||||
*/
|
||||
getFilePathnames() {
|
||||
return this.fileMap.getPathnames()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a File by its pathname.
|
||||
* @see FileMap#getFile
|
||||
*/
|
||||
getFile(pathname) {
|
||||
return this.fileMap.getFile(pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given file to the snapshot.
|
||||
* @see FileMap#addFile
|
||||
*/
|
||||
addFile(pathname, file) {
|
||||
this.fileMap.addFile(pathname, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move or remove a file.
|
||||
* @see FileMap#moveFile
|
||||
*/
|
||||
moveFile(pathname, newPathname) {
|
||||
this.fileMap.moveFile(pathname, newPathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of files in the snapshot.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
countFiles() {
|
||||
return this.fileMap.countFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the content of an editable file.
|
||||
*
|
||||
* Throws an error if no file with the given name exists.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {TextOperation} textOperation
|
||||
*/
|
||||
editFile(pathname, textOperation) {
|
||||
const file = this.fileMap.getFile(pathname)
|
||||
if (!file) {
|
||||
throw new Snapshot.EditMissingFileError(
|
||||
`can't find file for editing: ${pathname}`
|
||||
)
|
||||
}
|
||||
file.edit(textOperation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all changes in sequence. Modifies the snapshot in place.
|
||||
*
|
||||
* Ignore recoverable errors (caused by historical bad data) unless opts.strict is true
|
||||
*
|
||||
* @param {Change[]} changes
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.strict - do not ignore recoverable errors
|
||||
*/
|
||||
applyAll(changes, opts) {
|
||||
for (const change of changes) {
|
||||
change.applyTo(this, opts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the Files in this Snapshot reference blob hashes, add them to the given
|
||||
* set.
|
||||
*
|
||||
* @param {Set.<String>} blobHashes
|
||||
*/
|
||||
findBlobHashes(blobHashes) {
|
||||
// eslint-disable-next-line array-callback-return
|
||||
this.fileMap.map(file => {
|
||||
const hash = file.getHash()
|
||||
if (hash) blobHashes.add(hash)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all of the files in this snapshot.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadFiles(kind, blobStore) {
|
||||
return BPromise.props(this.fileMap.map(file => file.load(kind, blobStore)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Store each of the files in this snapshot and return the raw snapshot for
|
||||
* long term storage.
|
||||
*
|
||||
* @param {BlobStore} blobStore
|
||||
* @param {number} [concurrency]
|
||||
* @return {Promise.<Object>}
|
||||
*/
|
||||
store(blobStore, concurrency) {
|
||||
assert.maybe.number(concurrency, 'bad concurrency')
|
||||
|
||||
const projectVersion = this.projectVersion
|
||||
const rawV2DocVersions = this.v2DocVersions
|
||||
? this.v2DocVersions.toRaw()
|
||||
: undefined
|
||||
return this.fileMap
|
||||
.mapAsync(file => file.store(blobStore), concurrency)
|
||||
.then(rawFiles => {
|
||||
return {
|
||||
files: rawFiles,
|
||||
projectVersion,
|
||||
v2DocVersions: rawV2DocVersions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deep clone of this snapshot.
|
||||
*
|
||||
* @return {Snapshot}
|
||||
*/
|
||||
clone() {
|
||||
return Snapshot.fromRaw(this.toRaw())
|
||||
}
|
||||
}
|
||||
|
||||
class EditMissingFileError extends OError {}
|
||||
Snapshot.EditMissingFileError = EditMissingFileError
|
||||
|
||||
Snapshot.PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$'
|
||||
Snapshot.PROJECT_VERSION_RX = new RegExp(Snapshot.PROJECT_VERSION_RX_STRING)
|
||||
|
||||
module.exports = Snapshot
|
||||
@@ -0,0 +1,13 @@
|
||||
import Blob from './blob'
|
||||
import BPromise from 'bluebird'
|
||||
|
||||
export type BlobStore = {
|
||||
getString(hash: string): BPromise<string>
|
||||
putString(content: string): BPromise<Blob>
|
||||
}
|
||||
|
||||
export type StringFileRawData = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type RawV2DocVersions = Record<string, { pathname: string; v: number }>
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Misc functions
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
/*
|
||||
* return true/false if the given string contains non-BMP chars
|
||||
*/
|
||||
exports.containsNonBmpChars = function utilContainsNonBmpChars(str) {
|
||||
// check for first (high) surrogate in a non-BMP character
|
||||
return /[\uD800-\uDBFF]/.test(str)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
|
||||
/**
|
||||
* @typedef {import("./file")} File
|
||||
* @typedef {import("./types").RawV2DocVersions} RawV2DocVersions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {RawV2DocVersions} data
|
||||
* @classdesc
|
||||
*/
|
||||
function V2DocVersions(data) {
|
||||
this.data = data || {}
|
||||
}
|
||||
|
||||
V2DocVersions.fromRaw = function v2DocVersionsFromRaw(raw) {
|
||||
if (!raw) return undefined
|
||||
return new V2DocVersions(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
V2DocVersions.prototype.toRaw = function () {
|
||||
if (!this.data) return null
|
||||
const raw = _.clone(this.data)
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone this object.
|
||||
*
|
||||
* @return {V2DocVersions} a new object of the same type
|
||||
*/
|
||||
V2DocVersions.prototype.clone = function v2DocVersionsClone() {
|
||||
return V2DocVersions.fromRaw(this.toRaw())
|
||||
}
|
||||
|
||||
V2DocVersions.prototype.applyTo = function v2DocVersionsApplyTo(snapshot) {
|
||||
// Only update the snapshot versions if we have new versions
|
||||
if (!_.size(this.data)) return
|
||||
|
||||
// Create v2DocVersions in snapshot if it does not exist
|
||||
// otherwise update snapshot v2docversions
|
||||
if (!snapshot.v2DocVersions) {
|
||||
snapshot.v2DocVersions = this.clone()
|
||||
} else {
|
||||
_.assign(snapshot.v2DocVersions.data, this.data)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = V2DocVersions
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "overleaf-editor-core",
|
||||
"version": "1.0.0",
|
||||
"description": "Library shared between the editor server and clients.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "mocha",
|
||||
"format": "prettier --list-different $PWD/'**/*.js'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.js'",
|
||||
"lint": "eslint --max-warnings 0 --format unix lib test && tsc",
|
||||
"lint:fix": "eslint --fix lib test",
|
||||
"test:ci": "npm run test",
|
||||
"coverage": "istanbul cover _mocha"
|
||||
},
|
||||
"author": "team@overleaf.com",
|
||||
"license": "Proprietary",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bluebird": "^3.5.30",
|
||||
"chai": "^3.3.0",
|
||||
"istanbul": "^0.4.5",
|
||||
"mocha": "^6.1.4",
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@overleaf/o-error": "*",
|
||||
"bluebird": "^3.1.1",
|
||||
"check-types": "^5.1.0",
|
||||
"lodash": "^4.17.19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const Operation = core.Operation
|
||||
|
||||
describe('Change', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from operations', function () {
|
||||
const blobHashes = new Set()
|
||||
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with content doesn't have a hash.
|
||||
change.pushOperation(Operation.addFile('a.txt', File.fromString('a')))
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// AddFile with hash should give us a hash.
|
||||
change.pushOperation(
|
||||
Operation.addFile('b.txt', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
)
|
||||
change.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(1)
|
||||
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const Operation = ot.Operation
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('EditFileOperation', function () {
|
||||
function edit(pathname, textOperationJsonObject) {
|
||||
return Operation.editFile(
|
||||
pathname,
|
||||
TextOperation.fromJSON(textOperationJsonObject)
|
||||
)
|
||||
}
|
||||
|
||||
describe('canBeComposedWith', function () {
|
||||
it('on the same file', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('on different files', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('bar.tex', ['y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with a different type of opperation', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = Operation.addFile(
|
||||
'bar.tex',
|
||||
File.fromString('')
|
||||
)
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
|
||||
it('with incompatible lengths', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [2, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
|
||||
.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('canBeComposedWithForUndo', function () {
|
||||
it('can', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('cannot', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', ['y', 1, 'z'])
|
||||
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
|
||||
.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('compose', function () {
|
||||
it('composes text operations', function () {
|
||||
const editFileOperation1 = edit('foo.tex', ['x'])
|
||||
const editFileOperation2 = edit('foo.tex', [1, 'y'])
|
||||
const composedFileOperation =
|
||||
editFileOperation1.compose(editFileOperation2)
|
||||
const expectedComposedFileOperation = edit('foo.tex', ['xy'])
|
||||
expect(composedFileOperation).to.deep.equal(expectedComposedFileOperation)
|
||||
|
||||
// check that the original operation wasn't modified
|
||||
expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x']))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const FakeBlobStore = require('./support/fake_blob_store')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
|
||||
describe('File', function () {
|
||||
it('can have attached metadata', function () {
|
||||
// no metadata
|
||||
let file = File.fromString('foo')
|
||||
expect(file.getMetadata()).to.eql({})
|
||||
|
||||
// metadata passed in at construction time
|
||||
file = File.fromString('foo', { main: true })
|
||||
expect(file.getMetadata()).to.eql({ main: true })
|
||||
|
||||
// metadata set at runtime
|
||||
file.setMetadata({ main: false })
|
||||
expect(file.getMetadata()).to.eql({ main: false })
|
||||
})
|
||||
|
||||
describe('toRaw', function () {
|
||||
it('returns non-empty metadata', function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
expect(file.toRaw()).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata: metadata,
|
||||
})
|
||||
|
||||
delete file.getMetadata().main
|
||||
expect(file.toRaw()).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const raw = file.toRaw()
|
||||
const fileMetadata = file.getMetadata()
|
||||
const rawMetadata = raw.metadata
|
||||
expect(rawMetadata).not.to.equal(fileMetadata)
|
||||
expect(rawMetadata).to.deep.equal(fileMetadata)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store', function () {
|
||||
it('does not return empty metadata', function () {
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
expect(raw).to.eql({ hash: File.EMPTY_FILE_HASH })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns non-empty metadata', function () {
|
||||
const metadata = { main: true }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
expect(raw).to.eql({
|
||||
hash: File.EMPTY_FILE_HASH,
|
||||
metadata: metadata,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a deep clone of metadata', function () {
|
||||
const metadata = { externalFile: { id: 123 } }
|
||||
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
|
||||
const fakeBlobStore = new FakeBlobStore()
|
||||
return file.store(fakeBlobStore).then(raw => {
|
||||
raw.metadata.externalFile.id = 456
|
||||
expect(file.getMetadata().externalFile.id).to.equal(123)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with string data', function () {
|
||||
it('can be created from a string', function () {
|
||||
const file = File.fromString('foo')
|
||||
expect(file.getContent()).to.equal('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with hollow string data', function () {
|
||||
it('can be cloned', function () {
|
||||
const file = File.createHollow(null, 0)
|
||||
expect(file.getStringLength()).to.equal(0)
|
||||
const clone = file.clone()
|
||||
expect(clone.getStringLength()).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const BPromise = require('bluebird')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const FileMap = ot.FileMap
|
||||
|
||||
describe('FileMap', function () {
|
||||
function makeTestFile(pathname) {
|
||||
return File.fromString(pathname)
|
||||
}
|
||||
|
||||
function makeTestFiles(pathnames) {
|
||||
return _.zipObject(pathnames, _.map(pathnames, makeTestFile))
|
||||
}
|
||||
|
||||
function makeFileMap(pathnames) {
|
||||
return new FileMap(makeTestFiles(pathnames))
|
||||
}
|
||||
|
||||
it('allows construction with a single file', function () {
|
||||
makeFileMap(['a'])
|
||||
})
|
||||
|
||||
it('allows folders to differ by case', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a/b', 'A/c'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'A/b/d'])
|
||||
}).not.to.throw
|
||||
expect(() => {
|
||||
makeFileMap(['a/b/c', 'a/B/d'])
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('does not allow conflicting paths on construct', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['a', 'a/b'])
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('detects conflicting paths with characters that sort before /', function () {
|
||||
const fileMap = makeFileMap(['a', 'a!'])
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
})
|
||||
|
||||
it('detects conflicting paths', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/c/d')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b')).to.be.truthy
|
||||
expect(fileMap.wouldConflict('a/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/c')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/b/d')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('d/b/c')).to.be.falsy
|
||||
})
|
||||
|
||||
it('allows paths that differ by case', function () {
|
||||
const fileMap = makeFileMap(['a/b/c'])
|
||||
expect(fileMap.wouldConflict('a/b/C')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/b')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('a/B')).to.be.falsy
|
||||
expect(fileMap.wouldConflict('A/B')).to.be.falsy
|
||||
})
|
||||
|
||||
it('does not add a file with a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
const file = makeTestFile('a/b/c')
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('a/b/c', file)
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not move a file to a conflicting path', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('errors when trying to move a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.moveFile('b', 'a')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('moves a file over an empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b'])
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('a').getContent()).to.equal('a/b')
|
||||
})
|
||||
|
||||
it('does not move a file over a non-empty folder', function () {
|
||||
const fileMap = makeFileMap(['a/b', 'a/c'])
|
||||
expect(() => {
|
||||
fileMap.moveFile('a/b', 'a')
|
||||
}).to.throw(FileMap.PathnameConflictError)
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on add', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.addFile('A', makeTestFile('A'))
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('A')
|
||||
})
|
||||
|
||||
it('changes case on move', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
fileMap.moveFile('a', 'A')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.A).to.exist
|
||||
expect(fileMap.getFile('A').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not overwrite filename that differs by case on move', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.moveFile('a', 'B')
|
||||
expect(fileMap.countFiles()).to.equal(2)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
expect(fileMap.files.B).to.exist
|
||||
expect(fileMap.getFile('B').getContent()).to.equal('a')
|
||||
})
|
||||
|
||||
it('does not find pathname that differs by case', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(fileMap.getFile('a')).to.exist
|
||||
expect(fileMap.getFile('A')).not.to.exist
|
||||
expect(fileMap.getFile('b')).not.to.exist
|
||||
})
|
||||
|
||||
it('does not allow non-safe pathnames', function () {
|
||||
expect(() => {
|
||||
makeFileMap(['c*'])
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
const fileMap = makeFileMap([])
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('c*', makeTestFile('c:'))
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
fileMap.addFile('a', makeTestFile('a'))
|
||||
expect(() => {
|
||||
fileMap.moveFile('a', 'c*')
|
||||
}).to.throw(FileMap.BadPathnameError)
|
||||
|
||||
expect(() => {
|
||||
fileMap.addFile('hasOwnProperty', makeTestFile('hasOwnProperty'))
|
||||
fileMap.addFile('anotherFile', makeTestFile('anotherFile'))
|
||||
}).to.throw()
|
||||
})
|
||||
|
||||
it('removes a file', function () {
|
||||
const fileMap = makeFileMap(['a', 'b'])
|
||||
fileMap.removeFile('a')
|
||||
expect(fileMap.countFiles()).to.equal(1)
|
||||
expect(fileMap.files.a).not.to.exist
|
||||
expect(fileMap.files.b).to.exist
|
||||
})
|
||||
|
||||
it('errors when trying to remove a non-existent file', function () {
|
||||
const fileMap = makeFileMap(['a'])
|
||||
expect(() => fileMap.removeFile('b')).to.throw(FileMap.FileNotFoundError)
|
||||
})
|
||||
|
||||
it('has mapAsync', function () {
|
||||
const concurrency = 1
|
||||
return BPromise.map(
|
||||
[
|
||||
[[], {}],
|
||||
[['a'], { a: 'a-a' }], // the test is to map to "content-pathname"
|
||||
[['a', 'b'], { a: 'a-a', b: 'b-b' }],
|
||||
],
|
||||
test => {
|
||||
const input = test[0]
|
||||
const expectedOutput = test[1]
|
||||
const fileMap = makeFileMap(input)
|
||||
return fileMap
|
||||
.mapAsync((file, pathname) => {
|
||||
return file.getContent() + '-' + pathname
|
||||
}, concurrency)
|
||||
.then(result => {
|
||||
expect(result).to.deep.equal(expectedOutput)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const core = require('..')
|
||||
const Change = core.Change
|
||||
const File = core.File
|
||||
const History = core.History
|
||||
const Operation = core.Operation
|
||||
const Snapshot = core.Snapshot
|
||||
|
||||
describe('History', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from snapshot and changes', function () {
|
||||
const history = new History(new Snapshot(), [])
|
||||
|
||||
const blobHashes = new Set()
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash to the snapshot.
|
||||
history.getSnapshot().addFile('foo', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
|
||||
// Add a file with a hash to the changes.
|
||||
const testHash = 'a'.repeat(40)
|
||||
const change = Change.fromRaw({
|
||||
operations: [],
|
||||
timestamp: '2015-03-05T12:03:53.035Z',
|
||||
authors: [null],
|
||||
})
|
||||
change.pushOperation(Operation.addFile('bar', File.fromHash(testHash)))
|
||||
|
||||
history.pushChanges([change])
|
||||
history.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([
|
||||
File.EMPTY_FILE_HASH,
|
||||
testHash,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const HollowStringFileData = require('../lib/file_data/hollow_string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('HollowStringFileData', function () {
|
||||
it('validates string length when edited', function () {
|
||||
const maxLength = TextOperation.MAX_STRING_LENGTH
|
||||
const fileData = new HollowStringFileData(maxLength)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(maxLength).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getStringLength()).to.equal(maxLength)
|
||||
|
||||
fileData.edit(new TextOperation().retain(maxLength - 1).remove(1))
|
||||
expect(fileData.getStringLength()).to.equal(maxLength - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const Label = ot.Label
|
||||
|
||||
describe('Label', function () {
|
||||
it('can be created by an anonymous author', function () {
|
||||
const label = Label.fromRaw({
|
||||
text: 'test',
|
||||
authorId: null,
|
||||
timestamp: '2016-01-01T00:00:00Z',
|
||||
version: 123,
|
||||
})
|
||||
expect(label.getAuthorId()).to.be.null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const TextOperation = ot.TextOperation
|
||||
const LazyStringFileData = require('../lib/file_data/lazy_string_file_data')
|
||||
|
||||
describe('LazyStringFileData', function () {
|
||||
it('uses raw text operations for toRaw and fromRaw', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
let roundTripFileData
|
||||
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 0,
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).to.equal(testHash)
|
||||
expect(roundTripFileData.getStringLength()).to.equal(0)
|
||||
expect(roundTripFileData.getTextOperations()).to.have.length(0)
|
||||
|
||||
fileData.edit(new TextOperation().insert('a'))
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 1,
|
||||
textOperations: [['a']],
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).not.to.exist // file has changed
|
||||
expect(roundTripFileData.getStringLength()).to.equal(1)
|
||||
expect(roundTripFileData.getTextOperations()).to.have.length(1)
|
||||
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
|
||||
|
||||
fileData.edit(new TextOperation().retain(1).insert('b'))
|
||||
expect(fileData.toRaw()).to.eql({
|
||||
hash: testHash,
|
||||
stringLength: 2,
|
||||
textOperations: [['a'], [1, 'b']],
|
||||
})
|
||||
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
|
||||
expect(roundTripFileData.getHash()).not.to.exist // file has changed
|
||||
expect(roundTripFileData.getStringLength()).to.equal(2)
|
||||
expect(roundTripFileData.getTextOperations()).to.have.length(2)
|
||||
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
|
||||
expect(roundTripFileData.getTextOperations()[1].ops).to.have.length(2)
|
||||
})
|
||||
|
||||
it('validates operations when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
expect(fileData.getHash()).equal(testHash)
|
||||
expect(fileData.getByteLength()).to.equal(0) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(0)
|
||||
expect(fileData.getTextOperations()).to.have.length(0)
|
||||
|
||||
fileData.edit(new TextOperation().insert('a'))
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(1) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(1)
|
||||
expect(fileData.getTextOperations()).to.have.length(1)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(10))
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(1) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(1)
|
||||
expect(fileData.getTextOperations()).to.have.length(1)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const testHash = File.EMPTY_FILE_HASH
|
||||
const fileData = new LazyStringFileData(testHash, 0)
|
||||
expect(fileData.getHash()).equal(testHash)
|
||||
expect(fileData.getByteLength()).to.equal(0) // approximately
|
||||
expect(fileData.getStringLength()).to.equal(0)
|
||||
expect(fileData.getTextOperations()).to.have.length(0)
|
||||
|
||||
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
|
||||
fileData.edit(new TextOperation().insert(longString))
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
expect(fileData.getTextOperations()).to.have.length(1)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getHash()).not.to.exist
|
||||
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
expect(fileData.getTextOperations()).to.have.length(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const MoveFileOperation = ot.MoveFileOperation
|
||||
const Snapshot = ot.Snapshot
|
||||
|
||||
describe('MoveFileOperation', function () {
|
||||
function makeEmptySnapshot() {
|
||||
return new Snapshot()
|
||||
}
|
||||
|
||||
function makeOneFileSnapshot() {
|
||||
const snapshot = makeEmptySnapshot()
|
||||
snapshot.addFile('foo', File.fromString('test: foo'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function makeTwoFileSnapshot() {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
snapshot.addFile('bar', File.fromString('test: bar'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
it('moves a file over another', function () {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'bar')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(1)
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: foo')
|
||||
})
|
||||
|
||||
it('moves a file to another pathname', function () {
|
||||
const snapshot = makeTwoFileSnapshot()
|
||||
const operation = new MoveFileOperation('foo', 'a')
|
||||
operation.applyTo(snapshot)
|
||||
expect(snapshot.countFiles()).to.equal(2)
|
||||
expect(snapshot.getFile('a').getContent()).to.equal('test: foo')
|
||||
expect(snapshot.getFile('bar').getContent()).to.equal('test: bar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,746 @@
|
||||
'use strict'
|
||||
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const ot = require('..')
|
||||
const File = ot.File
|
||||
const AddFileOperation = ot.AddFileOperation
|
||||
const MoveFileOperation = ot.MoveFileOperation
|
||||
const EditFileOperation = ot.EditFileOperation
|
||||
const NoOperation = ot.NoOperation
|
||||
const Operation = ot.Operation
|
||||
const TextOperation = ot.TextOperation
|
||||
const Snapshot = ot.Snapshot
|
||||
|
||||
describe('Operation', function () {
|
||||
function makeEmptySnapshot() {
|
||||
return new Snapshot()
|
||||
}
|
||||
|
||||
function makeOneFileSnapshot() {
|
||||
const snapshot = makeEmptySnapshot()
|
||||
snapshot.addFile('foo', File.fromString(''))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function makeTwoFileSnapshot() {
|
||||
const snapshot = makeOneFileSnapshot()
|
||||
snapshot.addFile('bar', File.fromString('a'))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function addFile(pathname, content) {
|
||||
return new AddFileOperation(pathname, File.fromString(content))
|
||||
}
|
||||
|
||||
function roundTripOperation(operation) {
|
||||
return Operation.fromRaw(operation.toRaw())
|
||||
}
|
||||
|
||||
function deepCopySnapshot(snapshot) {
|
||||
return Snapshot.fromRaw(snapshot.toRaw())
|
||||
}
|
||||
|
||||
function runConcurrently(operation0, operation1, snapshot) {
|
||||
const operations = [
|
||||
// make sure they survive serialization
|
||||
roundTripOperation(operation0),
|
||||
roundTripOperation(operation1),
|
||||
]
|
||||
const primeOperations = Operation.transform(operation0, operation1)
|
||||
const originalSnapshot = snapshot || makeEmptySnapshot()
|
||||
const snapshotA = deepCopySnapshot(originalSnapshot)
|
||||
const snapshotB = deepCopySnapshot(originalSnapshot)
|
||||
|
||||
operations[0].applyTo(snapshotA)
|
||||
operations[1].applyTo(snapshotB)
|
||||
|
||||
primeOperations[0].applyTo(snapshotB)
|
||||
primeOperations[1].applyTo(snapshotA)
|
||||
expect(snapshotA).to.eql(snapshotB)
|
||||
|
||||
return {
|
||||
snapshot: snapshotA,
|
||||
operations: operations,
|
||||
primeOperations: primeOperations,
|
||||
|
||||
log() {
|
||||
console.log(this)
|
||||
return this
|
||||
},
|
||||
|
||||
expectNoTransform() {
|
||||
expect(this.operations).to.eql(this.primeOperations)
|
||||
return this
|
||||
},
|
||||
|
||||
swap() {
|
||||
return runConcurrently(operation1, operation0, originalSnapshot)
|
||||
},
|
||||
|
||||
expectFiles(files) {
|
||||
this.expectedFiles = files
|
||||
expect(this.snapshot.countFiles()).to.equal(_.size(files))
|
||||
_.forOwn(files, (expectedFile, pathname) => {
|
||||
if (_.isString(expectedFile)) {
|
||||
expectedFile = { content: expectedFile, metadata: {} }
|
||||
}
|
||||
const file = this.snapshot.getFile(pathname)
|
||||
expect(file.getContent()).to.equal(expectedFile.content)
|
||||
expect(file.getMetadata()).to.eql(expectedFile.metadata)
|
||||
})
|
||||
return this
|
||||
},
|
||||
|
||||
expectSymmetry() {
|
||||
if (!this.expectedFiles) {
|
||||
throw new Error('must call expectFiles before expectSymmetry')
|
||||
}
|
||||
this.swap().expectFiles(this.expectedFiles)
|
||||
return this
|
||||
},
|
||||
|
||||
expectPrime(index, klass) {
|
||||
expect(this.primeOperations[index]).to.be.an.instanceof(klass)
|
||||
return this
|
||||
},
|
||||
|
||||
tap(fn) {
|
||||
fn.call(this)
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// shorthand for creating an edit operation
|
||||
function edit(pathname, textOperationJsonObject) {
|
||||
return Operation.editFile(
|
||||
pathname,
|
||||
TextOperation.fromJSON(textOperationJsonObject)
|
||||
)
|
||||
}
|
||||
|
||||
it('transforms AddFile-AddFile with different names', function () {
|
||||
runConcurrently(addFile('foo', ''), addFile('bar', 'a'))
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', foo: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-AddFile with same name', function () {
|
||||
// the second file 'wins'
|
||||
runConcurrently(addFile('foo', ''), addFile('foo', 'a'))
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the first add was committed first, the second add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the second add was committed first, the first add becomes a no-op
|
||||
.expectPrime(0, NoOperation)
|
||||
.swap()
|
||||
.expectFiles({ foo: '' })
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', baz: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with move from new file', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move appears in the history
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-MoveFile with move to new file', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
addFile('baz', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move becomes a delete
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-RemoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-RemoveFile that removes added file', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the move was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the move gets dropped
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
addFile('bar', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: 'a', foo: 'x' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-EditFile when new file is edited', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
addFile('foo', 'a'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
// if the edit was committed first, the add overwrites it
|
||||
.expectPrime(1, AddFileOperation)
|
||||
// if the add was committed first, the edit gets dropped
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
addFile('bar', 'a'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata }, bar: 'a' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms AddFile-SetFileMetadata with same name', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
addFile('foo', 'x'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeEmptySnapshot()
|
||||
)
|
||||
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'bat'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bat: 'a', baz: '' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile same move foo->foo, foo->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, foo->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('bar', 'bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'a', foo: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile same move foo->bar, foo->bar', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: '' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile opposite foo->bar, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile no-op foo->foo, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a', foo: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile diverge foo->bar, foo->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: '' })
|
||||
// if foo->bar was committed first, the second move becomes bar->baz
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
// if foo->baz was committed first, the second move becomes a no-op
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
expect(this.primeOperations[1].getNewPathname()).to.equal('baz')
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ bar: '' })
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile transitive foo->baz, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile transitive foo->bar, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-MoveFile converge foo->baz, bar->baz', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
// if foo->baz was committed first, we just apply the move
|
||||
expect(this.primeOperations[1]).to.eql(this.operations[1])
|
||||
|
||||
// if bar->baz was committed first, the other move becomes a remove
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ baz: '' })
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no-op foo->foo, foo->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile same move foo->, foo->', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, NoOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no conflict foo->, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile no conflict foo->foo, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: '' })
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile transitive foo->, bar->foo', function () {
|
||||
runConcurrently(
|
||||
Operation.removeFile('foo'),
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles([])
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-RemoveFile transitive foo->bar, bar->', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectPrime(1, MoveFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].isRemoveFile()).to.be.true
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('bar', 'baz'),
|
||||
edit('foo', ['x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ baz: 'a', foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with edit on pathname', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
edit('foo', ['x']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'x' })
|
||||
.expectPrime(1, EditFileOperation)
|
||||
.expectPrime(0, MoveFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].getPathname()).to.equal('bar')
|
||||
|
||||
expect(this.primeOperations[0].getPathname()).to.equal('foo')
|
||||
expect(this.primeOperations[0].getNewPathname()).to.equal('bar')
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with edit on new pathname', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('bar', 'foo'),
|
||||
edit('foo', ['x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'a' })
|
||||
.expectPrime(1, NoOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[0]).to.eql(this.operations[0])
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-EditFile with no-op move', function () {
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
edit('foo', ['x']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'baz'),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ bar: { content: 'a', metadata: testMetadata }, baz: '' })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with set on pathname', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata w/ set on new pathname', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'bar'),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
// move wins
|
||||
.expectFiles({ bar: { content: '', metadata: {} } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms MoveFile-SetFileMetadata with no-op move', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.moveFile('foo', 'foo'),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-EditFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
edit('bar', [1, 'x']),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ bar: 'ax', foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-EditFile on same file', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
edit('foo', ['y']),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'xy' })
|
||||
.expectPrime(1, EditFileOperation)
|
||||
.expectPrime(0, EditFileOperation)
|
||||
.tap(function () {
|
||||
expect(this.primeOperations[1].getTextOperation().toJSON()).to.eql([
|
||||
1,
|
||||
'y',
|
||||
])
|
||||
expect(this.primeOperations[0].getTextOperation().toJSON()).to.eql([
|
||||
'x',
|
||||
1,
|
||||
])
|
||||
})
|
||||
.swap()
|
||||
.expectFiles({ foo: 'yx' })
|
||||
})
|
||||
|
||||
it('transforms EditFile-RemoveFile with no conflict', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectFiles({ foo: 'x' })
|
||||
.expectNoTransform()
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-RemoveFile on same file', function () {
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-SetFileMetadata with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.setFileMetadata('bar', testMetadata),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({
|
||||
foo: { content: 'x', metadata: {} },
|
||||
bar: { content: 'a', metadata: testMetadata },
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms EditFile-SetFileMetadata on same file', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
edit('foo', ['x']),
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-SetFileMetadata w/ no conflict', function () {
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', { baz: 1 }),
|
||||
Operation.setFileMetadata('bar', { baz: 2 }),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({
|
||||
foo: { content: '', metadata: { baz: 1 } },
|
||||
bar: { content: 'a', metadata: { baz: 2 } },
|
||||
})
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-SetFileMetadata on same file', function () {
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', { baz: 1 }),
|
||||
Operation.setFileMetadata('foo', { baz: 2 }),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
// second op wins
|
||||
.expectFiles({ foo: { content: '', metadata: { baz: 2 } } })
|
||||
.swap()
|
||||
// first op wins
|
||||
.expectFiles({ foo: { content: '', metadata: { baz: 1 } } })
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-RemoveFile with no conflict', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
Operation.removeFile('bar'),
|
||||
makeTwoFileSnapshot()
|
||||
)
|
||||
.expectNoTransform()
|
||||
.expectFiles({ foo: { content: '', metadata: testMetadata } })
|
||||
.expectSymmetry()
|
||||
})
|
||||
|
||||
it('transforms SetFileMetadata-RemoveFile on same file', function () {
|
||||
const testMetadata = { baz: 1 }
|
||||
runConcurrently(
|
||||
Operation.setFileMetadata('foo', testMetadata),
|
||||
Operation.removeFile('foo'),
|
||||
makeOneFileSnapshot()
|
||||
)
|
||||
.expectFiles({})
|
||||
.expectSymmetry()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const ot = require('..')
|
||||
const safePathname = ot.safePathname
|
||||
|
||||
describe('safePathname', function () {
|
||||
function expectClean(input, output) {
|
||||
// check expected output and also idempotency
|
||||
const cleanedInput = safePathname.clean(input)
|
||||
expect(cleanedInput).to.equal(output)
|
||||
expect(safePathname.clean(cleanedInput)).to.equal(cleanedInput)
|
||||
expect(safePathname.isClean(cleanedInput)).to.be.true
|
||||
}
|
||||
|
||||
it('cleans pathnames', function () {
|
||||
// preserve valid pathnames
|
||||
expectClean('llama.jpg', 'llama.jpg')
|
||||
expectClean('DSC4056.JPG', 'DSC4056.JPG')
|
||||
|
||||
// detects unclean pathnames
|
||||
expect(safePathname.isClean('rm -rf /')).to.be.falsy
|
||||
|
||||
// replace invalid characters with underscores
|
||||
expectClean('test-s*\u0001\u0002m\u0007st\u0008.jpg', 'test-s___m_st_.jpg')
|
||||
|
||||
// keep slashes, normalize paths, replace ..
|
||||
expectClean('./foo', 'foo')
|
||||
expectClean('../foo', '__/foo')
|
||||
expectClean('foo/./bar', 'foo/bar')
|
||||
expectClean('foo/../bar', 'bar')
|
||||
expectClean('../../tricky/foo.bar', '__/__/tricky/foo.bar')
|
||||
expectClean('foo/../../tricky/foo.bar', '__/tricky/foo.bar')
|
||||
expectClean('foo/bar/../../tricky/foo.bar', 'tricky/foo.bar')
|
||||
expectClean('foo/bar/baz/../../tricky/foo.bar', 'foo/tricky/foo.bar')
|
||||
|
||||
// remove illegal chars even when there is no extension
|
||||
expectClean('**foo', '__foo')
|
||||
|
||||
// remove windows file paths
|
||||
expectClean('c:\\temp\\foo.txt', 'c:/temp/foo.txt')
|
||||
|
||||
// do not allow a leading slash (relative paths only)
|
||||
expectClean('/foo', '_/foo')
|
||||
expectClean('//foo', '_/foo')
|
||||
|
||||
// do not allow multiple leading slashes
|
||||
expectClean('//foo', '_/foo')
|
||||
|
||||
// do not allow a trailing slash
|
||||
expectClean('/', '_')
|
||||
expectClean('foo/', 'foo')
|
||||
expectClean('foo.tex/', 'foo.tex')
|
||||
|
||||
// do not allow multiple trailing slashes
|
||||
expectClean('//', '_')
|
||||
expectClean('///', '_')
|
||||
expectClean('foo//', 'foo')
|
||||
|
||||
// file and folder names that consist of . and .. are not OK
|
||||
expectClean('.', '_')
|
||||
expectClean('..', '__')
|
||||
// we will allow name with more dots e.g. ... and ....
|
||||
expectClean('...', '...')
|
||||
expectClean('....', '....')
|
||||
expectClean('foo/...', 'foo/...')
|
||||
expectClean('foo/....', 'foo/....')
|
||||
expectClean('foo/.../bar', 'foo/.../bar')
|
||||
expectClean('foo/..../bar', 'foo/..../bar')
|
||||
|
||||
// leading dots are OK
|
||||
expectClean('._', '._')
|
||||
expectClean('.gitignore', '.gitignore')
|
||||
|
||||
// trailing dots are not OK on Windows but we allow them
|
||||
expectClean('_.', '_.')
|
||||
expectClean('foo/_.', 'foo/_.')
|
||||
expectClean('foo/_./bar', 'foo/_./bar')
|
||||
expectClean('foo/_../bar', 'foo/_../bar')
|
||||
|
||||
// spaces are allowed
|
||||
expectClean('a b.png', 'a b.png')
|
||||
|
||||
// leading and trailing spaces are not OK
|
||||
expectClean(' foo', 'foo')
|
||||
expectClean(' foo', 'foo')
|
||||
expectClean('foo ', 'foo')
|
||||
expectClean('foo ', 'foo')
|
||||
|
||||
// reserved file names on Windows should not be OK, but we already have
|
||||
// some in the old system, so have to allow them for now
|
||||
expectClean('AUX', 'AUX')
|
||||
expectClean('foo/AUX', 'foo/AUX')
|
||||
expectClean('AUX/foo', 'AUX/foo')
|
||||
|
||||
// multiple dots are OK
|
||||
expectClean('a.b.png', 'a.b.png')
|
||||
expectClean('a.code.tex', 'a.code.tex')
|
||||
|
||||
// there's no particular reason to allow multiple slashes; sometimes people
|
||||
// seem to rename files to URLs (https://domain/path) in an attempt to
|
||||
// upload a file, and this results in an empty directory name
|
||||
expectClean('foo//bar.png', 'foo/bar.png')
|
||||
expectClean('foo///bar.png', 'foo/bar.png')
|
||||
|
||||
// Check javascript property handling
|
||||
expectClean('foo/prototype', 'foo/prototype') // OK as part of a pathname
|
||||
expectClean('prototype/test.txt', 'prototype/test.txt')
|
||||
expectClean('prototype', '@prototype') // not OK as whole pathname
|
||||
expectClean('hasOwnProperty', '@hasOwnProperty')
|
||||
expectClean('**proto**', '@__proto__')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
File,
|
||||
Snapshot,
|
||||
TextOperation,
|
||||
Change,
|
||||
EditFileOperation,
|
||||
} = require('..')
|
||||
|
||||
describe('Snapshot', function () {
|
||||
describe('findBlobHashes', function () {
|
||||
it('finds blob hashes from files', function () {
|
||||
const snapshot = new Snapshot()
|
||||
|
||||
const blobHashes = new Set()
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file without a hash.
|
||||
snapshot.addFile('foo', File.fromString(''))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(blobHashes.size).to.equal(0)
|
||||
|
||||
// Add a file with a hash.
|
||||
snapshot.addFile('bar', File.fromHash(File.EMPTY_FILE_HASH))
|
||||
snapshot.findBlobHashes(blobHashes)
|
||||
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
|
||||
})
|
||||
})
|
||||
|
||||
describe('editFile', function () {
|
||||
let snapshot
|
||||
let operation
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('hello.txt', File.fromString('hello'))
|
||||
operation = new TextOperation()
|
||||
operation.retain(5)
|
||||
operation.insert(' world!')
|
||||
})
|
||||
|
||||
it('applies text operations to the file', function () {
|
||||
snapshot.editFile('hello.txt', operation)
|
||||
const file = snapshot.getFile('hello.txt')
|
||||
expect(file.getContent()).to.equal('hello world!')
|
||||
})
|
||||
|
||||
it('rejects text operations for nonexistent file', function () {
|
||||
expect(() => {
|
||||
snapshot.editFile('does-not-exist.txt', operation)
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAll', function () {
|
||||
let snapshot
|
||||
let change
|
||||
|
||||
beforeEach(function () {
|
||||
snapshot = new Snapshot()
|
||||
snapshot.addFile('empty.txt', File.fromString(''))
|
||||
const badTextOp = new TextOperation()
|
||||
badTextOp.insert('FAIL!')
|
||||
const goodTextOp = new TextOperation()
|
||||
goodTextOp.insert('SUCCESS!')
|
||||
change = new Change(
|
||||
[
|
||||
new EditFileOperation('missing.txt', badTextOp),
|
||||
new EditFileOperation('empty.txt', goodTextOp),
|
||||
],
|
||||
new Date()
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores recoverable errors', function () {
|
||||
snapshot.applyAll([change])
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('SUCCESS!')
|
||||
})
|
||||
|
||||
it('stops on recoverable errors in strict mode', function () {
|
||||
expect(() => {
|
||||
snapshot.applyAll([change], { strict: true })
|
||||
}).to.throw(Snapshot.EditMissingFileError)
|
||||
const file = snapshot.getFile('empty.txt')
|
||||
expect(file.getContent()).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const _ = require('lodash')
|
||||
|
||||
const ot = require('..')
|
||||
const StringFileData = require('../lib/file_data/string_file_data')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
describe('StringFileData', function () {
|
||||
it('throws when it contains non BMP chars', function () {
|
||||
const content = '𝌆𝌆𝌆'
|
||||
const fileData = new StringFileData(content)
|
||||
const operation = new TextOperation()
|
||||
operation.insert('aa')
|
||||
expect(() => {
|
||||
fileData.edit(operation)
|
||||
}).to.throw(TextOperation.ApplyError, /string contains non BMP characters/)
|
||||
})
|
||||
|
||||
it('validates string length when edited', function () {
|
||||
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
|
||||
const fileData = new StringFileData(longString)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
expect(() => {
|
||||
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
|
||||
}).to.throw(TextOperation.TooLongError)
|
||||
expect(fileData.getByteLength()).to.equal(longString.length)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length)
|
||||
|
||||
fileData.edit(new TextOperation().retain(longString.length - 1).remove(1))
|
||||
expect(fileData.getByteLength()).to.equal(longString.length - 1)
|
||||
expect(fileData.getStringLength()).to.equal(longString.length - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @typedef {import("../..").Blob } Blob
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("bluebird")<T>} BPromise
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fake blob store for tests
|
||||
*/
|
||||
class FakeBlobStore {
|
||||
/**
|
||||
* Get a string from the blob store
|
||||
*
|
||||
* @param {string} hash
|
||||
* @return {BPromise<string>}
|
||||
*/
|
||||
getString(hash) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a string in the blob store
|
||||
*
|
||||
* @param {string} content
|
||||
* @return {BPromise<Blob>}
|
||||
*/
|
||||
putString(content) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FakeBlobStore
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Randomised testing helpers from OT.js:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/helpers.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
function randomInt(n) {
|
||||
return Math.floor(Math.random() * n)
|
||||
}
|
||||
|
||||
function randomString(n) {
|
||||
let str = ''
|
||||
while (n--) {
|
||||
if (Math.random() < 0.15) {
|
||||
str += '\n'
|
||||
} else {
|
||||
const chr = randomInt(26) + 97
|
||||
str += String.fromCharCode(chr)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
function randomElement(arr) {
|
||||
return arr[randomInt(arr.length)]
|
||||
}
|
||||
|
||||
function randomTest(numTrials, test) {
|
||||
return function () {
|
||||
while (numTrials--) test()
|
||||
}
|
||||
}
|
||||
|
||||
exports.int = randomInt
|
||||
exports.string = randomString
|
||||
exports.element = randomElement
|
||||
exports.test = randomTest
|
||||
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// These tests are based on the OT.js tests:
|
||||
// https://github.com/Operational-Transformation/ot.js/blob/
|
||||
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js
|
||||
//
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const random = require('./support/random')
|
||||
|
||||
const ot = require('..')
|
||||
const TextOperation = ot.TextOperation
|
||||
|
||||
function randomOperation(str) {
|
||||
const operation = new TextOperation()
|
||||
let left
|
||||
while (true) {
|
||||
left = str.length - operation.baseLength
|
||||
if (left === 0) break
|
||||
const r = Math.random()
|
||||
const l = 1 + random.int(Math.min(left - 1, 20))
|
||||
if (r < 0.2) {
|
||||
operation.insert(random.string(l))
|
||||
} else if (r < 0.4) {
|
||||
operation.remove(l)
|
||||
} else {
|
||||
operation.retain(l)
|
||||
}
|
||||
}
|
||||
if (Math.random() < 0.3) {
|
||||
operation.insert(1 + random.string(10))
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
describe('TextOperation', function () {
|
||||
const numTrials = 500
|
||||
|
||||
it('tracks base and target lengths', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.baseLength).to.equal(0)
|
||||
expect(o.targetLength).to.equal(0)
|
||||
o.retain(5)
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
o.insert('abc')
|
||||
expect(o.baseLength).to.equal(5)
|
||||
expect(o.targetLength).to.equal(8)
|
||||
o.retain(2)
|
||||
expect(o.baseLength).to.equal(7)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
o.remove(2)
|
||||
expect(o.baseLength).to.equal(9)
|
||||
expect(o.targetLength).to.equal(10)
|
||||
})
|
||||
|
||||
it('supports chaining', function () {
|
||||
const o = new TextOperation()
|
||||
.retain(5)
|
||||
.retain(0)
|
||||
.insert('lorem')
|
||||
.insert('')
|
||||
.remove('abc')
|
||||
.remove(3)
|
||||
.remove(0)
|
||||
.remove('')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
})
|
||||
|
||||
it('ignores empty operations', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(0)
|
||||
o.insert('')
|
||||
o.remove('')
|
||||
expect(o.ops.length).to.equal(0)
|
||||
})
|
||||
|
||||
it('checks for equality', function () {
|
||||
const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
|
||||
const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
|
||||
expect(op1.equals(op2)).to.be.true
|
||||
op1.remove(1)
|
||||
op2.retain(1)
|
||||
expect(op1.equals(op2)).to.be.false
|
||||
})
|
||||
|
||||
it('merges ops', function () {
|
||||
function last(arr) {
|
||||
return arr[arr.length - 1]
|
||||
}
|
||||
const o = new TextOperation()
|
||||
expect(o.ops.length).to.equal(0)
|
||||
o.retain(2)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops)).to.equal(2)
|
||||
o.retain(3)
|
||||
expect(o.ops.length).to.equal(1)
|
||||
expect(last(o.ops)).to.equal(5)
|
||||
o.insert('abc')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops)).to.equal('abc')
|
||||
o.insert('xyz')
|
||||
expect(o.ops.length).to.equal(2)
|
||||
expect(last(o.ops)).to.equal('abcxyz')
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops)).to.equal(-1)
|
||||
o.remove('d')
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(last(o.ops)).to.equal(-2)
|
||||
})
|
||||
|
||||
it('checks for no-ops', function () {
|
||||
const o = new TextOperation()
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(5)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.retain(3)
|
||||
expect(o.isNoop()).to.be.true
|
||||
o.insert('lorem')
|
||||
expect(o.isNoop()).to.be.false
|
||||
})
|
||||
|
||||
it('converts to string', function () {
|
||||
const o = new TextOperation()
|
||||
o.retain(2)
|
||||
o.insert('lorem')
|
||||
o.remove('ipsum')
|
||||
o.retain(5)
|
||||
expect(o.toString()).to.equal(
|
||||
"retain 2, insert 'lorem', remove 5, retain 5"
|
||||
)
|
||||
})
|
||||
|
||||
it('converts from JSON', function () {
|
||||
const ops = [2, -1, -1, 'cde']
|
||||
const o = TextOperation.fromJSON(ops)
|
||||
expect(o.ops.length).to.equal(3)
|
||||
expect(o.baseLength).to.equal(4)
|
||||
expect(o.targetLength).to.equal(5)
|
||||
|
||||
function assertIncorrectAfter(fn) {
|
||||
const ops2 = ops.slice(0)
|
||||
fn(ops2)
|
||||
expect(() => {
|
||||
TextOperation.fromJSON(ops2)
|
||||
}).to.throw
|
||||
}
|
||||
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push({ insert: 'x' })
|
||||
})
|
||||
assertIncorrectAfter(ops2 => {
|
||||
ops2.push(null)
|
||||
})
|
||||
})
|
||||
|
||||
it(
|
||||
'applies (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const str = random.string(50)
|
||||
const o = randomOperation(str)
|
||||
expect(str.length).to.equal(o.baseLength)
|
||||
expect(o.apply(str).length).to.equal(o.targetLength)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'inverts (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const str = random.string(50)
|
||||
const o = randomOperation(str)
|
||||
const p = o.invert(str)
|
||||
expect(o.baseLength).to.equal(p.targetLength)
|
||||
expect(o.targetLength).to.equal(p.baseLength)
|
||||
expect(p.apply(o.apply(str))).to.equal(str)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'converts to/from JSON (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
const doc = random.string(50)
|
||||
const operation = randomOperation(doc)
|
||||
const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
|
||||
expect(operation.equals(roundTripOperation)).to.be.true
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'composes (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
|
||||
const str = random.string(20)
|
||||
const a = randomOperation(str)
|
||||
const afterA = a.apply(str)
|
||||
expect(afterA.length).to.equal(a.targetLength)
|
||||
const b = randomOperation(afterA)
|
||||
const afterB = b.apply(afterA)
|
||||
expect(afterB.length).to.equal(b.targetLength)
|
||||
const ab = a.compose(b)
|
||||
expect(ab.targetLength).to.equal(b.targetLength)
|
||||
const afterAB = ab.apply(str)
|
||||
expect(afterAB).to.equal(afterB)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'transforms (randomised)',
|
||||
random.test(numTrials, () => {
|
||||
// invariant: compose(a, b') = compose(b, a')
|
||||
// where (a', b') = transform(a, b)
|
||||
const str = random.string(20)
|
||||
const a = randomOperation(str)
|
||||
const b = randomOperation(str)
|
||||
const primes = TextOperation.transform(a, b)
|
||||
const aPrime = primes[0]
|
||||
const bPrime = primes[1]
|
||||
const abPrime = a.compose(bPrime)
|
||||
const baPrime = b.compose(aPrime)
|
||||
const afterAbPrime = abPrime.apply(str)
|
||||
const afterBaPrime = baPrime.apply(str)
|
||||
expect(abPrime.equals(baPrime)).to.be.true
|
||||
expect(afterAbPrime).to.equal(afterBaPrime)
|
||||
})
|
||||
)
|
||||
|
||||
it('throws when invalid operations are applied', function () {
|
||||
const operation = new TextOperation().retain(1)
|
||||
expect(() => {
|
||||
operation.apply('')
|
||||
}).to.throw(TextOperation.ApplyError)
|
||||
expect(() => {
|
||||
operation.apply(' ')
|
||||
}).not.to.throw
|
||||
})
|
||||
|
||||
it('throws when insert text contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.insert(str)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when base string contains non BMP chars', function () {
|
||||
const operation = new TextOperation()
|
||||
const str = '𝌆\n'
|
||||
expect(() => {
|
||||
operation.apply(str)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/string contains non BMP characters/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws at from JSON when it contains non BMP chars', function () {
|
||||
const operation = ['𝌆\n']
|
||||
expect(() => {
|
||||
TextOperation.fromJSON(operation)
|
||||
}).to.throw(
|
||||
TextOperation.UnprocessableError,
|
||||
/inserted text contains non BMP characters/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["es2018"],
|
||||
"module": "commonjs",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["lib/**/*", "typings/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user