|
|
@@ -1,30 +1,41 @@
|
|
|
+// @ts-check
|
|
|
+
|
|
|
import _ from 'lodash'
|
|
|
import Core from 'overleaf-editor-core'
|
|
|
import * as Errors from './Errors.js'
|
|
|
import * as OperationsCompressor from './OperationsCompressor.js'
|
|
|
|
|
|
-export function convertToChanges(projectId, updatesWithBlobs, callback) {
|
|
|
- let changes
|
|
|
- try {
|
|
|
- // convert update to change
|
|
|
- changes = updatesWithBlobs.map(update =>
|
|
|
- _convertToChange(projectId, update)
|
|
|
- )
|
|
|
- } catch (error1) {
|
|
|
- const error = error1
|
|
|
- if (
|
|
|
- error instanceof Errors.UpdateWithUnknownFormatError ||
|
|
|
- error instanceof Errors.UnexpectedOpTypeError
|
|
|
- ) {
|
|
|
- return callback(error)
|
|
|
- } else {
|
|
|
- throw error
|
|
|
- }
|
|
|
- }
|
|
|
+/**
|
|
|
+ * @typedef {import('./types.ts').AddDocUpdate} AddDocUpdate
|
|
|
+ * @typedef {import('./types.ts').AddFileUpdate} AddFileUpdate
|
|
|
+ * @typedef {import('./types.ts').CommentOp} CommentOp
|
|
|
+ * @typedef {import('./types.ts').DeleteOp} DeleteOp
|
|
|
+ * @typedef {import('./types.ts').InsertOp} InsertOp
|
|
|
+ * @typedef {import('./types.ts').Op} Op
|
|
|
+ * @typedef {import('./types.ts').RenameUpdate} RenameUpdate
|
|
|
+ * @typedef {import('./types.ts').TextUpdate} TextUpdate
|
|
|
+ * @typedef {import('./types.ts').Update} Update
|
|
|
+ * @typedef {import('./types.ts').UpdateWithBlob} UpdateWithBlob
|
|
|
+ */
|
|
|
|
|
|
- callback(null, changes)
|
|
|
+/**
|
|
|
+ * Convert updates into history changes
|
|
|
+ *
|
|
|
+ * @param {string} projectId
|
|
|
+ * @param {UpdateWithBlob[]} updatesWithBlobs
|
|
|
+ * @returns {Array<Core.Change | null>}
|
|
|
+ */
|
|
|
+export function convertToChanges(projectId, updatesWithBlobs) {
|
|
|
+ return updatesWithBlobs.map(update => _convertToChange(projectId, update))
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Convert an update into a history change
|
|
|
+ *
|
|
|
+ * @param {string} projectId
|
|
|
+ * @param {UpdateWithBlob} updateWithBlob
|
|
|
+ * @returns {Core.Change | null}
|
|
|
+ */
|
|
|
function _convertToChange(projectId, updateWithBlob) {
|
|
|
let operations
|
|
|
const { update } = updateWithBlob
|
|
|
@@ -55,11 +66,12 @@ function _convertToChange(projectId, updateWithBlob) {
|
|
|
let pathname = update.meta.pathname
|
|
|
|
|
|
pathname = _convertPathname(pathname)
|
|
|
- const builder = new TextOperationsBuilder(docLength, pathname)
|
|
|
+ const builder = new OperationsBuilder(docLength, pathname)
|
|
|
// convert ops
|
|
|
for (const op of update.op) {
|
|
|
+ // if this throws an exception it will be caught in convertToChanges
|
|
|
builder.addOp(op)
|
|
|
- } // if this throws an exception it will be caught in convertToChanges
|
|
|
+ }
|
|
|
operations = builder.finish()
|
|
|
// add doc version information if present
|
|
|
if (update.v != null) {
|
|
|
@@ -96,28 +108,62 @@ function _convertToChange(projectId, updateWithBlob) {
|
|
|
}
|
|
|
const change = Core.Change.fromRaw(rawChange)
|
|
|
|
|
|
- change.operations = OperationsCompressor.compressOperations(change.operations)
|
|
|
+ if (change != null) {
|
|
|
+ change.operations = OperationsCompressor.compressOperations(
|
|
|
+ change.operations
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
return change
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * @param {Update} update
|
|
|
+ * @returns {update is RenameUpdate}
|
|
|
+ */
|
|
|
function _isRenameUpdate(update) {
|
|
|
- return update.new_pathname != null
|
|
|
+ return 'new_pathname' in update && update.new_pathname != null
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * @param {Update} update
|
|
|
+ * @returns {update is AddDocUpdate}
|
|
|
+ */
|
|
|
function _isAddDocUpdate(update) {
|
|
|
- return update.doc != null && update.docLines != null
|
|
|
+ return (
|
|
|
+ 'doc' in update &&
|
|
|
+ update.doc != null &&
|
|
|
+ 'docLines' in update &&
|
|
|
+ update.docLines != null
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * @param {Update} update
|
|
|
+ * @returns {update is AddFileUpdate}
|
|
|
+ */
|
|
|
function _isAddFileUpdate(update) {
|
|
|
- return update.file != null && update.url != null
|
|
|
+ return (
|
|
|
+ 'file' in update &&
|
|
|
+ update.file != null &&
|
|
|
+ 'url' in update &&
|
|
|
+ update.url != null
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * @param {Update} update
|
|
|
+ * @returns {update is TextUpdate}
|
|
|
+ */
|
|
|
export function isTextUpdate(update) {
|
|
|
return (
|
|
|
+ 'doc' in update &&
|
|
|
update.doc != null &&
|
|
|
+ 'op' in update &&
|
|
|
update.op != null &&
|
|
|
+ 'pathname' in update.meta &&
|
|
|
update.meta.pathname != null &&
|
|
|
+ 'doc_length' in update.meta &&
|
|
|
update.meta.doc_length != null
|
|
|
)
|
|
|
}
|
|
|
@@ -126,6 +172,10 @@ export function isProjectStructureUpdate(update) {
|
|
|
return isAddUpdate(update) || _isRenameUpdate(update)
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * @param {Update} update
|
|
|
+ * @returns {update is AddDocUpdate | AddFileUpdate}
|
|
|
+ */
|
|
|
export function isAddUpdate(update) {
|
|
|
return _isAddDocUpdate(update) || _isAddFileUpdate(update)
|
|
|
}
|
|
|
@@ -152,20 +202,57 @@ export function _convertPathname(pathname) {
|
|
|
return pathname
|
|
|
}
|
|
|
|
|
|
-class TextOperationsBuilder {
|
|
|
+class OperationsBuilder {
|
|
|
+ /**
|
|
|
+ * @param {number} docLength
|
|
|
+ * @param {string} pathname
|
|
|
+ */
|
|
|
constructor(docLength, pathname) {
|
|
|
+ /**
|
|
|
+ * List of operations being built
|
|
|
+ */
|
|
|
this.operations = []
|
|
|
- this.currentOperation = []
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Currently built text operation
|
|
|
+ *
|
|
|
+ * @type {(number | string)[]}
|
|
|
+ */
|
|
|
+ this.textOperation = []
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Cursor inside the current text operation
|
|
|
+ */
|
|
|
this.cursor = 0
|
|
|
+
|
|
|
this.docLength = docLength
|
|
|
this.pathname = pathname
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * @param {Op} op
|
|
|
+ * @returns {void}
|
|
|
+ */
|
|
|
addOp(op) {
|
|
|
- if (op.c != null) {
|
|
|
- return // ignore comment op
|
|
|
+ if (isComment(op)) {
|
|
|
+ // Close the current text operation
|
|
|
+ this.pushTextOperation()
|
|
|
+
|
|
|
+ // Add a comment operation
|
|
|
+ this.operations.push({
|
|
|
+ pathname: this.pathname,
|
|
|
+ commentId: op.t,
|
|
|
+ ranges: [
|
|
|
+ {
|
|
|
+ pos: op.p,
|
|
|
+ length: op.c.length,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ return
|
|
|
}
|
|
|
- if (op.i == null && op.d == null) {
|
|
|
+
|
|
|
+ if (!isInsert(op) && !isDelete(op)) {
|
|
|
throw new Errors.UnexpectedOpTypeError('unexpected op type', { op })
|
|
|
}
|
|
|
|
|
|
@@ -175,7 +262,7 @@ class TextOperationsBuilder {
|
|
|
const pos = Math.min(op.p, this.docLength)
|
|
|
|
|
|
if (pos < this.cursor) {
|
|
|
- this.pushCurrentOperation()
|
|
|
+ this.pushTextOperation()
|
|
|
// At this point, this.cursor === 0 and we can continue
|
|
|
}
|
|
|
|
|
|
@@ -183,47 +270,72 @@ class TextOperationsBuilder {
|
|
|
this.retain(pos - this.cursor)
|
|
|
}
|
|
|
|
|
|
- if (op.i != null) {
|
|
|
+ if (isInsert(op)) {
|
|
|
this.insert(op.i)
|
|
|
}
|
|
|
|
|
|
- if (op.d != null) {
|
|
|
+ if (isDelete(op)) {
|
|
|
this.delete(op.d.length)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
retain(length) {
|
|
|
- this.currentOperation.push(length)
|
|
|
+ this.textOperation.push(length)
|
|
|
this.cursor += length
|
|
|
}
|
|
|
|
|
|
insert(str) {
|
|
|
- this.currentOperation.push(str)
|
|
|
+ this.textOperation.push(str)
|
|
|
this.cursor += str.length
|
|
|
this.docLength += str.length
|
|
|
}
|
|
|
|
|
|
delete(length) {
|
|
|
- this.currentOperation.push(-length)
|
|
|
+ this.textOperation.push(-length)
|
|
|
this.docLength -= length
|
|
|
}
|
|
|
|
|
|
- pushCurrentOperation() {
|
|
|
- if (this.cursor < this.docLength) {
|
|
|
- this.retain(this.docLength - this.cursor)
|
|
|
- }
|
|
|
- if (this.currentOperation.length > 0) {
|
|
|
+ pushTextOperation() {
|
|
|
+ if (this.textOperation.length > 0)
|
|
|
+ if (this.cursor < this.docLength) {
|
|
|
+ this.retain(this.docLength - this.cursor)
|
|
|
+ }
|
|
|
+ if (this.textOperation.length > 0) {
|
|
|
this.operations.push({
|
|
|
pathname: this.pathname,
|
|
|
- textOperation: this.currentOperation,
|
|
|
+ textOperation: this.textOperation,
|
|
|
})
|
|
|
- this.currentOperation = []
|
|
|
+ this.textOperation = []
|
|
|
}
|
|
|
this.cursor = 0
|
|
|
}
|
|
|
|
|
|
finish() {
|
|
|
- this.pushCurrentOperation()
|
|
|
+ this.pushTextOperation()
|
|
|
return this.operations
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Op} op
|
|
|
+ * @returns {op is InsertOp}
|
|
|
+ */
|
|
|
+function isInsert(op) {
|
|
|
+ return 'i' in op && op.i != null
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Op} op
|
|
|
+ * @returns {op is DeleteOp}
|
|
|
+ */
|
|
|
+function isDelete(op) {
|
|
|
+ return 'd' in op && op.d != null
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {Op} op
|
|
|
+ * @returns {op is CommentOp}
|
|
|
+ */
|
|
|
+function isComment(op) {
|
|
|
+ return 'c' in op && op.c != null && 't' in op && op.t != null
|
|
|
+}
|