mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
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:
@@ -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
|
||||
|
||||
@@ -42,4 +42,4 @@ function assertV2(authors, msg) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { assertV1: assertV1, assertV2: assertV2 }
|
||||
module.exports = { assertV1, assertV2 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,6 @@ const FileData = require('./')
|
||||
|
||||
class BinaryFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} hash
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
|
||||
@@ -7,7 +7,6 @@ const FileData = require('./')
|
||||
|
||||
class HollowBinaryFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} byteLength
|
||||
* @see FileData
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ const FileData = require('./')
|
||||
|
||||
class HollowStringFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {number} stringLength
|
||||
* @see FileData
|
||||
*/
|
||||
|
||||
@@ -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}.
|
||||
*/
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,7 +11,6 @@ const FileData = require('./')
|
||||
|
||||
class StringFileData extends FileData {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {string} content
|
||||
*/
|
||||
constructor(content) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,6 @@ let SetFileMetadataOperation = null
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc
|
||||
* An `Operation` changes a `Snapshot` when it is applied. See the
|
||||
* {@tutorial OT} tutorial for background.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["es2018"],
|
||||
"lib": ["es2022"],
|
||||
"module": "commonjs",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user