mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
b75ba32774
Handle comments when resyncing history GitOrigin-RevId: cd848fbd91f017a0a66e46df5c009bd16498d154
499 lines
13 KiB
JavaScript
499 lines
13 KiB
JavaScript
// @ts-check
|
|
|
|
import _ from 'lodash'
|
|
import Core from 'overleaf-editor-core'
|
|
import * as Errors from './Errors.js'
|
|
import * as OperationsCompressor from './OperationsCompressor.js'
|
|
|
|
/**
|
|
* @typedef {import('./types').AddDocUpdate} AddDocUpdate
|
|
* @typedef {import('./types').AddFileUpdate} AddFileUpdate
|
|
* @typedef {import('./types').CommentOp} CommentOp
|
|
* @typedef {import('./types').DeleteCommentUpdate} DeleteCommentUpdate
|
|
* @typedef {import('./types').DeleteOp} DeleteOp
|
|
* @typedef {import('./types').InsertOp} InsertOp
|
|
* @typedef {import('./types').RetainOp} RetainOp
|
|
* @typedef {import('./types').Op} Op
|
|
* @typedef {import('./types').RawScanOp} RawScanOp
|
|
* @typedef {import('./types').RenameUpdate} RenameUpdate
|
|
* @typedef {import('./types').TextUpdate} TextUpdate
|
|
* @typedef {import('./types').TrackingProps} TrackingProps
|
|
* @typedef {import('./types').SetCommentStateUpdate} SetCommentStateUpdate
|
|
* @typedef {import('./types').Update} Update
|
|
* @typedef {import('./types').UpdateWithBlob} UpdateWithBlob
|
|
*/
|
|
|
|
/**
|
|
* 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
|
|
|
|
let projectVersion = null
|
|
const v2DocVersions = {}
|
|
|
|
if (_isRenameUpdate(update)) {
|
|
operations = [
|
|
{
|
|
pathname: _convertPathname(update.pathname),
|
|
newPathname: _convertPathname(update.new_pathname),
|
|
},
|
|
]
|
|
projectVersion = update.version
|
|
} else if (isAddUpdate(update)) {
|
|
operations = [
|
|
{
|
|
pathname: _convertPathname(update.pathname),
|
|
file: {
|
|
hash: updateWithBlob.blobHash,
|
|
},
|
|
},
|
|
]
|
|
projectVersion = update.version
|
|
} else if (isTextUpdate(update)) {
|
|
const docLength = update.meta.history_doc_length ?? update.meta.doc_length
|
|
let pathname = update.meta.pathname
|
|
|
|
pathname = _convertPathname(pathname)
|
|
const builder = new OperationsBuilder(docLength, pathname)
|
|
// convert ops
|
|
for (const op of update.op) {
|
|
builder.addOp(op, update)
|
|
}
|
|
operations = builder.finish()
|
|
// add doc version information if present
|
|
if (update.v != null) {
|
|
v2DocVersions[update.doc] = { pathname, v: update.v }
|
|
}
|
|
} else if (isSetCommentStateUpdate(update)) {
|
|
operations = [
|
|
{
|
|
pathname: _convertPathname(update.pathname),
|
|
commentId: update.commentId,
|
|
resolved: update.resolved,
|
|
},
|
|
]
|
|
} else if (isDeleteCommentUpdate(update)) {
|
|
operations = [
|
|
{
|
|
pathname: _convertPathname(update.pathname),
|
|
deleteComment: update.deleteComment,
|
|
},
|
|
]
|
|
} else {
|
|
const error = new Errors.UpdateWithUnknownFormatError(
|
|
'update with unknown format',
|
|
{ projectId, update }
|
|
)
|
|
throw error
|
|
}
|
|
|
|
let v2Authors
|
|
if (update.meta.user_id === 'anonymous-user') {
|
|
// history-v1 uses null to represent an anonymous author
|
|
v2Authors = [null]
|
|
} else {
|
|
// user_id is missing on resync operations that update the contents of a doc
|
|
v2Authors = _.compact([update.meta.user_id])
|
|
}
|
|
|
|
const rawChange = {
|
|
operations,
|
|
v2Authors,
|
|
timestamp: new Date(update.meta.ts).toISOString(),
|
|
projectVersion,
|
|
v2DocVersions: Object.keys(v2DocVersions).length ? v2DocVersions : null,
|
|
}
|
|
if (update.meta.origin) {
|
|
rawChange.origin = update.meta.origin
|
|
} else if (update.meta.type === 'external' && update.meta.source) {
|
|
rawChange.origin = { kind: update.meta.source }
|
|
}
|
|
const change = Core.Change.fromRaw(rawChange)
|
|
|
|
if (change != null) {
|
|
change.operations = OperationsCompressor.compressOperations(
|
|
change.operations
|
|
)
|
|
}
|
|
|
|
return change
|
|
}
|
|
|
|
/**
|
|
* @param {Update} update
|
|
* @returns {update is RenameUpdate}
|
|
*/
|
|
function _isRenameUpdate(update) {
|
|
return 'new_pathname' in update && update.new_pathname != null
|
|
}
|
|
|
|
/**
|
|
* @param {Update} update
|
|
* @returns {update is AddDocUpdate}
|
|
*/
|
|
function _isAddDocUpdate(update) {
|
|
return (
|
|
'doc' in update &&
|
|
update.doc != null &&
|
|
'docLines' in update &&
|
|
update.docLines != null
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Update} update
|
|
* @returns {update is AddFileUpdate}
|
|
*/
|
|
function _isAddFileUpdate(update) {
|
|
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
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* @param {Update} update
|
|
* @returns {update is SetCommentStateUpdate}
|
|
*/
|
|
export function isSetCommentStateUpdate(update) {
|
|
return 'commentId' in update && 'resolved' in update
|
|
}
|
|
|
|
/**
|
|
* @param {Update} update
|
|
* @returns {update is DeleteCommentUpdate}
|
|
*/
|
|
export function isDeleteCommentUpdate(update) {
|
|
return 'deleteComment' in update
|
|
}
|
|
|
|
export function _convertPathname(pathname) {
|
|
// Strip leading /
|
|
pathname = pathname.replace(/^\//, '')
|
|
// Replace \\ with _. Backslashes are no longer allowed
|
|
// in projects in web, but we have some which have gone through
|
|
// into history before this restriction was added. This makes
|
|
// them valid for the history store.
|
|
// See https://github.com/overleaf/write_latex/issues/4471
|
|
pathname = pathname.replace(/\\/g, '_')
|
|
// workaround for filenames containing asterisks, this will
|
|
// fail if a corresponding replacement file already exists but it
|
|
// would fail anyway without this attempt to fix the pathname.
|
|
// See https://github.com/overleaf/internal/issues/900
|
|
pathname = pathname.replace(/\*/g, '__ASTERISK__')
|
|
// workaround for filenames beginning with spaces
|
|
// See https://github.com/overleaf/internal/issues/1404
|
|
// note: we have already stripped any leading slash above
|
|
pathname = pathname.replace(/^ /, '__SPACE__') // handle top-level
|
|
pathname = pathname.replace(/\/ /g, '/__SPACE__') // handle folders
|
|
return pathname
|
|
}
|
|
|
|
class OperationsBuilder {
|
|
/**
|
|
* @param {number} docLength
|
|
* @param {string} pathname
|
|
*/
|
|
constructor(docLength, pathname) {
|
|
/**
|
|
* List of operations being built
|
|
*/
|
|
this.operations = []
|
|
|
|
/**
|
|
* Currently built text operation
|
|
*
|
|
* @type {RawScanOp[]}
|
|
*/
|
|
this.textOperation = []
|
|
|
|
/**
|
|
* Cursor inside the current text operation
|
|
*/
|
|
this.cursor = 0
|
|
|
|
this.docLength = docLength
|
|
this.pathname = pathname
|
|
}
|
|
|
|
/**
|
|
* @param {Op} op
|
|
* @param {TextUpdate} update
|
|
* @returns {void}
|
|
*/
|
|
addOp(op, update) {
|
|
// We sometimes receive operations that operate at positions outside the
|
|
// docLength. Document updater coerces the position to the end of the
|
|
// document. We do the same here.
|
|
const pos = Math.min(op.hpos ?? op.p, this.docLength)
|
|
|
|
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,
|
|
length: op.hlen ?? op.c.length,
|
|
},
|
|
],
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!isInsert(op) && !isDelete(op) && !isRetain(op)) {
|
|
throw new Errors.UnexpectedOpTypeError('unexpected op type', { op })
|
|
}
|
|
|
|
if (pos < this.cursor) {
|
|
this.pushTextOperation()
|
|
// At this point, this.cursor === 0 and we can continue
|
|
}
|
|
|
|
if (pos > this.cursor) {
|
|
this.retain(pos - this.cursor)
|
|
}
|
|
|
|
if (isInsert(op)) {
|
|
if (op.trackedDeleteRejection) {
|
|
this.retain(op.i.length, {
|
|
tracking: {
|
|
type: 'none',
|
|
userId: update.meta.user_id,
|
|
ts: new Date(update.meta.ts).toISOString(),
|
|
},
|
|
})
|
|
} else {
|
|
const opts = {}
|
|
if (update.meta.tc != null) {
|
|
opts.tracking = {
|
|
type: 'insert',
|
|
userId: update.meta.user_id,
|
|
ts: new Date(update.meta.ts).toISOString(),
|
|
}
|
|
}
|
|
if (op.commentIds != null) {
|
|
opts.commentIds = op.commentIds
|
|
}
|
|
this.insert(op.i, opts)
|
|
}
|
|
}
|
|
|
|
if (isRetain(op)) {
|
|
if (op.tracking) {
|
|
this.retain(op.r.length, { tracking: op.tracking })
|
|
} else {
|
|
this.retain(op.r.length)
|
|
}
|
|
}
|
|
|
|
if (isDelete(op)) {
|
|
const changes = op.trackedChanges ?? []
|
|
|
|
// Tracked changes should already be ordered by offset, but let's make
|
|
// sure they are.
|
|
changes.sort((a, b) => {
|
|
const posOrder = a.offset - b.offset
|
|
if (posOrder !== 0) {
|
|
return posOrder
|
|
} else if (a.type === 'insert' && b.type === 'delete') {
|
|
return 1
|
|
} else if (a.type === 'delete' && b.type === 'insert') {
|
|
return -1
|
|
} else {
|
|
return 0
|
|
}
|
|
})
|
|
|
|
let offset = 0
|
|
for (const change of changes) {
|
|
if (change.offset > offset) {
|
|
// Handle the portion before the tracked change
|
|
if (update.meta.tc != null && op.u == null) {
|
|
// This is a tracked delete
|
|
this.retain(change.offset - offset, {
|
|
tracking: {
|
|
type: 'delete',
|
|
userId: update.meta.user_id,
|
|
ts: new Date(update.meta.ts).toISOString(),
|
|
},
|
|
})
|
|
} else {
|
|
// This is a regular delete
|
|
this.delete(change.offset - offset)
|
|
}
|
|
offset = change.offset
|
|
}
|
|
|
|
// Now, handle the portion inside the tracked change
|
|
if (change.type === 'delete') {
|
|
// Tracked deletes are skipped over when deleting
|
|
this.retain(change.length)
|
|
} else if (change.type === 'insert') {
|
|
// Deletes inside tracked inserts are always regular deletes
|
|
this.delete(change.length)
|
|
offset += change.length
|
|
}
|
|
}
|
|
if (offset < op.d.length) {
|
|
// Handle the portion after the last tracked change
|
|
if (update.meta.tc != null && op.u == null) {
|
|
// This is a tracked delete
|
|
this.retain(op.d.length - offset, {
|
|
tracking: {
|
|
type: 'delete',
|
|
userId: update.meta.user_id,
|
|
ts: new Date(update.meta.ts).toISOString(),
|
|
},
|
|
})
|
|
} else {
|
|
// This is a regular delete
|
|
this.delete(op.d.length - offset)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} length
|
|
* @param {object} opts
|
|
* @param {TrackingProps} [opts.tracking]
|
|
*/
|
|
retain(length, opts = {}) {
|
|
if (opts.tracking) {
|
|
this.textOperation.push({ r: length, ...opts })
|
|
} else {
|
|
this.textOperation.push(length)
|
|
}
|
|
this.cursor += length
|
|
}
|
|
|
|
/**
|
|
* @param {string} str
|
|
* @param {object} opts
|
|
* @param {TrackingProps} [opts.tracking]
|
|
* @param {string[]} [opts.commentIds]
|
|
*/
|
|
insert(str, opts = {}) {
|
|
if (opts.tracking || opts.commentIds) {
|
|
this.textOperation.push({ i: str, ...opts })
|
|
} else {
|
|
this.textOperation.push(str)
|
|
}
|
|
this.cursor += str.length
|
|
this.docLength += str.length
|
|
}
|
|
|
|
/**
|
|
* @param {number} length
|
|
* @param {object} opts
|
|
*/
|
|
delete(length, opts = {}) {
|
|
this.textOperation.push(-length)
|
|
this.docLength -= length
|
|
}
|
|
|
|
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.textOperation,
|
|
})
|
|
this.textOperation = []
|
|
}
|
|
this.cursor = 0
|
|
}
|
|
|
|
finish() {
|
|
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 RetainOp}
|
|
*/
|
|
function isRetain(op) {
|
|
return 'r' in op && op.r != 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
|
|
}
|