Avoid duplicating a math-closing dollar sign (#11227)

GitOrigin-RevId: ef2ef77e26df59d1af3df6dc664e284d3c70102d
This commit is contained in:
Alf Eaton
2023-01-13 12:42:29 +00:00
committed by Copybot
parent 377a8aed60
commit ee85d948e2
268 changed files with 57782 additions and 0 deletions
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,5 @@
/coverage
/node_modules
# managed by monorepo$ bin/update_build_scripts
.npmrc
+1
View File
@@ -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
+26
View File
@@ -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 }
+100
View File
@@ -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)
}
+166
View File
@@ -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
+241
View File
@@ -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/**/*"]
}