Merge pull request #14418 from overleaf/em-history-lib-es6-classes

Move overleaf-editor-core code to ES6 classes

GitOrigin-RevId: f9b50579aec0cef9d9e6aefcfcb3e380fae4b6f4
This commit is contained in:
Eric Mc Sween
2023-08-22 10:48:04 -04:00
committed by Copybot
parent b2e74464a2
commit d54bcc4aa9
31 changed files with 1676 additions and 1641 deletions

View File

@@ -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

View File

@@ -42,4 +42,4 @@ function assertV2(authors, msg) {
})
}
module.exports = { assertV1: assertV1, assertV2: assertV2 }
module.exports = { assertV1, assertV2 }

View File

@@ -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

View File

@@ -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.<Operation>} 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

View File

@@ -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

View File

@@ -11,12 +11,6 @@ const Operation = require('./operation')
*/
/**
* @constructor
* @param {number} baseVersion
* @param {Array.<Operation>} operations
* @param {boolean} [untransformable]
* @param {number[] | Author[]} [authors]
* @classdesc
* A `ChangeRequest` is a list of {@link Operation}s that the server can apply
* as a {@link Change}.
*
@@ -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.<Operation>} 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)
}

View File

@@ -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.<Change>}
*/
Chunk.prototype.getChanges = function () {
return this.history.getChanges()
}
this.history = history
this.startVersion = startVersion
}
/**
* {@see History#pushChanges}
* @param {Array.<Change>} 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.<Change>}
*/
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.<Change>} 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

View File

@@ -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

View File

@@ -20,12 +20,13 @@ const StringFileData = require('./file_data/string_file_data')
* @typedef {import("bluebird")<T>} 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.<File>} 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<Object>} 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.<File>} for this
*/
File.prototype.load = function (kind, blobStore) {
return this.data.load(kind, blobStore).then(data => {
this.data = data
return this
})
}
/**
* Store the file's content in the blob store and return a raw file with
* the corresponding hash. As a side effect, make this object consistent with
* the hash.
*
* @param {BlobStore} blobStore
* @return {BPromise<Object>} a raw HashFile
*/
File.prototype.store = function (blobStore) {
return this.data.store(blobStore).then(raw => {
storeRawMetadata(this.metadata, raw)
return raw
})
}
/**
* Blob hash for an empty file.
*
* @type {String}
*/
File.EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
module.exports = File

View File

@@ -8,7 +8,6 @@ const FileData = require('./')
class BinaryFileData extends FileData {
/**
* @constructor
* @param {string} hash
* @param {number} byteLength
* @see FileData

View File

@@ -7,7 +7,6 @@ const FileData = require('./')
class HollowBinaryFileData extends FileData {
/**
* @constructor
* @param {number} byteLength
* @see FileData
*/

View File

@@ -7,7 +7,6 @@ const FileData = require('./')
class HollowStringFileData extends FileData {
/**
* @constructor
* @param {number} stringLength
* @see FileData
*/

View File

@@ -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}.
*/

View File

@@ -11,7 +11,6 @@ const TextOperation = require('../operation/text_operation')
class LazyStringFileData extends FileData {
/**
* @constructor
* @param {string} hash
* @param {number} stringLength
* @param {Array.<TextOperation>} [textOperations]

View File

@@ -11,7 +11,6 @@ const FileData = require('./')
class StringFileData extends FileData {
/**
* @constructor
* @param {string} content
*/
constructor(content) {

View File

@@ -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.<String, File>} files
*/
class FileMap {
static PathnameError = PathnameError
static NonUniquePathnameError = NonUniquePathnameError
static BadPathnameError = BadPathnameError
static PathnameConflictError = PathnameConflictError
static FileNotFoundError = FileNotFoundError
/**
* @param {Object.<String, File>} 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
}

View File

@@ -10,116 +10,119 @@ const Snapshot = require('./snapshot')
* @typedef {import("./types").BlobStore} BlobStore
*/
/**
* @constructor
* @param {Snapshot} snapshot
* @param {Array.<Change>} changes
*
* @classdesc
* A History is a {@link Snapshot} and a sequence of {@link Change}s that can
* be applied to produce a new snapshot.
*/
function History(snapshot, changes) {
assert.instance(snapshot, Snapshot, 'bad snapshot')
assert.maybe.array.of.instance(changes, Change, 'bad changes')
class History {
/**
* @constructor
* @param {Snapshot} snapshot
* @param {Array.<Change>} changes
*
* @classdesc
* A History is a {@link Snapshot} and a sequence of {@link Change}s that can
* be applied to produce a new snapshot.
*/
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.<Change>} changes
*/
History.prototype.pushChanges = function historyPushChanges(changes) {
this.changes.push.apply(this.changes, changes)
}
/**
* If this History references blob hashes, either in the Snapshot or the
* Changes, add them to the given set.
*
* @param {Set.<String>} blobHashes
*/
History.prototype.findBlobHashes = function historyFindBlobHashes(blobHashes) {
function findChangeBlobHashes(change) {
change.findBlobHashes(blobHashes)
}
this.snapshot.findBlobHashes(blobHashes)
this.changes.forEach(findChangeBlobHashes)
}
/**
* If this History contains any File objects, load them.
*
* @param {string} kind see {File#load}
* @param {BlobStore} blobStore
* @return {Promise}
*/
History.prototype.loadFiles = function historyLoadFiles(kind, blobStore) {
function loadChangeFiles(change) {
return change.loadFiles(kind, blobStore)
}
return BPromise.join(
this.snapshot.loadFiles(kind, blobStore),
BPromise.each(this.changes, loadChangeFiles)
)
}
/**
* Return a version of this history that is suitable for long term storage.
* This requires that we store the content of file objects in the provided
* blobStore.
*
* @param {BlobStore} blobStore
* @param {number} [concurrency] applies separately to files, changes and
* operations
* @return {Promise.<Object>}
*/
History.prototype.store = function historyStoreFunc(blobStore, concurrency) {
assert.maybe.number(concurrency, 'bad concurrency')
function storeChange(change) {
return change.store(blobStore, concurrency)
}
return BPromise.join(
this.snapshot.store(blobStore, concurrency),
BPromise.map(this.changes, storeChange, { concurrency: concurrency || 1 })
).then(([rawSnapshot, rawChanges]) => {
return {
snapshot: rawSnapshot,
changes: rawChanges,
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.<Change>} 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.<String>} 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.<Object>}
*/
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

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -20,7 +20,6 @@ let SetFileMetadataOperation = null
*/
/**
* @classdesc
* An `Operation` changes a `Snapshot` when it is applied. See the
* {@tutorial OT} tutorial for background.
*/

View File

@@ -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

View File

@@ -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

View File

@@ -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
*/

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -4,7 +4,7 @@
"allowSyntheticDefaultImports": true,
"checkJs": true,
"esModuleInterop": true,
"lib": ["es2018"],
"lib": ["es2022"],
"module": "commonjs",
"noEmit": true,
"resolveJsonModule": true,