first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
// @ts-check
const core = require('../../index')
const Comment = require('../comment')
const Range = require('../range')
const EditOperation = require('./edit_operation')
/**
* @import DeleteCommentOperation from './delete_comment_operation'
* @import { CommentRawData, RawAddCommentOperation } from '../types'
* @import StringFileData from '../file_data/string_file_data'
*/
/**
* @extends EditOperation
*/
class AddCommentOperation extends EditOperation {
/**
* @param {string} commentId
* @param {ReadonlyArray<Range>} ranges
* @param {boolean} resolved
*/
constructor(commentId, ranges, resolved = false) {
super()
for (const range of ranges) {
if (range.isEmpty()) {
throw new Error("AddCommentOperation can't be built with empty ranges")
}
}
/** @readonly */
this.commentId = commentId
/** @readonly */
this.ranges = ranges
/** @readonly */
this.resolved = resolved
}
/**
*
* @returns {RawAddCommentOperation}
*/
toJSON() {
/** @type RawAddCommentOperation */
const raw = {
commentId: this.commentId,
ranges: this.ranges.map(range => range.toRaw()),
}
if (this.resolved) {
raw.resolved = true
}
return raw
}
/**
* @param {StringFileData} fileData
*/
apply(fileData) {
fileData.comments.add(
new Comment(this.commentId, this.ranges, this.resolved)
)
}
/**
* @inheritdoc
* @param {StringFileData} previousState
* @returns {EditOperation}
*/
invert(previousState) {
const comment = previousState.comments.getComment(this.commentId)
if (!comment) {
return new core.DeleteCommentOperation(this.commentId)
}
return new core.AddCommentOperation(
comment.id,
comment.ranges.slice(),
comment.resolved
)
}
/**
* @inheritdoc
* @param {EditOperation} other
* @returns {boolean}
*/
canBeComposedWith(other) {
return (
(other instanceof AddCommentOperation &&
this.commentId === other.commentId) ||
(other instanceof core.DeleteCommentOperation &&
this.commentId === other.commentId) ||
(other instanceof core.SetCommentStateOperation &&
this.commentId === other.commentId)
)
}
/**
* @inheritdoc
* @param {EditOperation} other
* @returns {EditOperation}
*/
compose(other) {
if (
other instanceof core.DeleteCommentOperation &&
other.commentId === this.commentId
) {
return other
}
if (
other instanceof AddCommentOperation &&
other.commentId === this.commentId
) {
return other
}
if (
other instanceof core.SetCommentStateOperation &&
other.commentId === this.commentId
) {
return new AddCommentOperation(
this.commentId,
this.ranges,
other.resolved
)
}
throw new Error(
`Trying to compose AddCommentOperation with ${other?.constructor?.name}.`
)
}
/**
* @inheritdoc
* @param {RawAddCommentOperation} raw
* @returns {AddCommentOperation}
*/
static fromJSON(raw) {
return new AddCommentOperation(
raw.commentId,
raw.ranges.map(Range.fromRaw),
raw.resolved ?? false
)
}
}
module.exports = AddCommentOperation

View File

@@ -0,0 +1,78 @@
'use strict'
const assert = require('check-types').assert
const File = require('../file')
const Operation = require('./')
/**
* Adds a new file to a project.
*/
class AddFileOperation extends Operation {
/**
* @param {string} pathname
* @param {File} file
*/
constructor(pathname, file) {
super()
assert.string(pathname, 'bad pathname')
assert.object(file, 'bad file')
this.pathname = pathname
this.file = file
}
/**
* @return {String}
*/
getPathname() {
return this.pathname
}
/**
* TODO
* @param {Object} raw
* @return {AddFileOperation}
*/
static fromRaw(raw) {
return new AddFileOperation(raw.pathname, File.fromRaw(raw.file))
}
/**
* @inheritdoc
*/
toRaw() {
return { pathname: this.pathname, file: this.file.toRaw() }
}
/**
* @inheritdoc
*/
getFile() {
return this.file
}
/** @inheritdoc */
findBlobHashes(blobHashes) {
const hash = this.file.getHash()
if (hash) blobHashes.add(hash)
}
/** @inheritdoc */
async loadFiles(kind, blobStore) {
return await this.file.load(kind, blobStore)
}
async store(blobStore) {
const rawFile = await this.file.store(blobStore)
return { pathname: this.pathname, file: rawFile }
}
/**
* @inheritdoc
*/
applyTo(snapshot) {
snapshot.addFile(this.pathname, this.file.clone())
}
}
module.exports = AddFileOperation

View File

@@ -0,0 +1,70 @@
// @ts-check
const core = require('../../index')
const EditNoOperation = require('./edit_no_operation')
const EditOperation = require('./edit_operation')
/**
* @import AddCommentOperation from './add_comment_operation'
* @import StringFileData from '../file_data/string_file_data'
* @import { RawDeleteCommentOperation } from '../types'
*/
/**
* @extends EditOperation
*/
class DeleteCommentOperation extends EditOperation {
/**
* @param {string} commentId
*/
constructor(commentId) {
super()
this.commentId = commentId
}
/**
* @inheritdoc
* @returns {RawDeleteCommentOperation}
*/
toJSON() {
return {
deleteComment: this.commentId,
}
}
/**
* @inheritdoc
* @param {StringFileData} fileData
*/
apply(fileData) {
fileData.comments.delete(this.commentId)
}
/**
* @inheritdoc
* @param {StringFileData} previousState
* @returns {AddCommentOperation | EditNoOperation}
*/
invert(previousState) {
const comment = previousState.comments.getComment(this.commentId)
if (!comment) {
return new EditNoOperation()
}
return new core.AddCommentOperation(
comment.id,
comment.ranges.slice(),
comment.resolved
)
}
/**
* @inheritdoc
* @param {RawDeleteCommentOperation} raw
* @returns {DeleteCommentOperation}
*/
static fromJSON(raw) {
return new DeleteCommentOperation(raw.deleteComment)
}
}
module.exports = DeleteCommentOperation

View File

@@ -0,0 +1,105 @@
// @ts-check
'use strict'
/**
* @import EditOperation from './edit_operation'
* @import { RawEditFileOperation } from '../types'
* @import Snapshot from "../snapshot"
*/
const Operation = require('./')
const EditOperationBuilder = require('./edit_operation_builder')
/**
* Edit a file in place. It is a wrapper around a single EditOperation.
*/
class EditFileOperation extends Operation {
/**
* @param {string} pathname
* @param {EditOperation} operation
*/
constructor(pathname, operation) {
super()
this.pathname = pathname
this.operation = operation
}
/**
* @inheritdoc
*/
toRaw() {
return {
pathname: this.pathname,
...this.operation.toJSON(),
}
}
/**
* Deserialize an EditFileOperation.
*
* @param {RawEditFileOperation} raw
* @return {EditFileOperation}
*/
static fromRaw(raw) {
return new EditFileOperation(
raw.pathname,
EditOperationBuilder.fromJSON(raw)
)
}
getPathname() {
return this.pathname
}
getOperation() {
return this.operation
}
/**
* @inheritdoc
* @param {Snapshot} snapshot
*/
applyTo(snapshot) {
// TODO(das7pad): can we teach typescript our polymorphism?
// @ts-ignore
snapshot.editFile(this.pathname, this.operation)
}
/**
* @inheritdoc
* @param {Operation} other
* @return {boolean}
*/
canBeComposedWithForUndo(other) {
return (
this.canBeComposedWith(other) &&
this.operation.canBeComposedWithForUndo(other.operation)
)
}
/**
* @inheritdoc
* @param {Operation} other
* @return {other is EditFileOperation}
*/
canBeComposedWith(other) {
// Ensure that other operation is an edit file operation
if (!(other instanceof EditFileOperation)) return false
// Ensure that both operations are editing the same file
if (this.getPathname() !== other.getPathname()) return false
return this.operation.canBeComposedWith(other.operation)
}
/**
* @inheritdoc
* @param {EditFileOperation} other
*/
compose(other) {
return new EditFileOperation(
this.pathname,
this.operation.compose(other.operation)
)
}
}
module.exports = EditFileOperation

View File

@@ -0,0 +1,29 @@
const EditOperation = require('./edit_operation')
/**
* @import { RawEditNoOperation } from '../types'
*/
class EditNoOperation extends EditOperation {
/**
* @inheritdoc
* @param {StringFileData} fileData
*/
apply(fileData) {}
/**
* @inheritdoc
* @returns {RawEditNoOperation}
*/
toJSON() {
return {
noOp: true,
}
}
static fromJSON() {
return new EditNoOperation()
}
}
module.exports = EditNoOperation

View File

@@ -0,0 +1,91 @@
// @ts-check
/**
* @import FileData from '../file_data'
* @import { RawEditOperation } from '../types'
*/
class EditOperation {
constructor() {
if (this.constructor === EditOperation) {
throw new Error('Cannot instantiate abstract class')
}
}
/**
* Converts operation into a JSON value.
* @returns {RawEditOperation}
*/
toJSON() {
throw new Error('Abstract method not implemented')
}
/**
* @abstract
* @param {FileData} fileData
*/
apply(fileData) {
throw new Error('Abstract method not implemented')
}
/**
* Determine the effect of this operation on the length of the text.
*
* NB: This is an Overleaf addition to the original OT system.
*
* @param {number} length of the original string; non-negative
* @return {number} length of the new string; non-negative
*/
applyToLength(length) {
return length
}
/**
* Computes the inverse of an operation. The inverse of an operation is the
* operation that reverts the effects of the operation, e.g. when you have an
* operation 'insert("hello "); skip(6);' then the inverse is 'remove("hello ");
* skip(6);'. The inverse should be used for implementing undo.
* @param {FileData} previousState
* @returns {EditOperation}
*/
invert(previousState) {
throw new Error('Abstract method not implemented')
}
/**
*
* @param {EditOperation} other
* @returns {boolean}
*/
canBeComposedWith(other) {
return false
}
/**
* When you use ctrl-z to undo your latest changes, you expect the program not
* to undo every single keystroke but to undo your last sentence you wrote at
* a stretch or the deletion you did by holding the backspace key down. This
* This can be implemented by composing operations on the undo stack. This
* method can help decide whether two operations should be composed. It
* returns true if the operations are consecutive insert operations or both
* operations delete text at the same position. You may want to include other
* factors like the time since the last change in your decision.
* @param {EditOperation} other
*/
canBeComposedWithForUndo(other) {
return false
}
/**
* Compose merges two consecutive operations into one operation, that
* preserves the changes of both. Or, in other words, for each input string S
* and a pair of consecutive operations A and B,
* apply(apply(S, A), B) = apply(S, compose(A, B)) must hold.
* @param {EditOperation} other
* @returns {EditOperation}
*/
compose(other) {
throw new Error('Abstract method not implemented')
}
}
module.exports = EditOperation

View File

@@ -0,0 +1,93 @@
// @ts-check
/**
* @import EditOperation from './edit_operation'
* @import { RawTextOperation, RawAddCommentOperation, RawEditOperation } from '../types'
* @import { RawDeleteCommentOperation, RawSetCommentStateOperation } from '../types'
*/
const DeleteCommentOperation = require('./delete_comment_operation')
const AddCommentOperation = require('./add_comment_operation')
const TextOperation = require('./text_operation')
const SetCommentStateOperation = require('./set_comment_state_operation')
const EditNoOperation = require('./edit_no_operation')
class EditOperationBuilder {
/**
*
* @param {RawEditOperation} raw
* @returns {EditOperation}
*/
static fromJSON(raw) {
if (isTextOperation(raw)) {
return TextOperation.fromJSON(raw)
}
if (isRawAddCommentOperation(raw)) {
return AddCommentOperation.fromJSON(raw)
}
if (isRawDeleteCommentOperation(raw)) {
return DeleteCommentOperation.fromJSON(raw)
}
if (isRawSetCommentStateOperation(raw)) {
return SetCommentStateOperation.fromJSON(raw)
}
if (isRawEditNoOperation(raw)) {
return EditNoOperation.fromJSON()
}
throw new Error('Unsupported operation in EditOperationBuilder.fromJSON')
}
}
/**
* @param {unknown} raw
* @returns {raw is RawTextOperation}
*/
function isTextOperation(raw) {
return raw !== null && typeof raw === 'object' && 'textOperation' in raw
}
/**
* @param {unknown} raw
* @returns {raw is RawAddCommentOperation}
*/
function isRawAddCommentOperation(raw) {
return (
raw !== null &&
typeof raw === 'object' &&
'commentId' in raw &&
'ranges' in raw &&
Array.isArray(raw.ranges)
)
}
/**
* @param {unknown} raw
* @returns {raw is RawDeleteCommentOperation}
*/
function isRawDeleteCommentOperation(raw) {
return raw !== null && typeof raw === 'object' && 'deleteComment' in raw
}
/**
* @param {unknown} raw
* @returns {raw is RawSetCommentStateOperation}
*/
function isRawSetCommentStateOperation(raw) {
return (
raw !== null &&
typeof raw === 'object' &&
'commentId' in raw &&
'resolved' in raw &&
typeof raw.resolved === 'boolean'
)
}
/**
* @param {unknown} raw
* @returns {raw is RawEditNoOperation}
*/
function isRawEditNoOperation(raw) {
return raw !== null && typeof raw === 'object' && 'noOp' in raw
}
module.exports = EditOperationBuilder

View File

@@ -0,0 +1,162 @@
// @ts-check
const core = require('../..')
const Comment = require('../comment')
const EditNoOperation = require('./edit_no_operation')
const TextOperation = require('./text_operation')
/**
* @import EditOperation from './edit_operation'
*/
class EditOperationTransformer {
/**
* Transform two edit operations against each other.
* @param {EditOperation} a
* @param {EditOperation} b
* @returns {[EditOperation, EditOperation]}
*/
static transform(a, b) {
const {
AddCommentOperation,
DeleteCommentOperation,
SetCommentStateOperation,
} = core
if (a instanceof EditNoOperation || b instanceof EditNoOperation) {
return [a, b]
}
const transformers = [
createTransformer(TextOperation, TextOperation, TextOperation.transform),
createTransformer(TextOperation, DeleteCommentOperation, noConflict),
createTransformer(TextOperation, SetCommentStateOperation, noConflict),
createTransformer(TextOperation, AddCommentOperation, (a, b) => {
// apply the text operation to the comment
const originalComment = new Comment(b.commentId, b.ranges, b.resolved)
const movedComment = originalComment.applyTextOperation(a, b.commentId)
return [
a,
new AddCommentOperation(
movedComment.id,
movedComment.ranges,
movedComment.resolved
),
]
}),
createTransformer(AddCommentOperation, AddCommentOperation, (a, b) => {
if (a.commentId === b.commentId) {
return [new EditNoOperation(), b]
}
return [a, b]
}),
createTransformer(AddCommentOperation, DeleteCommentOperation, (a, b) => {
if (a.commentId === b.commentId) {
// delete wins
return [new EditNoOperation(), b]
}
return [a, b]
}),
createTransformer(
AddCommentOperation,
SetCommentStateOperation,
(a, b) => {
if (a.commentId === b.commentId) {
const newA = new AddCommentOperation(
a.commentId,
a.ranges,
b.resolved
)
return [newA, b]
}
return [a, b]
}
),
createTransformer(
DeleteCommentOperation,
DeleteCommentOperation,
(a, b) => {
if (a.commentId === b.commentId) {
// if both operations delete the same comment, we can ignore both
return [new EditNoOperation(), new EditNoOperation()]
}
return [a, b]
}
),
createTransformer(
DeleteCommentOperation,
SetCommentStateOperation,
(a, b) => {
if (a.commentId === b.commentId) {
// delete wins
return [a, new EditNoOperation()]
}
return [a, b]
}
),
createTransformer(
SetCommentStateOperation,
SetCommentStateOperation,
(a, b) => {
if (a.commentId !== b.commentId) {
return [a, b]
}
if (a.resolved === b.resolved) {
return [new EditNoOperation(), new EditNoOperation()]
}
const shouldResolve = a.resolved && b.resolved
if (a.resolved === shouldResolve) {
return [a, new EditNoOperation()]
} else {
return [new EditNoOperation(), b]
}
}
),
]
for (const transformer of transformers) {
const result = transformer(a, b)
if (result) {
return result
}
}
throw new Error(
`Transform not implemented for ${a.constructor.name}${b.constructor.name}`
)
}
}
/**
* @template {EditOperation} X
* @template {EditOperation} Y
* @param {new(...args: any[]) => X} ClassA
* @param {new(...args: any[]) => Y} ClassB
* @param {(a: X, b: Y) => [EditOperation, EditOperation]} transformer
* @returns {(a: EditOperation, b: EditOperation) => [EditOperation, EditOperation] | false}
*/
function createTransformer(ClassA, ClassB, transformer) {
return (a, b) => {
if (a instanceof ClassA && b instanceof ClassB) {
return transformer(a, b)
}
if (b instanceof ClassA && a instanceof ClassB) {
const [bPrime, aPrime] = transformer(b, a)
return [aPrime, bPrime]
}
return false
}
}
/**
*
* @param {EditOperation} a
* @param {EditOperation} b
* @returns {[EditOperation, EditOperation]}
*/
function noConflict(a, b) {
return [a, b]
}
module.exports = EditOperationTransformer

View File

@@ -0,0 +1,462 @@
'use strict'
const _ = require('lodash')
const assert = require('check-types').assert
const EditOperationTransformer = require('./edit_operation_transformer')
// Dependencies are loaded at the bottom of the file to mitigate circular
// dependency
let NoOperation = null
let AddFileOperation = null
let MoveFileOperation = null
let EditFileOperation = null
let SetFileMetadataOperation = null
/**
* @import { BlobStore } from "../types"
* @import Snapshot from "../snapshot"
*/
/**
* An `Operation` changes a `Snapshot` when it is applied. See the
* {@tutorial OT} tutorial for background.
*/
class Operation {
/**
* Deserialize an Operation.
*
* @param {Object} raw
* @return {Operation} one of the subclasses
*/
static fromRaw(raw) {
if ('file' in raw) {
return AddFileOperation.fromRaw(raw)
}
if (
'textOperation' in raw ||
'commentId' in raw ||
'deleteComment' in raw
) {
return EditFileOperation.fromRaw(raw)
}
if ('newPathname' in raw) {
return new MoveFileOperation(raw.pathname, raw.newPathname)
}
if ('metadata' in raw) {
return new SetFileMetadataOperation(raw.pathname, raw.metadata)
}
if (_.isEmpty(raw)) {
return new NoOperation()
}
throw new Error('invalid raw operation ' + JSON.stringify(raw))
}
/**
* Serialize an Operation.
*
* @return {Object}
*/
toRaw() {
return {}
}
/**
* Whether this operation does nothing when applied.
*
* @return {Boolean}
*/
isNoOp() {
return false
}
/**
* If this Operation references blob hashes, add them to the given Set.
*
* @param {Set.<String>} blobHashes
*/
findBlobHashes(blobHashes) {}
/**
* If this operation references any files, load the files.
*
* @param {string} kind see {File#load}
* @param {BlobStore} blobStore
* @return {Promise<void>}
*/
async loadFiles(kind, blobStore) {}
/**
* Return a version of this operation that is suitable for long term storage.
* In most cases, we just need to convert the operation to raw form, but if
* the operation involves File objects, we may need to store their content.
*
* @param {BlobStore} blobStore
* @return {Promise.<Object>}
*/
async store(blobStore) {
return this.toRaw()
}
/**
* Apply this Operation to a snapshot.
*
* The snapshot is modified in place.
*
* @param {Snapshot} snapshot
*/
applyTo(snapshot) {
assert.object(snapshot, 'bad snapshot')
}
/**
* Whether this operation can be composed with another operation to produce a
* single operation of the same type as this one, while keeping the composed
* operation small and logical enough to be used in the undo stack.
*
* @param {Operation} other
* @return {Boolean}
*/
canBeComposedWithForUndo(other) {
return false
}
/**
* Whether this operation can be composed with another operation to produce a
* single operation of the same type as this one.
*
* TODO Moves can be composed. For example, if you rename a to b and then decide
* shortly after that actually you want to call it c, we could compose the two
* to get a -> c). Edits can also be composed --- see rules in TextOperation.
* We also need to consider the Change --- we will need to consider both time
* and author(s) when composing changes. I guess that AddFile can also be
* composed in some cases --- if you upload a file and then decide it was the
* wrong one and upload a new one, we could drop the one in the middle, but
* that seems like a pretty rare case.
*
* @param {Operation} other
* @return {Boolean}
*/
canBeComposedWith(other) {
return false
}
/**
* Compose this operation with another operation to produce a single operation
* of the same type as this one.
*
* @param {Operation} other
* @return {Operation}
*/
compose(other) {
throw new Error('not implemented')
}
/**
* Transform takes two operations A and B that happened concurrently and
* produces two operations A' and B' (in an array) such that
* `apply(apply(S, A), B') = apply(apply(S, B), A')`.
*
* That is, if one client applies A and then B', they get the same result as
* another client who applies B and then A'.
*
* @param {Operation} a
* @param {Operation} b
* @return {Operation[]} operations `[a', b']`
*/
static transform(a, b) {
if (a.isNoOp() || b.isNoOp()) return [a, b]
function transpose(transformer) {
return transformer(b, a).reverse()
}
const bIsAddFile = b instanceof AddFileOperation
const bIsEditFile = b instanceof EditFileOperation
const bIsMoveFile = b instanceof MoveFileOperation
const bIsSetFileMetadata = b instanceof SetFileMetadataOperation
if (a instanceof AddFileOperation) {
if (bIsAddFile) return transformAddFileAddFile(a, b)
if (bIsMoveFile) return transformAddFileMoveFile(a, b)
if (bIsEditFile) return transformAddFileEditFile(a, b)
if (bIsSetFileMetadata) return transformAddFileSetFileMetadata(a, b)
throw new Error('bad op b')
}
if (a instanceof MoveFileOperation) {
if (bIsAddFile) return transpose(transformAddFileMoveFile)
if (bIsMoveFile) return transformMoveFileMoveFile(a, b)
if (bIsEditFile) return transformMoveFileEditFile(a, b)
if (bIsSetFileMetadata) return transformMoveFileSetFileMetadata(a, b)
throw new Error('bad op b')
}
if (a instanceof EditFileOperation) {
if (bIsAddFile) return transpose(transformAddFileEditFile)
if (bIsMoveFile) return transpose(transformMoveFileEditFile)
if (bIsEditFile) return transformEditFileEditFile(a, b)
if (bIsSetFileMetadata) return transformEditFileSetFileMetadata(a, b)
throw new Error('bad op b')
}
if (a instanceof SetFileMetadataOperation) {
if (bIsAddFile) return transpose(transformAddFileSetFileMetadata)
if (bIsMoveFile) return transpose(transformMoveFileSetFileMetadata)
if (bIsEditFile) return transpose(transformEditFileSetFileMetadata)
if (bIsSetFileMetadata) return transformSetFileMetadatas(a, b)
throw new Error('bad op b')
}
throw new Error('bad op a')
}
/**
* Transform each operation in `a` by each operation in `b` and save the primed
* operations in place.
*
* @param {Array.<Operation>} as - modified in place
* @param {Array.<Operation>} bs - modified in place
*/
static transformMultiple(as, bs) {
for (let i = 0; i < as.length; ++i) {
for (let j = 0; j < bs.length; ++j) {
const primes = Operation.transform(as[i], bs[j])
as[i] = primes[0]
bs[j] = primes[1]
}
}
}
static addFile(pathname, file) {
return new AddFileOperation(pathname, file)
}
static editFile(pathname, editOperation) {
return new EditFileOperation(pathname, editOperation)
}
static moveFile(pathname, newPathname) {
return new MoveFileOperation(pathname, newPathname)
}
static removeFile(pathname) {
return new MoveFileOperation(pathname, '')
}
static setFileMetadata(pathname, metadata) {
return new SetFileMetadataOperation(pathname, metadata)
}
}
//
// Transform
//
// The way to read these transform functions is that
// 1. return_value[0] is the op to be applied after arguments[1], and
// 2. return_value[1] is the op to be applied after arguments[0],
// in order to arrive at the same project state.
//
function transformAddFileAddFile(add1, add2) {
if (add1.getPathname() === add2.getPathname()) {
return [Operation.NO_OP, add2] // add2 wins
}
return [add1, add2]
}
function transformAddFileMoveFile(add, move) {
function relocateAddFile() {
return new AddFileOperation(move.getNewPathname(), add.getFile().clone())
}
if (add.getPathname() === move.getPathname()) {
if (move.isRemoveFile()) {
return [add, Operation.NO_OP]
}
return [
relocateAddFile(),
new MoveFileOperation(add.getPathname(), move.getNewPathname()),
]
}
if (add.getPathname() === move.getNewPathname()) {
return [relocateAddFile(), new MoveFileOperation(move.getPathname(), '')]
}
return [add, move]
}
function transformAddFileEditFile(add, edit) {
if (add.getPathname() === edit.getPathname()) {
return [add, Operation.NO_OP] // the add wins
}
return [add, edit]
}
function transformAddFileSetFileMetadata(add, set) {
if (add.getPathname() === set.getPathname()) {
const newFile = add.getFile().clone()
newFile.setMetadata(set.getMetadata())
return [new AddFileOperation(add.getPathname(), newFile), set]
}
return [add, set]
}
//
// This is one of the trickier ones. There are 15 possible equivalence
// relationships between our four variables:
//
// path1, newPath1, path2, newPath2 --- "same move" (all equal)
//
// path1, newPath1, path2 | newPath2 --- "no-ops" (1)
// path1, newPath1, newPath2 | path2 --- "no-ops" (1)
// path1, path2, newPath2 | newPath1 --- "no-ops" (2)
// newPath1, path2, newPath2 | path1 --- "no-ops" (2)
//
// path1, newPath1 | path2, newPath2 --- "no-ops" (1 and 2)
// path1, path2 | newPath1, newPath2 --- "same move"
// path1, newPath2 | newPath1, path2 --- "opposite moves"
//
// path1, newPath1 | path2 | newPath2 --- "no-ops" (1)
// path1, path2 | newPath1 | newPath2 --- "divergent moves"
// path1, newPath2 | newPath1 | path2 --- "transitive move"
// newPath1, path2 | path1 | newPath2 --- "transitive move"
// newPath1, newPath2 | path1 | path2 --- "convergent move"
// path2, newPath2 | path1 | newPath1 --- "no-ops" (2)
//
// path1 | newPath1 | path2 | newPath2 --- "no conflict"
//
function transformMoveFileMoveFile(move1, move2) {
const path1 = move1.getPathname()
const path2 = move2.getPathname()
const newPath1 = move1.getNewPathname()
const newPath2 = move2.getNewPathname()
// the same move
if (path1 === path2 && newPath1 === newPath2) {
return [Operation.NO_OP, Operation.NO_OP]
}
// no-ops
if (path1 === newPath1 && path2 === newPath2) {
return [Operation.NO_OP, Operation.NO_OP]
}
if (path1 === newPath1) {
return [Operation.NO_OP, move2]
}
if (path2 === newPath2) {
return [move1, Operation.NO_OP]
}
// opposite moves (foo -> bar, bar -> foo)
if (path1 === newPath2 && path2 === newPath1) {
// We can't handle this very well: if we wanted move2 (say) to win, move2'
// would have to be addFile(foo) with the content of bar, but we don't have
// the content of bar available here. So, we just destroy both files.
return [Operation.removeFile(path1), Operation.removeFile(path2)]
}
// divergent moves (foo -> bar, foo -> baz); convention: move2 wins
if (path1 === path2 && newPath1 !== newPath2) {
return [Operation.NO_OP, Operation.moveFile(newPath1, newPath2)]
}
// convergent move (foo -> baz, bar -> baz); convention: move2 wins
if (newPath1 === newPath2 && path1 !== path2) {
return [Operation.removeFile(path1), move2]
}
// transitive move:
// 1: foo -> baz, 2: bar -> foo (result: bar -> baz) or
// 1: foo -> bar, 2: bar -> baz (result: foo -> baz)
if (path1 === newPath2 && newPath1 !== path2) {
return [
Operation.moveFile(newPath2, newPath1),
Operation.moveFile(path2, newPath1),
]
}
if (newPath1 === path2 && path1 !== newPath2) {
return [
Operation.moveFile(path1, newPath2),
Operation.moveFile(newPath1, newPath2),
]
}
// no conflict
return [move1, move2]
}
function transformMoveFileEditFile(move, edit) {
if (move.getPathname() === edit.getPathname()) {
if (move.isRemoveFile()) {
// let the remove win
return [move, Operation.NO_OP]
}
return [
move,
Operation.editFile(move.getNewPathname(), edit.getOperation()),
]
}
if (move.getNewPathname() === edit.getPathname()) {
// let the move win
return [move, Operation.NO_OP]
}
return [move, edit]
}
function transformMoveFileSetFileMetadata(move, set) {
if (move.getPathname() === set.getPathname()) {
return [
move,
Operation.setFileMetadata(move.getNewPathname(), set.getMetadata()),
]
}
// A: mv foo -> bar
// B: set bar.x
//
// A': mv foo -> bar
// B': nothing
if (move.getNewPathname() === set.getPathname()) {
return [move, Operation.NO_OP] // let the move win
}
return [move, set]
}
function transformEditFileEditFile(edit1, edit2) {
if (edit1.getPathname() === edit2.getPathname()) {
const primeOps = EditOperationTransformer.transform(
edit1.getOperation(),
edit2.getOperation()
)
return [
Operation.editFile(edit1.getPathname(), primeOps[0]),
Operation.editFile(edit2.getPathname(), primeOps[1]),
]
}
return [edit1, edit2]
}
function transformEditFileSetFileMetadata(edit, set) {
// There is no conflict.
return [edit, set]
}
function transformSetFileMetadatas(set1, set2) {
if (set1.getPathname() === set2.getPathname()) {
return [Operation.NO_OP, set2] // set2 wins
}
return [set1, set2]
}
module.exports = Operation
// Work around circular import
NoOperation = require('./no_operation')
AddFileOperation = require('./add_file_operation')
MoveFileOperation = require('./move_file_operation')
EditFileOperation = require('./edit_file_operation')
SetFileMetadataOperation = require('./set_file_metadata_operation')
Operation.NO_OP = new NoOperation()

View File

@@ -0,0 +1,54 @@
'use strict'
const Operation = require('./')
/**
* Moves or removes a file from a project.
*/
class MoveFileOperation extends Operation {
/**
* @param {string} pathname
* @param {string} newPathname
*/
constructor(pathname, newPathname) {
super()
this.pathname = pathname
this.newPathname = newPathname
}
/**
* @inheritdoc
*/
toRaw() {
return {
pathname: this.pathname,
newPathname: this.newPathname,
}
}
getPathname() {
return this.pathname
}
getNewPathname() {
return this.newPathname
}
/**
* Whether this operation is a MoveFile operation that deletes the file.
*
* @return {boolean}
*/
isRemoveFile() {
return this.getNewPathname() === ''
}
/**
* @inheritdoc
*/
applyTo(snapshot) {
snapshot.moveFile(this.getPathname(), this.getNewPathname())
}
}
module.exports = MoveFileOperation

View File

@@ -0,0 +1,20 @@
'use strict'
const Operation = require('./')
/**
* An explicit no-operation.
*
* There are several no-ops, such as moving a file to itself, but it's useful
* to have a generic no-op as well.
*/
class NoOperation extends Operation {
/**
* @inheritdoc
*/
isNoOp() {
return true
}
}
module.exports = NoOperation

View File

@@ -0,0 +1,457 @@
// @ts-check
const { containsNonBmpChars } = require('../util')
const {
ApplyError,
InvalidInsertionError,
UnprocessableError,
} = require('../errors')
const ClearTrackingProps = require('../file_data/clear_tracking_props')
const TrackingProps = require('../file_data/tracking_props')
/**
* @import { RawScanOp, RawInsertOp, RawRetainOp, RawRemoveOp, TrackingDirective } from '../types'
*
* @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext
*/
class ScanOp {
constructor() {
if (this.constructor === ScanOp) {
throw new Error('Cannot instantiate abstract class')
}
}
/**
* Applies an operation to a length
* @param {LengthApplyContext} current
* @returns {LengthApplyContext}
*/
applyToLength(current) {
throw new Error('abstract method')
}
/**
* @returns {RawScanOp}
*/
toJSON() {
throw new Error('abstract method')
}
/**
* @param {RawScanOp} raw
* @returns {ScanOp}
*/
static fromJSON(raw) {
if (isRetain(raw)) {
return RetainOp.fromJSON(raw)
} else if (isInsert(raw)) {
return InsertOp.fromJSON(raw)
} else if (isRemove(raw)) {
return RemoveOp.fromJSON(raw)
}
throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`)
}
/**
* Tests whether two ScanOps are equal
* @param {ScanOp} _other
* @returns {boolean}
*/
equals(_other) {
return false
}
/**
* Tests whether two ScanOps can be merged into a single operation
* @param {ScanOp} other
* @returns
*/
canMergeWith(other) {
return false
}
/**
* Merge two ScanOps into a single operation
* @param {ScanOp} _other
* @returns {void}
*/
mergeWith(_other) {
throw new Error('abstract method')
}
toString() {
'ScanOp'
}
}
class InsertOp extends ScanOp {
/**
*
* @param {string} insertion
* @param {TrackingProps | undefined} tracking
* @param {string[] | undefined} commentIds
*/
constructor(insertion, tracking = undefined, commentIds = undefined) {
super()
if (typeof insertion !== 'string') {
throw new InvalidInsertionError('insertion must be a string')
}
if (containsNonBmpChars(insertion)) {
throw new InvalidInsertionError('insertion contains non-BMP characters')
}
/** @type {string} */
this.insertion = insertion
/** @type {TrackingProps | undefined} */
this.tracking = tracking
/** @type {string[] | undefined} */
this.commentIds = commentIds
}
/**
*
* @param {RawInsertOp} op
* @returns {InsertOp}
*/
static fromJSON(op) {
if (typeof op === 'string') {
return new InsertOp(op)
}
// It must be an object with an 'i' property.
if (typeof op.i !== 'string') {
throw new InvalidInsertionError(
'insert operation must have a string property'
)
}
return new InsertOp(
op.i,
op.tracking && TrackingProps.fromRaw(op.tracking),
op.commentIds
)
}
/**
* @inheritdoc
* @param {LengthApplyContext} current
* @returns {LengthApplyContext}
*/
applyToLength(current) {
current.length += this.insertion.length
return current
}
/** @inheritdoc
* @param {ScanOp} other
*/
equals(other) {
if (!(other instanceof InsertOp)) {
return false
}
if (this.insertion !== other.insertion) {
return false
}
if (this.tracking) {
if (!this.tracking.equals(other.tracking)) {
return false
}
} else if (other.tracking) {
return false
}
if (this.commentIds) {
return (
this.commentIds.length === other.commentIds?.length &&
this.commentIds.every(id => other.commentIds?.includes(id))
)
}
return !other.commentIds
}
/**
* @param {ScanOp} other
* @return {other is InsertOp}
*/
canMergeWith(other) {
if (!(other instanceof InsertOp)) {
return false
}
if (this.tracking) {
if (!this.tracking.equals(other.tracking)) {
return false
}
} else if (other.tracking) {
return false
}
if (this.commentIds) {
return (
this.commentIds.length === other.commentIds?.length &&
this.commentIds.every(id => other.commentIds?.includes(id))
)
}
return !other.commentIds
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')
}
this.insertion += other.insertion
// We already have the same tracking info and commentIds
}
/**
* @returns {RawInsertOp}
*/
toJSON() {
if (!this.tracking && !this.commentIds) {
return this.insertion
}
/** @type RawInsertOp */
const obj = { i: this.insertion }
if (this.tracking) {
obj.tracking = this.tracking.toRaw()
}
if (this.commentIds) {
obj.commentIds = this.commentIds
}
return obj
}
toString() {
return `insert '${this.insertion}'`
}
}
class RetainOp extends ScanOp {
/**
* @param {number} length
* @param {TrackingDirective | undefined} tracking
*/
constructor(length, tracking = undefined) {
super()
if (length < 0) {
throw new Error('length must be non-negative')
}
/** @type {number} */
this.length = length
/** @type {TrackingDirective | undefined} */
this.tracking = tracking
}
/**
* @inheritdoc
* @param {LengthApplyContext} current
* @returns {LengthApplyContext}
*/
applyToLength(current) {
if (current.inputCursor + this.length > current.inputLength) {
throw new ApplyError(
"Operation can't retain more chars than are left in the string.",
this.toJSON(),
current.inputLength
)
}
current.length += this.length
current.inputCursor += this.length
return current
}
/**
*
* @param {RawRetainOp} op
* @returns {RetainOp}
*/
static fromJSON(op) {
if (typeof op === 'number') {
return new RetainOp(op)
}
// It must be an object with a 'r' property.
if (typeof op.r !== 'number') {
throw new Error('retain operation must have a number property')
}
if (op.tracking) {
const tracking =
op.tracking.type === 'none'
? new ClearTrackingProps()
: TrackingProps.fromRaw(op.tracking)
return new RetainOp(op.r, tracking)
}
return new RetainOp(op.r)
}
/** @inheritdoc
* @param {ScanOp} other
*/
equals(other) {
if (!(other instanceof RetainOp)) {
return false
}
if (this.length !== other.length) {
return false
}
if (this.tracking) {
return this.tracking.equals(other.tracking)
}
return !other.tracking
}
/**
* @param {ScanOp} other
* @return {other is RetainOp}
*/
canMergeWith(other) {
if (!(other instanceof RetainOp)) {
return false
}
if (this.tracking) {
return this.tracking.equals(other.tracking)
}
return !other.tracking
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')
}
this.length += other.length
}
/**
* @returns {RawRetainOp}
*/
toJSON() {
if (!this.tracking) {
return this.length
}
return { r: this.length, tracking: this.tracking.toRaw() }
}
toString() {
return `retain ${this.length}`
}
}
class RemoveOp extends ScanOp {
/**
* @param {number} length
*/
constructor(length) {
super()
if (length < 0) {
throw new Error('length must be non-negative')
}
/** @type {number} */
this.length = length
}
/**
* @inheritdoc
* @param {LengthApplyContext} current
* @returns {LengthApplyContext}
*/
applyToLength(current) {
current.inputCursor += this.length
return current
}
/**
*
* @param {RawRemoveOp} op
* @returns {RemoveOp}
*/
static fromJSON(op) {
if (typeof op !== 'number' || op > 0) {
throw new Error('delete operation must be a negative number')
}
return new RemoveOp(-op)
}
/**
* @inheritdoc
* @param {ScanOp} other
* @return {boolean}
*/
equals(other) {
if (!(other instanceof RemoveOp)) {
return false
}
return this.length === other.length
}
/**
* @param {ScanOp} other
* @return {other is RemoveOp}
*/
canMergeWith(other) {
return other instanceof RemoveOp
}
/**
* @param {ScanOp} other
*/
mergeWith(other) {
if (!this.canMergeWith(other)) {
throw new Error('Cannot merge with incompatible operation')
}
this.length += other.length
}
/**
* @returns {RawRemoveOp}
*/
toJSON() {
return -this.length
}
toString() {
return `remove ${this.length}`
}
}
/**
* @param {RawScanOp} op
* @returns {op is RawRetainOp}
*/
function isRetain(op) {
return (
(typeof op === 'number' && op > 0) ||
(typeof op === 'object' &&
'r' in op &&
typeof op.r === 'number' &&
op.r > 0)
)
}
/**
* @param {RawScanOp} op
* @returns {op is RawInsertOp}
*/
function isInsert(op) {
return (
typeof op === 'string' ||
(typeof op === 'object' && 'i' in op && typeof op.i === 'string')
)
}
/**
* @param {RawScanOp} op
* @returns {op is RawRemoveOp}
*/
function isRemove(op) {
return typeof op === 'number' && op < 0
}
module.exports = {
ScanOp,
InsertOp,
RetainOp,
RemoveOp,
isRetain,
isInsert,
isRemove,
}

View File

@@ -0,0 +1,112 @@
// @ts-check
const core = require('../../index')
const Comment = require('../comment')
const EditNoOperation = require('./edit_no_operation')
const EditOperation = require('./edit_operation')
/**
* @import DeleteCommentOperation from './delete_comment_operation'
* @import { CommentRawData } from '../types'
* @import { RawSetCommentStateOperation } from '../types'
* @import StringFileData from '../file_data/string_file_data'
*/
/**
* @extends EditOperation
*/
class SetCommentStateOperation extends EditOperation {
/**
* @param {string} commentId
* @param {boolean} resolved
*/
constructor(commentId, resolved) {
super()
this.commentId = commentId
this.resolved = resolved
}
/**
*
* @returns {RawSetCommentStateOperation}
*/
toJSON() {
return {
resolved: this.resolved,
commentId: this.commentId,
}
}
/**
* @param {StringFileData} fileData
*/
apply(fileData) {
const comment = fileData.comments.getComment(this.commentId)
if (comment) {
const newComment = new Comment(comment.id, comment.ranges, this.resolved)
fileData.comments.add(newComment)
}
}
/**
* @param {StringFileData} previousState
* @returns {SetCommentStateOperation | EditNoOperation}
*/
invert(previousState) {
const comment = previousState.comments.getComment(this.commentId)
if (!comment) {
return new EditNoOperation()
}
return new SetCommentStateOperation(this.commentId, comment.resolved)
}
/**
* @inheritdoc
* @param {EditOperation} other
* @returns {boolean}
*/
canBeComposedWith(other) {
return (
(other instanceof SetCommentStateOperation &&
this.commentId === other.commentId) ||
(other instanceof core.DeleteCommentOperation &&
this.commentId === other.commentId)
)
}
/**
* @inheritdoc
* @param {EditOperation} other
* @returns {SetCommentStateOperation | core.DeleteCommentOperation}
*/
compose(other) {
if (
other instanceof SetCommentStateOperation &&
other.commentId === this.commentId
) {
return other
}
if (
other instanceof core.DeleteCommentOperation &&
other.commentId === this.commentId
) {
return other
}
throw new Error(
`Trying to compose SetCommentStateOperation with ${other?.constructor?.name}.`
)
}
/**
* @inheritdoc
* @param {RawSetCommentStateOperation} raw
* @returns {SetCommentStateOperation}
*/
static fromJSON(raw) {
return new SetCommentStateOperation(raw.commentId, raw.resolved)
}
}
module.exports = SetCommentStateOperation

View File

@@ -0,0 +1,53 @@
'use strict'
const _ = require('lodash')
const assert = require('check-types').assert
const Operation = require('./')
/**
* Moves or removes a file from a project.
*/
class SetFileMetadataOperation extends Operation {
/**
* @param {string} pathname
* @param {Object} metadata
*/
constructor(pathname, metadata) {
super()
assert.string(pathname, 'SetFileMetadataOperation: bad pathname')
assert.object(metadata, 'SetFileMetadataOperation: bad metadata')
this.pathname = pathname
this.metadata = metadata
}
/**
* @inheritdoc
*/
toRaw() {
return {
pathname: this.pathname,
metadata: _.cloneDeep(this.metadata),
}
}
getPathname() {
return this.pathname
}
getMetadata() {
return this.metadata
}
/**
* @inheritdoc
*/
applyTo(snapshot) {
const file = snapshot.getFile(this.pathname)
if (!file) return
file.setMetadata(this.metadata)
}
}
module.exports = SetFileMetadataOperation

View File

@@ -0,0 +1,929 @@
// @ts-check
/**
* The text operation from OT.js with some minor cosmetic changes.
*
* Specifically, this is based on
* https://github.com/Operational-Transformation/ot.js/
* blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
* from 18 Mar 2013.
*/
'use strict'
const containsNonBmpChars = require('../util').containsNonBmpChars
const EditOperation = require('./edit_operation')
const {
RetainOp,
InsertOp,
RemoveOp,
isRetain,
isInsert,
isRemove,
} = require('./scan_op')
const {
UnprocessableError,
ApplyError,
InvalidInsertionError,
TooLongError,
} = require('../errors')
const Range = require('../range')
const ClearTrackingProps = require('../file_data/clear_tracking_props')
const TrackingProps = require('../file_data/tracking_props')
/**
* @import StringFileData from '../file_data/string_file_data'
* @import { RawTextOperation, TrackingDirective } from '../types'
* @import { ScanOp } from '../operation/scan_op'
* @import TrackedChangeList from '../file_data/tracked_change_list'
*
* @typedef {{tracking?: TrackingProps, commentIds?: string[]}} InsertOptions
*/
/**
* Create an empty text operation.
* @extends EditOperation
*/
class TextOperation extends EditOperation {
/**
* Length of the longest file that we'll attempt to edit, in characters.
*
* @type {number}
*/
static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
static UnprocessableError = UnprocessableError
static ApplyError = ApplyError
static InvalidInsertionError = InvalidInsertionError
static TooLongError = TooLongError
constructor() {
super()
/**
* When an operation is applied to an input string, you can think of this as
* if an imaginary cursor runs over the entire string and skips over some
* parts, removes some parts and inserts characters at some positions. These
* actions (skip/remove/insert) are stored as an array in the "ops" property.
* @type {ScanOp[]}
*/
this.ops = []
/**
* An operation's baseLength is the length of every string the operation
* can be applied to.
*/
this.baseLength = 0
/**
* The targetLength is the length of every string that results from applying
* the operation on a valid input string.
*/
this.targetLength = 0
/**
* The expected content hash after this operation is applied
*
* @type {string | null}
*/
this.contentHash = null
}
/**
* @param {TextOperation} other
* @return {boolean}
*/
equals(other) {
if (this.baseLength !== other.baseLength) {
return false
}
if (this.targetLength !== other.targetLength) {
return false
}
if (this.ops.length !== other.ops.length) {
return false
}
for (let i = 0; i < this.ops.length; i++) {
if (!this.ops[i].equals(other.ops[i])) {
return false
}
}
return true
}
// After an operation is constructed, the user of the library can specify the
// actions of an operation (skip/insert/remove) with these three builder
// methods. They all return the operation for convenient chaining.
/**
* Skip over a given number of characters.
* @param {number | {r: number}} n
* @param {{tracking?: TrackingDirective}} opts
* @returns {TextOperation}
*/
retain(n, opts = {}) {
if (n === 0) {
return this
}
if (!isRetain(n)) {
throw new Error('retain expects an integer or a retain object')
}
const newOp = RetainOp.fromJSON(n)
newOp.tracking = opts.tracking
if (newOp.length === 0) {
return this
}
this.baseLength += newOp.length
this.targetLength += newOp.length
const lastOperation = this.ops[this.ops.length - 1]
if (lastOperation?.canMergeWith(newOp)) {
// The last op is a retain op => we can merge them into one op.
lastOperation.mergeWith(newOp)
} else {
// Create a new op.
this.ops.push(newOp)
}
return this
}
/**
* Insert a string at the current position.
* @param {string | {i: string}} insertValue
* @param {InsertOptions} opts
* @returns {TextOperation}
*/
insert(insertValue, opts = {}) {
if (!isInsert(insertValue)) {
throw new Error('insert expects a string or an insert object')
}
const newOp = InsertOp.fromJSON(insertValue)
newOp.tracking = opts.tracking
newOp.commentIds = opts.commentIds
if (newOp.insertion === '') {
return this
}
this.targetLength += newOp.insertion.length
const ops = this.ops
const lastOp = this.ops[this.ops.length - 1]
if (lastOp?.canMergeWith(newOp)) {
// Merge insert op.
lastOp.mergeWith(newOp)
} else if (lastOp instanceof RemoveOp) {
// It doesn't matter when an operation is applied whether the operation
// is remove(3), insert("something") or insert("something"), remove(3).
// Here we enforce that in this case, the insert op always comes first.
// This makes all operations that have the same effect when applied to
// a document of the right length equal in respect to the `equals` method.
const secondToLastOp = ops[ops.length - 2]
if (secondToLastOp?.canMergeWith(newOp)) {
secondToLastOp.mergeWith(newOp)
} else {
ops[ops.length] = ops[ops.length - 1]
ops[ops.length - 2] = newOp
}
} else {
ops.push(newOp)
}
return this
}
/**
* Remove a string at the current position.
* @param {number | string} n
* @returns {TextOperation}
*/
remove(n) {
if (typeof n === 'string') {
n = n.length
}
if (typeof n !== 'number') {
throw new Error('remove expects an integer or a string')
}
if (n === 0) {
return this
}
if (n > 0) {
n = -n
}
const newOp = RemoveOp.fromJSON(n)
this.baseLength -= n
const lastOp = this.ops[this.ops.length - 1]
if (lastOp?.canMergeWith(newOp)) {
lastOp.mergeWith(newOp)
} else {
this.ops.push(newOp)
}
return this
}
/**
* Tests whether this operation has no effect.
*/
isNoop() {
return (
this.ops.length === 0 ||
(this.ops.length === 1 && this.ops[0] instanceof RetainOp)
)
}
/**
* Pretty printing.
*/
toString() {
return this.ops.map(op => op.toString()).join(', ')
}
/**
* @inheritdoc
* @returns {RawTextOperation}
*/
toJSON() {
/** @type {RawTextOperation} */
const json = { textOperation: this.ops.map(op => op.toJSON()) }
if (this.contentHash != null) {
json.contentHash = this.contentHash
}
return json
}
/**
* Converts a plain JS object into an operation and validates it.
* @param {RawTextOperation} obj
* @returns {TextOperation}
*/
static fromJSON = function ({ textOperation: ops, contentHash }) {
const o = new TextOperation()
for (const op of ops) {
if (isRetain(op)) {
const retain = RetainOp.fromJSON(op)
o.retain(retain.length, { tracking: retain.tracking })
} else if (isInsert(op)) {
const insert = InsertOp.fromJSON(op)
o.insert(insert.insertion, {
commentIds: insert.commentIds,
tracking: insert.tracking,
})
} else if (isRemove(op)) {
const remove = RemoveOp.fromJSON(op)
o.remove(-remove.length)
} else {
throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
}
}
if (contentHash != null) {
o.contentHash = contentHash
}
return o
}
/**
* Apply an operation to a string, returning a new string. Throws an error if
* there's a mismatch between the input string and the operation.
* @override
* @inheritdoc
* @param {StringFileData} file
*/
apply(file) {
const str = file.getContent()
const operation = this
if (containsNonBmpChars(str)) {
throw new TextOperation.ApplyError(
'The string contains non BMP characters.',
operation,
str
)
}
if (str.length !== operation.baseLength) {
throw new TextOperation.ApplyError(
"The operation's base length must be equal to the string's length.",
operation,
str
)
}
const ops = this.ops
let inputCursor = 0
let result = ''
for (const op of ops) {
if (op instanceof RetainOp) {
if (inputCursor + op.length > str.length) {
throw new ApplyError(
"Operation can't retain more chars than are left in the string.",
op.toJSON(),
str
)
}
file.trackedChanges.applyRetain(result.length, op.length, {
tracking: op.tracking,
})
result += str.slice(inputCursor, inputCursor + op.length)
inputCursor += op.length
} else if (op instanceof InsertOp) {
if (containsNonBmpChars(op.insertion)) {
throw new InvalidInsertionError(str, op.toJSON())
}
file.trackedChanges.applyInsert(result.length, op.insertion, {
tracking: op.tracking,
})
file.comments.applyInsert(
new Range(result.length, op.insertion.length),
{ commentIds: op.commentIds }
)
result += op.insertion
} else if (op instanceof RemoveOp) {
file.trackedChanges.applyDelete(result.length, op.length)
file.comments.applyDelete(new Range(result.length, op.length))
inputCursor += op.length
} else {
throw new UnprocessableError('Unknown ScanOp type during apply')
}
}
if (inputCursor !== str.length) {
throw new TextOperation.ApplyError(
"The operation didn't operate on the whole string.",
operation,
str
)
}
if (result.length > TextOperation.MAX_STRING_LENGTH) {
throw new TextOperation.TooLongError(operation, result.length)
}
file.content = result
}
/**
* @inheritdoc
* @param {number} length of the original string; non-negative
* @return {number} length of the new string; non-negative
*/
applyToLength(length) {
const operation = this
if (length !== operation.baseLength) {
throw new TextOperation.ApplyError(
"The operation's base length must be equal to the string's length.",
operation,
length
)
}
const { length: newLength, inputCursor } = this.ops.reduce(
(intermediate, op) => op.applyToLength(intermediate),
{ length: 0, inputCursor: 0, inputLength: length }
)
if (inputCursor !== length) {
throw new TextOperation.ApplyError(
"The operation didn't operate on the whole string.",
operation,
length
)
}
if (newLength > TextOperation.MAX_STRING_LENGTH) {
throw new TextOperation.TooLongError(operation, newLength)
}
return newLength
}
/**
* @inheritdoc
* @param {StringFileData} previousState
*/
invert(previousState) {
const str = previousState.getContent()
let strIndex = 0
const inverse = new TextOperation()
const ops = this.ops
for (let i = 0, l = ops.length; i < l; i++) {
const op = ops[i]
if (op instanceof RetainOp) {
// Where we need to end up after the retains
const target = strIndex + op.length
// A previous retain could have overriden some tracking info. Now we
// need to restore it.
const previousRanges = previousState.trackedChanges.inRange(
new Range(strIndex, op.length)
)
let removeTrackingInfoIfNeeded
if (op.tracking) {
removeTrackingInfoIfNeeded = new ClearTrackingProps()
}
for (const trackedChange of previousRanges) {
if (strIndex < trackedChange.range.start) {
inverse.retain(trackedChange.range.start - strIndex, {
tracking: removeTrackingInfoIfNeeded,
})
strIndex = trackedChange.range.start
}
if (trackedChange.range.end < strIndex + op.length) {
inverse.retain(trackedChange.range.length, {
tracking: trackedChange.tracking,
})
strIndex = trackedChange.range.end
}
if (trackedChange.range.end !== strIndex) {
// No need to split the range at the end
const [left] = trackedChange.range.splitAt(strIndex)
inverse.retain(left.length, { tracking: trackedChange.tracking })
strIndex = left.end
}
}
if (strIndex < target) {
inverse.retain(target - strIndex, {
tracking: removeTrackingInfoIfNeeded,
})
strIndex = target
}
} else if (op instanceof InsertOp) {
inverse.remove(op.insertion.length)
} else if (op instanceof RemoveOp) {
const segments = calculateTrackingCommentSegments(
strIndex,
op.length,
previousState.comments,
previousState.trackedChanges
)
for (const segment of segments) {
inverse.insert(str.slice(strIndex, strIndex + segment.length), {
tracking: segment.tracking,
commentIds: segment.commentIds,
})
strIndex += segment.length
}
} else {
throw new UnprocessableError('unknown scanop during inversion')
}
}
return inverse
}
/**
* @inheritdoc
* @param {EditOperation} other
*/
canBeComposedWithForUndo(other) {
if (!(other instanceof TextOperation)) {
return false
}
if (this.isNoop() || other.isNoop()) {
return true
}
const startA = getStartIndex(this)
const startB = getStartIndex(other)
const simpleA = getSimpleOp(this)
const simpleB = getSimpleOp(other)
if (!simpleA || !simpleB) {
return false
}
if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
return startA + simpleA.insertion.length === startB
}
if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
// there are two possibilities to delete: with backspace and with the
// delete key.
return startB + simpleB.length === startA || startA === startB
}
return false
}
/**
* @inheritdoc
* @param {EditOperation} other
*/
canBeComposedWith(other) {
if (!(other instanceof TextOperation)) {
return false
}
return this.targetLength === other.baseLength
}
/**
* @inheritdoc
* @param {EditOperation} operation2
*/
compose(operation2) {
if (!(operation2 instanceof TextOperation)) {
throw new Error(
`Trying to compose TextOperation with ${operation2?.constructor?.name}.`
)
}
const operation1 = this
if (operation1.targetLength !== operation2.baseLength) {
throw new Error(
'The base length of the second operation has to be the ' +
'target length of the first operation'
)
}
const operation = new TextOperation() // the combined operation
const ops1 = operation1.ops
const ops2 = operation2.ops // for fast access
let i1 = 0
let i2 = 0 // current index into ops1 respectively ops2
let op1 = ops1[i1++]
let op2 = ops2[i2++] // current ops
for (;;) {
// Dispatch on the type of op1 and op2
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
// end condition: both ops1 and ops2 have been processed
break
}
if (op1 instanceof RemoveOp) {
operation.remove(-op1.length)
op1 = ops1[i1++]
continue
}
if (op2 instanceof InsertOp) {
operation.insert(op2.insertion, {
tracking: op2.tracking,
commentIds: op2.commentIds,
})
op2 = ops2[i2++]
continue
}
if (typeof op1 === 'undefined') {
throw new Error(
'Cannot compose operations: first operation is too short.'
)
}
if (typeof op2 === 'undefined') {
throw new Error(
'Cannot compose operations: first operation is too long.'
)
}
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
// If both have tracking info, use the latter one. Otherwise use the
// tracking info from the former.
const tracking = op2.tracking ?? op1.tracking
if (op1.length > op2.length) {
operation.retain(op2.length, {
tracking,
})
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
operation.retain(op1.length, {
tracking,
})
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
operation.retain(op1.length, {
tracking,
})
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
op1 = ops1[i1++]
}
} else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
if (op1.insertion.length > op2.length) {
op1 = new InsertOp(
op1.insertion.slice(op2.length),
op1.tracking,
op1.commentIds
)
op2 = ops2[i2++]
} else if (op1.insertion.length === op2.length) {
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
op2 = RemoveOp.fromJSON(op1.insertion.length - op2.length)
op1 = ops1[i1++]
}
} else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
/** @type InsertOptions */
const opts = {
commentIds: op1.commentIds,
}
if (op2.tracking instanceof TrackingProps) {
// Prefer the tracking info on the second operation
opts.tracking = op2.tracking
} else if (!(op2.tracking instanceof ClearTrackingProps)) {
// The second operation does not cancel the first operation's tracking
opts.tracking = op1.tracking
}
if (op1.insertion.length > op2.length) {
operation.insert(op1.insertion.slice(0, op2.length), opts)
op1 = new InsertOp(
op1.insertion.slice(op2.length),
op1.tracking,
op1.commentIds
)
op2 = ops2[i2++]
} else if (op1.insertion.length === op2.length) {
operation.insert(op1.insertion, opts)
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
operation.insert(op1.insertion, opts)
op2 = new RetainOp(op2.length - op1.insertion.length, op2.tracking)
op1 = ops1[i1++]
}
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
if (op1.length > op2.length) {
operation.remove(-op2.length)
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
operation.remove(-op2.length)
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
operation.remove(op1.length)
op2 = RemoveOp.fromJSON(op1.length - op2.length)
op1 = ops1[i1++]
}
} else {
throw new Error(
"This shouldn't happen: op1: " +
JSON.stringify(op1) +
', op2: ' +
JSON.stringify(op2)
)
}
}
return operation
}
/**
* Transform takes two operations A and B that happened concurrently and
* produces two operations A' and B' (in an array) such that
* `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
* heart of OT.
* @param {TextOperation} operation1
* @param {TextOperation} operation2
* @returns {[TextOperation, TextOperation]}
*/
static transform(operation1, operation2) {
if (operation1.baseLength !== operation2.baseLength) {
throw new Error('Both operations have to have the same base length')
}
const operation1prime = new TextOperation()
const operation2prime = new TextOperation()
const ops1 = operation1.ops
const ops2 = operation2.ops
let i1 = 0
let i2 = 0
let op1 = ops1[i1++]
let op2 = ops2[i2++]
for (;;) {
// At every iteration of the loop, the imaginary cursor that both
// operation1 and operation2 have that operates on the input string must
// have the same position in the input string.
if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
// end condition: both ops1 and ops2 have been processed
break
}
// next two cases: one or both ops are insert ops
// => insert the string in the corresponding prime operation, skip it in
// the other one. If both op1 and op2 are insert ops, prefer op1.
if (op1 instanceof InsertOp) {
operation1prime.insert(op1.insertion, {
tracking: op1.tracking,
commentIds: op1.commentIds,
})
operation2prime.retain(op1.insertion.length)
op1 = ops1[i1++]
continue
}
if (op2 instanceof InsertOp) {
operation1prime.retain(op2.insertion.length)
operation2prime.insert(op2.insertion, {
tracking: op2.tracking,
commentIds: op2.commentIds,
})
op2 = ops2[i2++]
continue
}
if (typeof op1 === 'undefined') {
throw new Error(
'Cannot compose operations: first operation is too short.'
)
}
if (typeof op2 === 'undefined') {
throw new Error(
'Cannot compose operations: first operation is too long.'
)
}
let minl
if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
// Simple case: retain/retain
// If both have tracking info, we use the one from op1
/** @type {TrackingProps | ClearTrackingProps | undefined} */
let operation1primeTracking
/** @type {TrackingProps | ClearTrackingProps | undefined} */
let operation2primeTracking
if (op1.tracking) {
operation1primeTracking = op1.tracking
} else {
operation2primeTracking = op2.tracking
}
if (op1.length > op2.length) {
minl = op2.length
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
minl = op2.length
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
minl = op1.length
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
op1 = ops1[i1++]
}
operation1prime.retain(minl, { tracking: operation1primeTracking })
operation2prime.retain(minl, { tracking: operation2primeTracking })
} else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
// Both operations remove the same string at the same position. We don't
// need to produce any operations, we just skip over the remove ops and
// handle the case that one operation removes more than the other.
if (op1.length > op2.length) {
op1 = RemoveOp.fromJSON(op2.length - op1.length)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
op2 = RemoveOp.fromJSON(op1.length - op2.length)
op1 = ops1[i1++]
}
// next two cases: remove/retain and retain/remove
} else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
if (op1.length > op2.length) {
minl = op2.length
op1 = RemoveOp.fromJSON(op2.length - op1.length)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
minl = op2.length
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
minl = op1.length
op2 = new RetainOp(op2.length - op1.length, op2.tracking)
op1 = ops1[i1++]
}
operation1prime.remove(minl)
} else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
if (op1.length > op2.length) {
minl = op2.length
op1 = new RetainOp(op1.length - op2.length, op1.tracking)
op2 = ops2[i2++]
} else if (op1.length === op2.length) {
minl = op1.length
op1 = ops1[i1++]
op2 = ops2[i2++]
} else {
minl = op1.length
op2 = RemoveOp.fromJSON(op1.length - op2.length)
op1 = ops1[i1++]
}
operation2prime.remove(minl)
} else {
throw new Error("The two operations aren't compatible")
}
}
return [operation1prime, operation2prime]
}
}
// Operation are essentially lists of ops. There are three types of ops:
//
// * Retain ops: Advance the cursor position by a given number of characters.
// Represented by positive ints.
// * Insert ops: Insert a given string at the current cursor position.
// Represented by strings.
// * Remove ops: Remove the next n characters. Represented by negative ints.
/**
*
* @param {TextOperation} operation
* @returns {ScanOp | null}
*/
function getSimpleOp(operation) {
const ops = operation.ops
switch (ops.length) {
case 1:
return ops[0]
case 2:
return ops[0] instanceof RetainOp
? ops[1]
: ops[1] instanceof RetainOp
? ops[0]
: null
case 3:
if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
return ops[1]
}
}
return null
}
/**
* @param {TextOperation} operation
* @return {number}
*/
function getStartIndex(operation) {
if (operation.ops[0] instanceof RetainOp) {
return operation.ops[0].length
}
return 0
}
/**
* Constructs the segments defined as each overlapping range of tracked
* changes and comments. Each segment can have it's own tracking props and
* attached comment ids.
*
* The quick brown fox jumps over the lazy dog
* Tracked inserts ---------- -----
* Tracked deletes ------
* Comment 1 -------
* Comment 2 ----
* Comment 3 -----------------
*
* Approx. boundaries: | | | || | | | |
*
* @param {number} cursor
* @param {number} length
* @param {import('../file_data/comment_list')} commentsList
* @param {TrackedChangeList} trackedChangeList
* @returns {{length: number, commentIds?: string[], tracking?: TrackingProps}[]}
*/
function calculateTrackingCommentSegments(
cursor,
length,
commentsList,
trackedChangeList
) {
const breaks = new Set()
const opStart = cursor
const opEnd = cursor + length
/**
* Utility function to limit breaks to the boundary set by the operation range
* @param {number} rangeBoundary
*/
function addBreak(rangeBoundary) {
if (rangeBoundary < opStart || rangeBoundary > opEnd) {
return
}
breaks.add(rangeBoundary)
}
// Add comment boundaries
for (const comment of commentsList.comments.values()) {
for (const range of comment.ranges) {
addBreak(range.end)
addBreak(range.start)
}
}
// Add tracked change boundaries
for (const trackedChange of trackedChangeList.asSorted()) {
addBreak(trackedChange.range.start)
addBreak(trackedChange.range.end)
}
// Add operation boundaries
addBreak(opStart)
addBreak(opEnd)
// Sort the boundaries so that we can construct ranges between them
const sortedBreaks = Array.from(breaks).sort((a, b) => a - b)
const separateRanges = []
for (let i = 1; i < sortedBreaks.length; i++) {
const start = sortedBreaks[i - 1]
const end = sortedBreaks[i]
const currentRange = new Range(start, end - start)
// The comment ids that cover the current range is part of this sub-range
const commentIds = commentsList.idsCoveringRange(currentRange)
// The tracking info that covers the current range is part of this sub-range
const tracking = trackedChangeList.propsAtRange(currentRange)
separateRanges.push({
length: currentRange.length,
commentIds: commentIds.length > 0 ? commentIds : undefined,
tracking,
})
}
return separateRanges
}
module.exports = TextOperation