From d54bcc4aa9df979f91f1cd8428465490931dd876 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 22 Aug 2023 10:48:04 -0400 Subject: [PATCH] Merge pull request #14418 from overleaf/em-history-lib-es6-classes Move overleaf-editor-core code to ES6 classes GitOrigin-RevId: f9b50579aec0cef9d9e6aefcfcb3e380fae4b6f4 --- libraries/overleaf-editor-core/lib/author.js | 98 +- .../overleaf-editor-core/lib/author_list.js | 2 +- libraries/overleaf-editor-core/lib/blob.js | 172 +-- libraries/overleaf-editor-core/lib/change.js | 8 +- .../overleaf-editor-core/lib/change_note.js | 81 +- .../lib/change_request.js | 122 +- libraries/overleaf-editor-core/lib/chunk.js | 181 +-- .../lib/chunk_response.js | 43 +- libraries/overleaf-editor-core/lib/file.js | 377 +++-- .../lib/file_data/binary_file_data.js | 1 - .../lib/file_data/hollow_binary_file_data.js | 1 - .../lib/file_data/hollow_string_file_data.js | 1 - .../lib/file_data/index.js | 1 - .../lib/file_data/lazy_string_file_data.js | 1 - .../lib/file_data/string_file_data.js | 1 - .../overleaf-editor-core/lib/file_map.js | 76 +- libraries/overleaf-editor-core/lib/history.js | 213 +-- libraries/overleaf-editor-core/lib/label.js | 137 +- .../lib/operation/add_file_operation.js | 2 - .../lib/operation/edit_file_operation.js | 2 - .../lib/operation/index.js | 1 - .../lib/operation/move_file_operation.js | 2 +- .../lib/operation/no_operation.js | 1 - .../operation/set_file_metadata_operation.js | 2 - .../lib/operation/text_operation.js | 1229 +++++++++-------- .../overleaf-editor-core/lib/origin/index.js | 64 +- .../lib/origin/restore_origin.js | 2 - .../overleaf-editor-core/lib/ot_client.js | 403 +++--- .../overleaf-editor-core/lib/snapshot.js | 17 +- .../lib/v2_doc_versions.js | 74 +- libraries/overleaf-editor-core/tsconfig.json | 2 +- 31 files changed, 1676 insertions(+), 1641 deletions(-) diff --git a/libraries/overleaf-editor-core/lib/author.js b/libraries/overleaf-editor-core/lib/author.js index 7a49ac65f4..10d305f002 100644 --- a/libraries/overleaf-editor-core/lib/author.js +++ b/libraries/overleaf-editor-core/lib/author.js @@ -3,11 +3,6 @@ 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. @@ -16,55 +11,62 @@ const assert = require('check-types').assert * 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') +class Author { + /** + * @param {number} id + * @param {string} email + * @param {string} name + */ + constructor(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 -} + 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) -} + /** + * Create an Author from its raw form. + * + * @param {Object} [raw] + * @return {Author | null} + */ + static fromRaw(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 } -} + /** + * Convert the Author to raw form for storage or transmission. + * + * @return {Object} + */ + toRaw() { + return { id: this.id, email: this.email, name: this.name } + } -/** - * @return {number} - */ -Author.prototype.getId = function () { - return this.id -} + /** + * @return {number} + */ + getId() { + return this.id + } -/** - * @return {string} - */ -Author.prototype.getEmail = function () { - return this.email -} + /** + * @return {string} + */ + getEmail() { + return this.email + } -/** - * @return {string} - */ -Author.prototype.getName = function () { - return this.name + /** + * @return {string} + */ + getName() { + return this.name + } } module.exports = Author diff --git a/libraries/overleaf-editor-core/lib/author_list.js b/libraries/overleaf-editor-core/lib/author_list.js index 8dcc857183..f24c1f9d40 100644 --- a/libraries/overleaf-editor-core/lib/author_list.js +++ b/libraries/overleaf-editor-core/lib/author_list.js @@ -42,4 +42,4 @@ function assertV2(authors, msg) { }) } -module.exports = { assertV1: assertV1, assertV2: assertV2 } +module.exports = { assertV1, assertV2 } diff --git a/libraries/overleaf-editor-core/lib/blob.js b/libraries/overleaf-editor-core/lib/blob.js index 3b7947fdf5..8acd0b6dcd 100644 --- a/libraries/overleaf-editor-core/lib/blob.js +++ b/libraries/overleaf-editor-core/lib/blob.js @@ -5,96 +5,100 @@ 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) +/** + * Metadata record for the content of a file. + */ +class Blob { + static HEX_HASH_RX_STRING = '^[0-9a-f]{40,40}$' + static HEX_HASH_RX = new RegExp(Blob.HEX_HASH_RX_STRING) + + /** + * 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} + */ + static MAX_EDITABLE_BYTE_LENGTH_BOUND = 3 * TextOperation.MAX_STRING_LENGTH + + static NotFoundError = NotFoundError + + constructor(hash, byteLength, stringLength) { + this.setHash(hash) + this.setByteLength(byteLength) + this.setStringLength(stringLength) + } + + static fromRaw(raw) { + if (raw) { + return new Blob(raw.hash, raw.byteLength, raw.stringLength) + } + return null + } + + toRaw() { + return { + hash: this.hash, + byteLength: this.byteLength, + stringLength: this.stringLength, + } + } + + /** + * Hex hash. + * @return {?String} + */ + getHash() { + return this.hash + } + + setHash(hash) { + assert.maybe.match(hash, Blob.HEX_HASH_RX, 'bad hash') + this.hash = hash + } + + /** + * Length of the blob in bytes. + * @return {number} + */ + getByteLength() { + return this.byteLength + } + + setByteLength(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} + */ + getStringLength() { + return this.stringLength + } + + setStringLength(stringLength) { + assert.maybe.integer(stringLength, 'bad stringLength') + this.stringLength = stringLength + } +} 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 diff --git a/libraries/overleaf-editor-core/lib/change.js b/libraries/overleaf-editor-core/lib/change.js index 36bfcd3a05..b940f4e25f 100644 --- a/libraries/overleaf-editor-core/lib/change.js +++ b/libraries/overleaf-editor-core/lib/change.js @@ -17,13 +17,14 @@ const V2DocVersions = require('./v2_doc_versions') */ /** - * @classdesc * A Change is a list of {@link Operation}s applied atomically by given * {@link Author}(s) at a given time. */ class Change { + static PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$' + static PROJECT_VERSION_RX = new RegExp(Change.PROJECT_VERSION_RX_STRING) + /** - * @constructor * @param {Array.} operations * @param {Date} timestamp * @param {number[] | Author[]} [authors] @@ -327,7 +328,4 @@ class Change { } } -Change.PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$' -Change.PROJECT_VERSION_RX = new RegExp(Change.PROJECT_VERSION_RX_STRING) - module.exports = Change diff --git a/libraries/overleaf-editor-core/lib/change_note.js b/libraries/overleaf-editor-core/lib/change_note.js index b8d21ca0cf..94bd344130 100644 --- a/libraries/overleaf-editor-core/lib/change_note.js +++ b/libraries/overleaf-editor-core/lib/change_note.js @@ -5,56 +5,57 @@ 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') +class ChangeNote { + /** + * @param {number} baseVersion the new base version for the change + * @param {?Change} change + */ + constructor(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(), + this.baseVersion = baseVersion + this.change = change } -} -ChangeNote.prototype.toRawWithoutChange = - function changeNoteToRawWithoutChange() { + /** + * For serialization. + * + * @return {Object} + */ + toRaw() { + return { + baseVersion: this.baseVersion, + change: this.change.toRaw(), + } + } + + toRawWithoutChange() { return { baseVersion: this.baseVersion, } } -ChangeNote.fromRaw = function changeNoteFromRaw(raw) { - assert.integer(raw.baseVersion, 'bad raw.baseVersion') - assert.maybe.object(raw.change, 'bad raw.changes') + static fromRaw(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)) + return new ChangeNote(raw.baseVersion, Change.fromRaw(raw.change)) + } + + getBaseVersion() { + return this.baseVersion + } + + getResultVersion() { + return this.baseVersion + 1 + } + + getChange() { + return this.change + } } -ChangeNote.prototype.getBaseVersion = function () { - return this.baseVersion -} - -ChangeNote.prototype.getResultVersion = function () { - return this.baseVersion + 1 -} - -ChangeNote.prototype.getChange = function () { - return this.change -} +module.exports = ChangeNote diff --git a/libraries/overleaf-editor-core/lib/change_request.js b/libraries/overleaf-editor-core/lib/change_request.js index c8d7bfe626..63eb1d1fd1 100644 --- a/libraries/overleaf-editor-core/lib/change_request.js +++ b/libraries/overleaf-editor-core/lib/change_request.js @@ -11,12 +11,6 @@ const Operation = require('./operation') */ /** - * @constructor - * @param {number} baseVersion - * @param {Array.} 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}. * @@ -28,63 +22,69 @@ const Operation = require('./operation') * 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 || [] +class ChangeRequest { + /** + * @param {number} baseVersion + * @param {Array.} operations + * @param {boolean} [untransformable] + * @param {number[] | Author[]} [authors] + */ + constructor(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') + // check all are the same type + AuthorList.assertV1(authors, 'bad authors') - this.authors = authors - this.baseVersion = baseVersion - this.operations = operations - this.untransformable = untransformable || false + this.authors = authors + this.baseVersion = baseVersion + this.operations = operations + this.untransformable = untransformable || false + } + + /** + * For serialization. + * + * @return {Object} + */ + toRaw() { + function operationToRaw(operation) { + return operation.toRaw() + } + + return { + baseVersion: this.baseVersion, + operations: this.operations.map(operationToRaw), + untransformable: this.untransformable, + authors: this.authors, + } + } + + static fromRaw(raw) { + assert.array.of.object(raw.operations, 'bad raw.operations') + return new ChangeRequest( + raw.baseVersion, + raw.operations.map(Operation.fromRaw), + raw.untransformable, + raw.authors + ) + } + + getBaseVersion() { + return this.baseVersion + } + + isUntransformable() { + return this.untransformable + } + + makeChange(timestamp) { + return new Change(this.operations, timestamp, this.authors) + } } 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) -} diff --git a/libraries/overleaf-editor-core/lib/chunk.js b/libraries/overleaf-editor-core/lib/chunk.js index 8ddf9625d3..0404a4fad2 100644 --- a/libraries/overleaf-editor-core/lib/chunk.js +++ b/libraries/overleaf-editor-core/lib/chunk.js @@ -10,24 +10,6 @@ const History = require('./history') * @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 = @@ -40,7 +22,6 @@ class ConflictingEndVersion extends OError { this.latestEndVersion = latestEndVersion } } -Chunk.ConflictingEndVersion = ConflictingEndVersion class NotFoundError extends OError { // `message` and `info` optional arguments allow children classes to override @@ -53,7 +34,6 @@ class NotFoundError extends OError { this.projectId = projectId } } -Chunk.NotFoundError = NotFoundError class VersionNotFoundError extends NotFoundError { constructor(projectId, version) { @@ -65,7 +45,6 @@ class VersionNotFoundError extends NotFoundError { this.version = version } } -Chunk.VersionNotFoundError = VersionNotFoundError class BeforeTimestampNotFoundError extends NotFoundError { constructor(projectId, timestamp) { @@ -74,7 +53,6 @@ class BeforeTimestampNotFoundError extends NotFoundError { this.timestamp = timestamp } } -Chunk.BeforeTimestampNotFoundError = BeforeTimestampNotFoundError class NotPersistedError extends NotFoundError { constructor(projectId) { @@ -82,85 +60,108 @@ class NotPersistedError extends NotFoundError { 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} + * 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. */ -Chunk.prototype.getHistory = function () { - return this.history -} +class Chunk { + static ConflictingEndVersion = ConflictingEndVersion + static NotFoundError = NotFoundError + static VersionNotFoundError = VersionNotFoundError + static BeforeTimestampNotFoundError = BeforeTimestampNotFoundError + static NotPersistedError = NotPersistedError -/** - * {@see History#getSnapshot} - * @return {Snapshot} - */ -Chunk.prototype.getSnapshot = function () { - return this.history.getSnapshot() -} + /** + * @param {History} history + * @param {number} startVersion + */ + constructor(history, startVersion) { + assert.instance(history, History, 'bad history') + assert.integer(startVersion, 'bad startVersion') -/** - * {@see History#getChanges} - * @return {Array.} - */ -Chunk.prototype.getChanges = function () { - return this.history.getChanges() -} + this.history = history + this.startVersion = startVersion + } -/** - * {@see History#pushChanges} - * @param {Array.} changes - */ -Chunk.prototype.pushChanges = function chunkPushChanges(changes) { - this.history.pushChanges(changes) -} + static fromRaw(raw) { + return new Chunk(History.fromRaw(raw.history), raw.startVersion) + } -/** - * 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() -} + toRaw() { + return { history: this.history.toRaw(), startVersion: this.startVersion } + } -/** - * The timestamp of the last change in this chunk - */ + /** + * The history for this chunk. + * + * @return {History} + */ + getHistory() { + return this.history + } -Chunk.prototype.getEndTimestamp = function getEndTimestamp() { - if (!this.history.countChanges()) return null - return this.history.getChanges().slice(-1)[0].getTimestamp() -} + /** + * {@see History#getSnapshot} + * @return {Snapshot} + */ + getSnapshot() { + return this.history.getSnapshot() + } -/** - * 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#getChanges} + * @return {Array.} + */ + getChanges() { + return this.history.getChanges() + } -/** - * {@see History#loadFiles} - * - * @param {string} kind - * @param {BlobStore} blobStore - * @return {Promise} - */ -Chunk.prototype.loadFiles = function chunkLoadFiles(kind, blobStore) { - return this.history.loadFiles(kind, blobStore) + /** + * {@see History#pushChanges} + * @param {Array.} changes + */ + pushChanges(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 + */ + getEndVersion() { + return this.startVersion + this.history.countChanges() + } + + /** + * The timestamp of the last change in this chunk + */ + + 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 + */ + getStartVersion() { + return this.startVersion + } + + /** + * {@see History#loadFiles} + * + * @param {string} kind + * @param {BlobStore} blobStore + * @return {Promise} + */ + loadFiles(kind, blobStore) { + return this.history.loadFiles(kind, blobStore) + } } module.exports = Chunk diff --git a/libraries/overleaf-editor-core/lib/chunk_response.js b/libraries/overleaf-editor-core/lib/chunk_response.js index 84ad2c2559..454d0de216 100644 --- a/libraries/overleaf-editor-core/lib/chunk_response.js +++ b/libraries/overleaf-editor-core/lib/chunk_response.js @@ -3,30 +3,31 @@ 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. -// +/** + * The ChunkResponse allows for additional data to be sent back with the chunk + * at present there are no extra data to send. + */ +class ChunkResponse { + constructor(chunk) { + assert.instance(chunk, Chunk) + this.chunk = chunk + } -function ChunkResponse(chunk) { - assert.instance(chunk, Chunk) - this.chunk = chunk -} + toRaw() { + return { + chunk: this.chunk.toRaw(), + } + } -ChunkResponse.prototype.toRaw = function chunkResponseToRaw() { - return { - chunk: this.chunk.toRaw(), + static fromRaw(raw) { + if (!raw) return null + + return new ChunkResponse(Chunk.fromRaw(raw.chunk)) + } + + getChunk() { + return this.chunk } } -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 diff --git a/libraries/overleaf-editor-core/lib/file.js b/libraries/overleaf-editor-core/lib/file.js index 78826c04d2..9586478582 100644 --- a/libraries/overleaf-editor-core/lib/file.js +++ b/libraries/overleaf-editor-core/lib/file.js @@ -20,12 +20,13 @@ const StringFileData = require('./file_data/string_file_data') * @typedef {import("bluebird")} BPromise */ +class NotEditableError extends OError { + constructor() { + super('File is not editable') + } +} + /** - * @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 @@ -33,66 +34,199 @@ const StringFileData = require('./file_data/string_file_data') * * 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, + * 2. 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 + * 3. 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 + * 4. 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') +class File { + /** + * Blob hash for an empty file. + * + * @type {String} + */ + static EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' - this.data = data - this.setMetadata(metadata || {}) -} + static NotEditableError = NotEditableError -File.fromRaw = function fileFromRaw(raw) { - if (!raw) return null - return new File(FileData.fromRaw(raw), raw.metadata) -} + /** + * @param {FileData} data + * @param {Object} [metadata] + */ + constructor(data, metadata) { + assert.instance(data, FileData, 'File: bad data') -/** - * @param {string} hash - * @param {Object} [metadata] - * @return {File} - */ -File.fromHash = function fileFromHash(hash, metadata) { - return new File(new HashFileData(hash), metadata) -} + this.data = data + this.setMetadata(metadata || {}) + } -/** - * @param {string} string - * @param {Object} [metadata] - * @return {File} - */ -File.fromString = function fileFromString(string, metadata) { - return new File(new StringFileData(string), metadata) -} + static fromRaw(raw) { + if (!raw) return null + return new File(FileData.fromRaw(raw), raw.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 {string} hash + * @param {Object} [metadata] + * @return {File} + */ + static fromHash(hash, metadata) { + return new File(new HashFileData(hash), metadata) + } -/** - * @param {Blob} blob - * @param {Object} [metadata] - * @return {File} - */ -File.createLazyFromBlob = function fileCreateLazyFromBlob(blob, metadata) { - return new File(FileData.createLazyFromBlob(blob), metadata) + /** + * @param {string} string + * @param {Object} [metadata] + * @return {File} + */ + static fromString(string, metadata) { + return new File(new StringFileData(string), metadata) + } + + /** + * @param {number} [byteLength] + * @param {number} [stringLength] + * @param {Object} [metadata] + * @return {File} + */ + static createHollow(byteLength, stringLength, metadata) { + return new File(FileData.createHollow(byteLength, stringLength), metadata) + } + + /** + * @param {Blob} blob + * @param {Object} [metadata] + * @return {File} + */ + static createLazyFromBlob(blob, metadata) { + return new File(FileData.createLazyFromBlob(blob), metadata) + } + + toRaw() { + 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} + */ + getHash() { + 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} + */ + getContent() { + 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 + */ + isEditable() { + return this.data.isEditable() + } + + /** + * The length of the file's content in bytes, if known. + * + * @return {number | null | undefined} + */ + getByteLength() { + return this.data.getByteLength() + } + + /** + * The length of the file's content in characters, if known. + * + * @return {number | null | undefined} + */ + getStringLength() { + return this.data.getStringLength() + } + + /** + * Return the metadata object for this file. + * + * @return {Object} + */ + getMetadata() { + return this.metadata + } + + /** + * Set the metadata object for this file. + * + * @param {Object} metadata + */ + setMetadata(metadata) { + assert.object(metadata, 'File: bad metadata') + this.metadata = metadata + } + + /** + * Edit this file, if possible. + * + * @param {TextOperation} textOperation + */ + edit(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 + */ + clone() { + 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.} for this + */ + load(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} a raw HashFile + */ + store(blobStore) { + return this.data.store(blobStore).then(raw => { + storeRawMetadata(this.metadata, raw) + return raw + }) + } } function storeRawMetadata(metadata, raw) { @@ -101,141 +235,4 @@ function storeRawMetadata(metadata, raw) { } } -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.} 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} 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 diff --git a/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js b/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js index 88f74dc6ee..b577958871 100644 --- a/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/binary_file_data.js @@ -8,7 +8,6 @@ const FileData = require('./') class BinaryFileData extends FileData { /** - * @constructor * @param {string} hash * @param {number} byteLength * @see FileData diff --git a/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js b/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js index fb444a5ddd..c63a9827b9 100644 --- a/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/hollow_binary_file_data.js @@ -7,7 +7,6 @@ const FileData = require('./') class HollowBinaryFileData extends FileData { /** - * @constructor * @param {number} byteLength * @see FileData */ diff --git a/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js index 9be9253cbe..911c74a57f 100644 --- a/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/hollow_string_file_data.js @@ -7,7 +7,6 @@ const FileData = require('./') class HollowStringFileData extends FileData { /** - * @constructor * @param {number} stringLength * @see FileData */ diff --git a/libraries/overleaf-editor-core/lib/file_data/index.js b/libraries/overleaf-editor-core/lib/file_data/index.js index 3b9b4b7a77..4b09050d7a 100644 --- a/libraries/overleaf-editor-core/lib/file_data/index.js +++ b/libraries/overleaf-editor-core/lib/file_data/index.js @@ -19,7 +19,6 @@ let StringFileData = null */ /** - * @classdesc * Helper to represent the content of a file. This class and its subclasses * should be used only through {@link File}. */ diff --git a/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js index 66e29dcd27..21bc150b48 100644 --- a/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js @@ -11,7 +11,6 @@ const TextOperation = require('../operation/text_operation') class LazyStringFileData extends FileData { /** - * @constructor * @param {string} hash * @param {number} stringLength * @param {Array.} [textOperations] diff --git a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js index b0977dafa9..abfe7fcdd5 100644 --- a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js @@ -11,7 +11,6 @@ const FileData = require('./') class StringFileData extends FileData { /** - * @constructor * @param {string} content */ constructor(content) { diff --git a/libraries/overleaf-editor-core/lib/file_map.js b/libraries/overleaf-editor-core/lib/file_map.js index e4c8de44ba..2b61e8f9c7 100644 --- a/libraries/overleaf-editor-core/lib/file_map.js +++ b/libraries/overleaf-editor-core/lib/file_map.js @@ -9,6 +9,36 @@ const OError = require('@overleaf/o-error') const File = require('./file') const safePathname = require('./safe_pathname') +class PathnameError extends OError {} + +class NonUniquePathnameError extends PathnameError { + constructor(pathnames) { + super('pathnames are not unique: ' + pathnames, { pathnames }) + this.pathnames = pathnames + } +} + +class BadPathnameError extends PathnameError { + constructor(pathname) { + super(pathname + ' is not a valid pathname', { pathname }) + this.pathname = pathname + } +} + +class PathnameConflictError extends PathnameError { + constructor(pathname) { + super(`pathname '${pathname}' conflicts with another file`, { pathname }) + this.pathname = pathname + } +} + +class FileNotFoundError extends PathnameError { + constructor(pathname) { + super(`file ${pathname} does not exist`, { pathname }) + this.pathname = pathname + } +} + /** * A set of {@link File}s. Several properties are enforced on the pathnames: * @@ -26,10 +56,17 @@ const safePathname = require('./safe_pathname') * 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.} files */ class FileMap { + static PathnameError = PathnameError + static NonUniquePathnameError = NonUniquePathnameError + static BadPathnameError = BadPathnameError + static PathnameConflictError = PathnameConflictError + static FileNotFoundError = FileNotFoundError + + /** + * @param {Object.} files + */ constructor(files) { // create bare object for use as Map // http://ryanmorr.com/true-hash-maps-in-javascript/ @@ -215,41 +252,6 @@ class FileMap { } } -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 } diff --git a/libraries/overleaf-editor-core/lib/history.js b/libraries/overleaf-editor-core/lib/history.js index 2b15e0103e..14bbb7f1d4 100644 --- a/libraries/overleaf-editor-core/lib/history.js +++ b/libraries/overleaf-editor-core/lib/history.js @@ -10,116 +10,119 @@ const Snapshot = require('./snapshot') * @typedef {import("./types").BlobStore} BlobStore */ -/** - * @constructor - * @param {Snapshot} snapshot - * @param {Array.} 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') +class History { + /** + * @constructor + * @param {Snapshot} snapshot + * @param {Array.} changes + * + * @classdesc + * A History is a {@link Snapshot} and a sequence of {@link Change}s that can + * be applied to produce a new snapshot. + */ + constructor(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() + this.snapshot = snapshot + this.changes = changes || [] } - return { - snapshot: this.snapshot.toRaw(), - changes: this.changes.map(changeToRaw), + + static fromRaw(raw) { + return new History( + Snapshot.fromRaw(raw.snapshot), + raw.changes.map(Change.fromRaw) + ) } -} -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.} 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.} 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.} - */ -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, + toRaw() { + function changeToRaw(change) { + return change.toRaw() } - }) + return { + snapshot: this.snapshot.toRaw(), + changes: this.changes.map(changeToRaw), + } + } + + getSnapshot() { + return this.snapshot + } + + getChanges() { + return this.changes + } + + countChanges() { + return this.changes.length + } + + /** + * Add changes to this history. + * + * @param {Array.} changes + */ + pushChanges(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.} blobHashes + */ + findBlobHashes(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} + */ + loadFiles(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.} + */ + store(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 diff --git a/libraries/overleaf-editor-core/lib/label.js b/libraries/overleaf-editor-core/lib/label.js index df516483b6..8ec86e0a01 100644 --- a/libraries/overleaf-editor-core/lib/label.js +++ b/libraries/overleaf-editor-core/lib/label.js @@ -3,80 +3,89 @@ 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') +class Label { + /** + * @constructor + * @param {string} text + */ + constructor(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 -} + 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) -} + /** + * Create a Label from its raw form. + * + * @param {Object} raw + * @return {Label} + */ + static fromRaw(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, + /** + * Convert the Label to raw form for transmission. + * + * @return {Object} + */ + toRaw() { + return { + text: this.text, + authorId: this.authorId, + timestamp: this.timestamp.toISOString(), + version: this.version, + } + } + + /** + * @return {string} + */ + getText() { + 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} + */ + getAuthorId() { + return this.authorId + } + + /** + * @return {Date} + */ + getTimestamp() { + return this.timestamp + } + + /** + * @return {number | undefined} + */ + getVersion() { + return 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 diff --git a/libraries/overleaf-editor-core/lib/operation/add_file_operation.js b/libraries/overleaf-editor-core/lib/operation/add_file_operation.js index 8560c7f42e..f23665d5f4 100644 --- a/libraries/overleaf-editor-core/lib/operation/add_file_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/add_file_operation.js @@ -6,12 +6,10 @@ 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 */ diff --git a/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js b/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js index e5c88bcdb0..52a1ddedea 100644 --- a/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/edit_file_operation.js @@ -4,12 +4,10 @@ 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 */ diff --git a/libraries/overleaf-editor-core/lib/operation/index.js b/libraries/overleaf-editor-core/lib/operation/index.js index a58c1345c0..4ecd32b53d 100644 --- a/libraries/overleaf-editor-core/lib/operation/index.js +++ b/libraries/overleaf-editor-core/lib/operation/index.js @@ -20,7 +20,6 @@ let SetFileMetadataOperation = null */ /** - * @classdesc * An `Operation` changes a `Snapshot` when it is applied. See the * {@tutorial OT} tutorial for background. */ diff --git a/libraries/overleaf-editor-core/lib/operation/move_file_operation.js b/libraries/overleaf-editor-core/lib/operation/move_file_operation.js index 01552d99aa..85e3707551 100644 --- a/libraries/overleaf-editor-core/lib/operation/move_file_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/move_file_operation.js @@ -3,7 +3,6 @@ const Operation = require('./') /** - * @classdesc * Moves or removes a file from a project. */ class MoveFileOperation extends Operation { @@ -51,4 +50,5 @@ class MoveFileOperation extends Operation { snapshot.moveFile(this.getPathname(), this.getNewPathname()) } } + module.exports = MoveFileOperation diff --git a/libraries/overleaf-editor-core/lib/operation/no_operation.js b/libraries/overleaf-editor-core/lib/operation/no_operation.js index a28cea5bdf..4c79665790 100644 --- a/libraries/overleaf-editor-core/lib/operation/no_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/no_operation.js @@ -3,7 +3,6 @@ const Operation = require('./') /** - * @classdesc * An explicit no-operation. * * There are several no-ops, such as moving a file to itself, but it's useful diff --git a/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js b/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js index d689ea9f73..5bdd00fc6f 100644 --- a/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/set_file_metadata_operation.js @@ -6,12 +6,10 @@ 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 */ diff --git a/libraries/overleaf-editor-core/lib/operation/text_operation.js b/libraries/overleaf-editor-core/lib/operation/text_operation.js index 94ad07895d..588daa2f3e 100644 --- a/libraries/overleaf-editor-core/lib/operation/text_operation.js +++ b/libraries/overleaf-editor-core/lib/operation/text_operation.js @@ -13,52 +13,7 @@ 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) { @@ -67,7 +22,6 @@ class ApplyError extends UnprocessableError { this.operand = operand } } -TextOperation.ApplyError = ApplyError class InvalidInsertionError extends UnprocessableError { constructor(str, operation) { @@ -76,7 +30,6 @@ class InvalidInsertionError extends UnprocessableError { this.operation = operation } } -TextOperation.InvalidInsertionError = InvalidInsertionError class TooLongError extends UnprocessableError { constructor(operation, resultLength) { @@ -88,7 +41,630 @@ class TooLongError extends UnprocessableError { this.resultLength = resultLength } } -TextOperation.TooLongError = TooLongError + +/** + * Create an empty text operation. + */ +class TextOperation { + /** + * Length of the longest file that we'll attempt to edit, in characters. + * + * @type {number} + */ + static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2) + static UnprocessableError = UnprocessableError + static ApplyError = ApplyError + static InvalidInsertionError = InvalidInsertionError + static TooLongError = TooLongError + static isRetain = isRetain + static isInsert = isInsert + static isRemove = isRemove + + constructor() { + // 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 + } + + equals(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 + } + + // 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. + */ + retain(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. + */ + insert(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. + */ + remove(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. + */ + isNoop() { + return ( + this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])) + ) + } + + /** + * Pretty printing. + */ + toString() { + 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. + */ + toJSON() { + return this.ops + } + + /** + * Converts a plain JS object into an operation and validates it. + */ + static 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. + */ + apply(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 + */ + applyToLength(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. + */ + invert(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. + */ + canBeComposedWithForUndo(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 + */ + canBeComposedWith(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. + compose(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 + } + + /** + * 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. + */ + static transform(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] + } +} // Operation are essentially lists of ops. There are three types of ops: // @@ -98,440 +674,16 @@ TextOperation.TooLongError = TooLongError // Represented by strings. // * Remove ops: Remove the next n characters. Represented by negative ints. -const isRetain = (TextOperation.isRetain = function (op) { +function isRetain(op) { return typeof op === 'number' && op > 0 -}) +} -const isInsert = (TextOperation.isInsert = function (op) { +function isInsert(op) { return typeof op === 'string' -}) +} -const isRemove = (TextOperation.isRemove = function (op) { +function isRemove(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) { @@ -556,127 +708,4 @@ function getStartIndex(operation) { 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 diff --git a/libraries/overleaf-editor-core/lib/origin/index.js b/libraries/overleaf-editor-core/lib/origin/index.js index 2c6a228212..6ed663c242 100644 --- a/libraries/overleaf-editor-core/lib/origin/index.js +++ b/libraries/overleaf-editor-core/lib/origin/index.js @@ -7,46 +7,48 @@ const assert = require('check-types').assert 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') +class Origin { + /** + * @param {string} kind + */ + constructor(kind) { + assert.string(kind, 'Origin: bad kind') - this.kind = 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) -} + /** + * Create an Origin from its raw form. + * + * @param {Object} [raw] + * @return {Origin | null} + */ + static fromRaw(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 } -} + /** + * Convert the Origin to raw form for storage or transmission. + * + * @return {Object} + */ + toRaw() { + return { kind: this.kind } + } -/** - * @return {string} - */ -Origin.prototype.getKind = function () { - return this.kind + /** + * @return {string} + */ + getKind() { + return this.kind + } } module.exports = Origin diff --git a/libraries/overleaf-editor-core/lib/origin/restore_origin.js b/libraries/overleaf-editor-core/lib/origin/restore_origin.js index 180b8bc36f..15bb4258f6 100644 --- a/libraries/overleaf-editor-core/lib/origin/restore_origin.js +++ b/libraries/overleaf-editor-core/lib/origin/restore_origin.js @@ -5,7 +5,6 @@ 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 @@ -18,7 +17,6 @@ const Origin = require('./') */ class RestoreOrigin extends Origin { /** - * @constructor * @param {number} version that was restored * @param {Date} timestamp from the restored version */ diff --git a/libraries/overleaf-editor-core/lib/ot_client.js b/libraries/overleaf-editor-core/lib/ot_client.js index b3eafbc213..1dd632c2c3 100644 --- a/libraries/overleaf-editor-core/lib/ot_client.js +++ b/libraries/overleaf-editor-core/lib/ot_client.js @@ -9,229 +9,232 @@ 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 +class OtClient { + constructor(_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 = [] + 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 + 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) } - 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) + /** + * The latest project version number for which the client can construct the + * project content. + * + * @return {number} non-negative + */ + this.getVersion = function () { + return _version } - } - /** - * 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) + _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) + } }) - } - 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] - } - } + // + // Local Operations + // - // - // Messages from Server - // + function sendOutstandingChange() { + const changeRequest = new ChangeRequest(_version, _outstanding) + _socket.emit('change', changeRequest.toRaw()) + _state = STATE_WAITING + } - 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 = [] + function sendLocalOperation(operation) { + _outstanding.push(operation) sendOutstandingChange() - } else { - _outstanding = [] - _state = STATE_READY } - } - function handleRemoteChangeReady(change) { - if (_pending.length > 0) { + 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('pending change without outstanding change') + 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 } } - Operation.transformMultiple(_outstanding, change.getOperations()) - Operation.transformMultiple(_pending, change.getOperations()) + function handleRemoteChangeReady(change) { + if (_pending.length > 0) { + if (_outstanding.length === 0) { + throw new Error('pending change without outstanding change') + } + } - _editor.applyRemoteChange(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? + }) } - - _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 diff --git a/libraries/overleaf-editor-core/lib/snapshot.js b/libraries/overleaf-editor-core/lib/snapshot.js index c635533f31..95ce947fa7 100644 --- a/libraries/overleaf-editor-core/lib/snapshot.js +++ b/libraries/overleaf-editor-core/lib/snapshot.js @@ -13,11 +13,17 @@ const V2DocVersions = require('./v2_doc_versions') * @typedef {import("./operation/text_operation")} TextOperation */ +class EditMissingFileError extends OError {} + /** - * @classdesc A Snapshot represents the state of a {@link Project} at a - * particular version. + * A Snapshot represents the state of a {@link Project} at a + * particular version. */ class Snapshot { + static PROJECT_VERSION_RX_STRING = '^[0-9]+\\.[0-9]+$' + static PROJECT_VERSION_RX = new RegExp(Snapshot.PROJECT_VERSION_RX_STRING) + static EditMissingFileError = EditMissingFileError + static fromRaw(raw) { assert.object(raw.files, 'bad raw.files') return new Snapshot( @@ -37,7 +43,6 @@ class Snapshot { } /** - * @constructor * @param {FileMap} [fileMap] * @param {string} [projectVersion] * @param {V2DocVersions} [v2DocVersions] @@ -231,10 +236,4 @@ class Snapshot { } } -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 diff --git a/libraries/overleaf-editor-core/lib/v2_doc_versions.js b/libraries/overleaf-editor-core/lib/v2_doc_versions.js index 4b5500cf2c..3911c2e396 100644 --- a/libraries/overleaf-editor-core/lib/v2_doc_versions.js +++ b/libraries/overleaf-editor-core/lib/v2_doc_versions.js @@ -7,48 +7,48 @@ const _ = require('lodash') * @typedef {import("./types").RawV2DocVersions} RawV2DocVersions */ -/** - * @constructor - * @param {RawV2DocVersions} data - * @classdesc - */ -function V2DocVersions(data) { - this.data = data || {} -} +class V2DocVersions { + /** + * @param {RawV2DocVersions} data + */ + constructor(data) { + this.data = data || {} + } -V2DocVersions.fromRaw = function v2DocVersionsFromRaw(raw) { - if (!raw) return undefined - return new V2DocVersions(raw) -} + static fromRaw(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 -} + /** + * @abstract + */ + toRaw() { + 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()) -} + /** + * Clone this object. + * + * @return {V2DocVersions} a new object of the same type + */ + clone() { + 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 + applyTo(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) + // 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) + } } } diff --git a/libraries/overleaf-editor-core/tsconfig.json b/libraries/overleaf-editor-core/tsconfig.json index 536cb6e9a5..9b963217fd 100644 --- a/libraries/overleaf-editor-core/tsconfig.json +++ b/libraries/overleaf-editor-core/tsconfig.json @@ -4,7 +4,7 @@ "allowSyntheticDefaultImports": true, "checkJs": true, "esModuleInterop": true, - "lib": ["es2018"], + "lib": ["es2022"], "module": "commonjs", "noEmit": true, "resolveJsonModule": true,