mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 16:33:39 -05:00
390 lines
10 KiB
JavaScript
390 lines
10 KiB
JavaScript
|
import _ from 'lodash'
|
||
|
import logger from '@overleaf/logger'
|
||
|
import OError from '@overleaf/o-error'
|
||
|
import * as HistoryStoreManager from './HistoryStoreManager.js'
|
||
|
import * as WebApiManager from './WebApiManager.js'
|
||
|
import * as Errors from './Errors.js'
|
||
|
|
||
|
export function convertToSummarizedUpdates(chunk, callback) {
|
||
|
const version = chunk.chunk.startVersion
|
||
|
const { files } = chunk.chunk.history.snapshot
|
||
|
const builder = new UpdateSetBuilder(version, files)
|
||
|
|
||
|
for (const change of chunk.chunk.history.changes) {
|
||
|
try {
|
||
|
builder.applyChange(change)
|
||
|
} catch (error1) {
|
||
|
const error = error1
|
||
|
return callback(error)
|
||
|
}
|
||
|
}
|
||
|
callback(null, builder.summarizedUpdates)
|
||
|
}
|
||
|
|
||
|
export function convertToDiffUpdates(
|
||
|
projectId,
|
||
|
chunk,
|
||
|
pathname,
|
||
|
fromVersion,
|
||
|
toVersion,
|
||
|
callback
|
||
|
) {
|
||
|
let error
|
||
|
let version = chunk.chunk.startVersion
|
||
|
const { files } = chunk.chunk.history.snapshot
|
||
|
const builder = new UpdateSetBuilder(version, files)
|
||
|
|
||
|
let file = null
|
||
|
for (const change of chunk.chunk.history.changes) {
|
||
|
// Because we're referencing by pathname, which can change, we
|
||
|
// want to get the first file in the range fromVersion:toVersion
|
||
|
// that has the pathname we want. Note that this might not exist yet
|
||
|
// at fromVersion, so we'll just settle for the first one we find
|
||
|
// after that.
|
||
|
if (fromVersion <= version && version <= toVersion) {
|
||
|
if (file == null) {
|
||
|
file = builder.getFile(pathname)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
builder.applyChange(change)
|
||
|
} catch (error1) {
|
||
|
error = error1
|
||
|
return callback(error)
|
||
|
}
|
||
|
version += 1
|
||
|
}
|
||
|
// Versions act as fence posts, with updates taking us from one to another,
|
||
|
// so we also need to check after the final update, when we're at the last version.
|
||
|
if (fromVersion <= version && version <= toVersion) {
|
||
|
if (file == null) {
|
||
|
file = builder.getFile(pathname)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// return an empty diff if the file was flagged as missing with an explicit null
|
||
|
if (builder.getFile(pathname) === null) {
|
||
|
return callback(null, { initialContent: '', updates: [] })
|
||
|
}
|
||
|
|
||
|
if (file == null) {
|
||
|
error = new Errors.NotFoundError(
|
||
|
`pathname '${pathname}' not found in range`
|
||
|
)
|
||
|
return callback(error)
|
||
|
}
|
||
|
|
||
|
WebApiManager.getHistoryId(projectId, (err, historyId) => {
|
||
|
if (err) {
|
||
|
return callback(err)
|
||
|
}
|
||
|
file.getDiffUpdates(historyId, fromVersion, toVersion, callback)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
class UpdateSetBuilder {
|
||
|
constructor(startVersion, files) {
|
||
|
this.version = startVersion
|
||
|
this.summarizedUpdates = []
|
||
|
|
||
|
this.files = Object.create(null)
|
||
|
for (const pathname in files) {
|
||
|
// initialize file from snapshot
|
||
|
const data = files[pathname]
|
||
|
this.files[pathname] = new File(pathname, data, startVersion)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
getFile(pathname) {
|
||
|
return this.files[pathname]
|
||
|
}
|
||
|
|
||
|
applyChange(change) {
|
||
|
const timestamp = new Date(change.timestamp)
|
||
|
let authors = _.map(change.authors, id => {
|
||
|
if (id == null) {
|
||
|
return null
|
||
|
}
|
||
|
return id
|
||
|
})
|
||
|
authors = authors.concat(change.v2Authors || [])
|
||
|
this.currentUpdate = {
|
||
|
meta: {
|
||
|
users: authors,
|
||
|
start_ts: timestamp.getTime(),
|
||
|
end_ts: timestamp.getTime(),
|
||
|
},
|
||
|
v: this.version,
|
||
|
pathnames: new Set([]),
|
||
|
project_ops: [],
|
||
|
}
|
||
|
if (change.origin) {
|
||
|
this.currentUpdate.meta.origin = change.origin
|
||
|
}
|
||
|
|
||
|
for (const op of change.operations) {
|
||
|
this.applyOperation(op, timestamp, authors)
|
||
|
}
|
||
|
|
||
|
this.currentUpdate.pathnames = Array.from(this.currentUpdate.pathnames)
|
||
|
this.summarizedUpdates.push(this.currentUpdate)
|
||
|
|
||
|
this.version += 1
|
||
|
}
|
||
|
|
||
|
applyOperation(op, timestamp, authors) {
|
||
|
if (UpdateSetBuilder._isTextOperation(op)) {
|
||
|
this.applyTextOperation(op, timestamp, authors)
|
||
|
} else if (UpdateSetBuilder._isRenameOperation(op)) {
|
||
|
this.applyRenameOperation(op, timestamp, authors)
|
||
|
} else if (UpdateSetBuilder._isRemoveFileOperation(op)) {
|
||
|
this.applyRemoveFileOperation(op, timestamp, authors)
|
||
|
} else if (UpdateSetBuilder._isAddFileOperation(op)) {
|
||
|
this.applyAddFileOperation(op, timestamp, authors)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
applyTextOperation(operation, timestamp, authors) {
|
||
|
const { pathname } = operation
|
||
|
if (pathname === '') {
|
||
|
// this shouldn't happen, but we continue to allow the user to see the history
|
||
|
logger.warn(
|
||
|
{ operation, timestamp, authors },
|
||
|
'pathname is empty for text operation'
|
||
|
)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
const file = this.files[pathname]
|
||
|
if (file == null) {
|
||
|
// this shouldn't happen, but we continue to allow the user to see the history
|
||
|
logger.warn(
|
||
|
{ operation, timestamp, authors },
|
||
|
'file is missing for text operation'
|
||
|
)
|
||
|
this.files[pathname] = null // marker for a missing file
|
||
|
return
|
||
|
}
|
||
|
|
||
|
file.applyTextOperation(authors, timestamp, this.version, operation)
|
||
|
this.currentUpdate.pathnames.add(pathname)
|
||
|
}
|
||
|
|
||
|
applyRenameOperation(operation, timestamp, authors) {
|
||
|
const { pathname, newPathname } = operation
|
||
|
const file = this.files[pathname]
|
||
|
if (file == null) {
|
||
|
// this shouldn't happen, but we continue to allow the user to see the history
|
||
|
logger.warn(
|
||
|
{ operation, timestamp, authors },
|
||
|
'file is missing for rename operation'
|
||
|
)
|
||
|
this.files[pathname] = null // marker for a missing file
|
||
|
return
|
||
|
}
|
||
|
|
||
|
file.rename(newPathname)
|
||
|
delete this.files[pathname]
|
||
|
this.files[newPathname] = file
|
||
|
|
||
|
this.currentUpdate.project_ops.push({
|
||
|
rename: { pathname, newPathname },
|
||
|
})
|
||
|
}
|
||
|
|
||
|
applyAddFileOperation(operation, timestamp, authors) {
|
||
|
const { pathname } = operation
|
||
|
// add file
|
||
|
this.files[pathname] = new File(pathname, operation.file, this.version)
|
||
|
|
||
|
this.currentUpdate.project_ops.push({ add: { pathname } })
|
||
|
}
|
||
|
|
||
|
applyRemoveFileOperation(operation, timestamp, authors) {
|
||
|
const { pathname } = operation
|
||
|
const file = this.files[pathname]
|
||
|
if (file == null) {
|
||
|
// this shouldn't happen, but we continue to allow the user to see the history
|
||
|
logger.warn(
|
||
|
{ operation, timestamp, authors },
|
||
|
'pathname not found when removing file'
|
||
|
)
|
||
|
this.files[pathname] = null // marker for a missing file
|
||
|
return
|
||
|
}
|
||
|
|
||
|
delete this.files[pathname]
|
||
|
|
||
|
this.currentUpdate.project_ops.push({ remove: { pathname } })
|
||
|
}
|
||
|
|
||
|
static _isTextOperation(op) {
|
||
|
return Object.prototype.hasOwnProperty.call(op, 'textOperation')
|
||
|
}
|
||
|
|
||
|
static _isRenameOperation(op) {
|
||
|
return (
|
||
|
Object.prototype.hasOwnProperty.call(op, 'newPathname') &&
|
||
|
op.newPathname !== ''
|
||
|
)
|
||
|
}
|
||
|
|
||
|
static _isRemoveFileOperation(op) {
|
||
|
return (
|
||
|
Object.prototype.hasOwnProperty.call(op, 'newPathname') &&
|
||
|
op.newPathname === ''
|
||
|
)
|
||
|
}
|
||
|
|
||
|
static _isAddFileOperation(op) {
|
||
|
return Object.prototype.hasOwnProperty.call(op, 'file')
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class File {
|
||
|
constructor(pathname, snapshot, initialVersion) {
|
||
|
this.pathname = pathname
|
||
|
this.snapshot = snapshot
|
||
|
this.initialVersion = initialVersion
|
||
|
this.operations = []
|
||
|
}
|
||
|
|
||
|
applyTextOperation(authors, timestamp, version, operation) {
|
||
|
this.operations.push({ authors, timestamp, version, operation })
|
||
|
}
|
||
|
|
||
|
rename(pathname) {
|
||
|
this.pathname = pathname
|
||
|
}
|
||
|
|
||
|
getDiffUpdates(historyId, fromVersion, toVersion, callback) {
|
||
|
if (this.snapshot.stringLength == null) {
|
||
|
// Binary file
|
||
|
return callback(null, { binary: true })
|
||
|
}
|
||
|
HistoryStoreManager.getProjectBlob(
|
||
|
historyId,
|
||
|
this.snapshot.hash,
|
||
|
(error, content) => {
|
||
|
if (error != null) {
|
||
|
return callback(OError.tag(error))
|
||
|
}
|
||
|
let initialContent = content
|
||
|
const updates = []
|
||
|
for (let operation of this.operations) {
|
||
|
let authors, ops, timestamp, version
|
||
|
;({ authors, timestamp, version, operation } = operation)
|
||
|
;({ content, ops } = this._convertTextOperation(content, operation))
|
||
|
|
||
|
// Keep updating our initialContent, until we're actually in the version
|
||
|
// we want to diff, at which point initialContent is the content just before
|
||
|
// the diff updates we will return
|
||
|
if (version < fromVersion) {
|
||
|
initialContent = content
|
||
|
}
|
||
|
|
||
|
// We only need to return the updates between fromVersion and toVersion
|
||
|
if (fromVersion <= version && version < toVersion) {
|
||
|
updates.push({
|
||
|
meta: {
|
||
|
users: authors,
|
||
|
start_ts: timestamp.getTime(),
|
||
|
end_ts: timestamp.getTime(),
|
||
|
},
|
||
|
v: version,
|
||
|
op: ops,
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
callback(null, { initialContent, updates })
|
||
|
}
|
||
|
)
|
||
|
}
|
||
|
|
||
|
_convertTextOperation(content, operation) {
|
||
|
const textUpdateBuilder = new TextUpdateBuilder(content)
|
||
|
for (const op of operation.textOperation || []) {
|
||
|
textUpdateBuilder.applyOp(op)
|
||
|
}
|
||
|
textUpdateBuilder.finish()
|
||
|
return {
|
||
|
content: textUpdateBuilder.result,
|
||
|
ops: textUpdateBuilder.changes,
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class TextUpdateBuilder {
|
||
|
constructor(source) {
|
||
|
this.source = source
|
||
|
this.sourceCursor = 0
|
||
|
this.result = ''
|
||
|
this.changes = []
|
||
|
}
|
||
|
|
||
|
applyOp(op) {
|
||
|
if (TextUpdateBuilder._isRetainOperation(op)) {
|
||
|
this.applyRetain(op)
|
||
|
}
|
||
|
|
||
|
if (TextUpdateBuilder._isInsertOperation(op)) {
|
||
|
this.applyInsert(op)
|
||
|
}
|
||
|
|
||
|
if (TextUpdateBuilder._isDeleteOperation(op)) {
|
||
|
this.applyDelete(-op)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
applyRetain(offset) {
|
||
|
this.result += this.source.slice(
|
||
|
this.sourceCursor,
|
||
|
this.sourceCursor + offset
|
||
|
)
|
||
|
this.sourceCursor += offset
|
||
|
}
|
||
|
|
||
|
applyInsert(content) {
|
||
|
this.changes.push({
|
||
|
i: content,
|
||
|
p: this.result.length,
|
||
|
})
|
||
|
this.result += content
|
||
|
// The source cursor doesn't advance
|
||
|
}
|
||
|
|
||
|
applyDelete(offset) {
|
||
|
const deletedContent = this.source.slice(
|
||
|
this.sourceCursor,
|
||
|
this.sourceCursor + offset
|
||
|
)
|
||
|
|
||
|
this.changes.push({
|
||
|
d: deletedContent,
|
||
|
p: this.result.length,
|
||
|
})
|
||
|
|
||
|
this.sourceCursor += offset
|
||
|
}
|
||
|
|
||
|
finish() {
|
||
|
if (this.sourceCursor < this.source.length) {
|
||
|
this.result += this.source.slice(this.sourceCursor)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
static _isRetainOperation(op) {
|
||
|
return typeof op === 'number' && op > 0
|
||
|
}
|
||
|
|
||
|
static _isInsertOperation(op) {
|
||
|
return typeof op === 'string'
|
||
|
}
|
||
|
|
||
|
static _isDeleteOperation(op) {
|
||
|
return typeof op === 'number' && op < 0
|
||
|
}
|
||
|
}
|