mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Send origin metadata through docupdater and project-history when restoring files (#18721)
* add RestoreFileOrigin in overleaf-editor-core * support source to be an object * use sourceOrOrigin as param * rename to originOrSource so the priority is more clear * get timestamp from version * fix test * include version and min_count in getUpdatesFromHistory * extractOriginOrSource util function * fix RestoreManagerTests GitOrigin-RevId: 0ace05a6ade2794c753a9d0bffb4f858ecc6899a
This commit is contained in:
parent
5a5defee69
commit
7e8e2b0585
11 changed files with 186 additions and 15 deletions
|
@ -22,6 +22,7 @@ const SetFileMetadataOperation = require('./lib/operation/set_file_metadata_oper
|
||||||
const NoOperation = require('./lib/operation/no_operation')
|
const NoOperation = require('./lib/operation/no_operation')
|
||||||
const Operation = require('./lib/operation')
|
const Operation = require('./lib/operation')
|
||||||
const RestoreOrigin = require('./lib/origin/restore_origin')
|
const RestoreOrigin = require('./lib/origin/restore_origin')
|
||||||
|
const RestoreFileOrigin = require('./lib/origin/restore_file_origin')
|
||||||
const Origin = require('./lib/origin')
|
const Origin = require('./lib/origin')
|
||||||
const OtClient = require('./lib/ot_client')
|
const OtClient = require('./lib/ot_client')
|
||||||
const TextOperation = require('./lib/operation/text_operation')
|
const TextOperation = require('./lib/operation/text_operation')
|
||||||
|
@ -66,6 +67,7 @@ exports.SetFileMetadataOperation = SetFileMetadataOperation
|
||||||
exports.NoOperation = NoOperation
|
exports.NoOperation = NoOperation
|
||||||
exports.Operation = Operation
|
exports.Operation = Operation
|
||||||
exports.RestoreOrigin = RestoreOrigin
|
exports.RestoreOrigin = RestoreOrigin
|
||||||
|
exports.RestoreFileOrigin = RestoreFileOrigin
|
||||||
exports.Origin = Origin
|
exports.Origin = Origin
|
||||||
exports.OtClient = OtClient
|
exports.OtClient = OtClient
|
||||||
exports.TextOperation = TextOperation
|
exports.TextOperation = TextOperation
|
||||||
|
|
|
@ -5,6 +5,7 @@ const assert = require('check-types').assert
|
||||||
// Dependencies are loaded at the bottom of the file to mitigate circular
|
// Dependencies are loaded at the bottom of the file to mitigate circular
|
||||||
// dependency
|
// dependency
|
||||||
let RestoreOrigin = null
|
let RestoreOrigin = null
|
||||||
|
let RestoreFileOrigin = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An Origin records where a {@link Change} came from. The Origin class handles
|
* An Origin records where a {@link Change} came from. The Origin class handles
|
||||||
|
@ -31,6 +32,8 @@ class Origin {
|
||||||
static fromRaw(raw) {
|
static fromRaw(raw) {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
||||||
|
if (raw.kind === RestoreFileOrigin.KIND)
|
||||||
|
return RestoreFileOrigin.fromRaw(raw)
|
||||||
return new Origin(raw.kind)
|
return new Origin(raw.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,3 +57,4 @@ class Origin {
|
||||||
module.exports = Origin
|
module.exports = Origin
|
||||||
|
|
||||||
RestoreOrigin = require('./restore_origin')
|
RestoreOrigin = require('./restore_origin')
|
||||||
|
RestoreFileOrigin = require('./restore_file_origin')
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const assert = require('check-types').assert
|
||||||
|
|
||||||
|
const Origin = require('.')
|
||||||
|
|
||||||
|
class RestoreFileOrigin extends Origin {
|
||||||
|
/**
|
||||||
|
* @param {number} version that was restored
|
||||||
|
* @param {string} path that was restored
|
||||||
|
* @param {Date} timestamp from the restored version
|
||||||
|
*/
|
||||||
|
constructor(version, path, timestamp) {
|
||||||
|
assert.integer(version, 'RestoreFileOrigin: bad version')
|
||||||
|
assert.string(path, 'RestoreFileOrigin: bad path')
|
||||||
|
assert.date(timestamp, 'RestoreFileOrigin: bad timestamp')
|
||||||
|
|
||||||
|
super(RestoreFileOrigin.KIND)
|
||||||
|
this.version = version
|
||||||
|
this.path = path
|
||||||
|
this.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromRaw(raw) {
|
||||||
|
return new RestoreFileOrigin(raw.version, raw.path, new Date(raw.timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
toRaw() {
|
||||||
|
return {
|
||||||
|
kind: RestoreFileOrigin.KIND,
|
||||||
|
version: this.version,
|
||||||
|
path: this.path,
|
||||||
|
timestamp: this.timestamp.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
getVersion() {
|
||||||
|
return this.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
getPath() {
|
||||||
|
return this.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Date}
|
||||||
|
*/
|
||||||
|
getTimestamp() {
|
||||||
|
return this.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoreFileOrigin.KIND = 'file-restore'
|
||||||
|
|
||||||
|
module.exports = RestoreFileOrigin
|
|
@ -34,4 +34,29 @@ describe('Change', function () {
|
||||||
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
|
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('RestoreFileOrigin', function () {
|
||||||
|
it('should convert to and from raw', function () {
|
||||||
|
const origin = new core.RestoreFileOrigin(1, 'path', new Date())
|
||||||
|
const raw = origin.toRaw()
|
||||||
|
const newOrigin = core.Origin.fromRaw(raw)
|
||||||
|
expect(newOrigin).to.eql(origin)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('change should have a correct origin class', function () {
|
||||||
|
const change = Change.fromRaw({
|
||||||
|
operations: [],
|
||||||
|
timestamp: '2015-03-05T12:03:53.035Z',
|
||||||
|
authors: [null],
|
||||||
|
origin: {
|
||||||
|
kind: 'file-restore',
|
||||||
|
version: 1,
|
||||||
|
path: 'path',
|
||||||
|
timestamp: '2015-03-05T12:03:53.035Z',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(change.getOrigin()).to.be.an.instanceof(core.RestoreFileOrigin)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,6 +8,7 @@ const Metrics = require('./Metrics')
|
||||||
const HistoryManager = require('./HistoryManager')
|
const HistoryManager = require('./HistoryManager')
|
||||||
const Errors = require('./Errors')
|
const Errors = require('./Errors')
|
||||||
const RangesManager = require('./RangesManager')
|
const RangesManager = require('./RangesManager')
|
||||||
|
const { extractOriginOrSource } = require('./Utils')
|
||||||
|
|
||||||
const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
|
const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
|
||||||
|
|
||||||
|
@ -111,7 +112,7 @@ const DocumentManager = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setDoc(projectId, docId, newLines, source, userId, undoing) {
|
async setDoc(projectId, docId, newLines, originOrSource, userId, undoing) {
|
||||||
if (newLines == null) {
|
if (newLines == null) {
|
||||||
throw new Error('No lines were provided to setDoc')
|
throw new Error('No lines were provided to setDoc')
|
||||||
}
|
}
|
||||||
|
@ -141,16 +142,23 @@ const DocumentManager = {
|
||||||
o.u = true
|
o.u = true
|
||||||
} // Turn on undo flag for each op for track changes
|
} // Turn on undo flag for each op for track changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { origin, source } = extractOriginOrSource(originOrSource)
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
doc: docId,
|
doc: docId,
|
||||||
op,
|
op,
|
||||||
v: version,
|
v: version,
|
||||||
meta: {
|
meta: {
|
||||||
type: 'external',
|
type: 'external',
|
||||||
source,
|
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if (origin) {
|
||||||
|
update.meta.origin = origin
|
||||||
|
} else if (source) {
|
||||||
|
update.meta.source = source
|
||||||
|
}
|
||||||
// Keep track of external updates, whether they are for live documents
|
// Keep track of external updates, whether they are for live documents
|
||||||
// (flush) or unloaded documents (evict), and whether the update is a no-op.
|
// (flush) or unloaded documents (evict), and whether the update is a no-op.
|
||||||
Metrics.inc('external-update', 1, {
|
Metrics.inc('external-update', 1, {
|
||||||
|
|
|
@ -9,7 +9,7 @@ const rclient = require('@overleaf/redis-wrapper').createClient(
|
||||||
const logger = require('@overleaf/logger')
|
const logger = require('@overleaf/logger')
|
||||||
const metrics = require('./Metrics')
|
const metrics = require('./Metrics')
|
||||||
const { docIsTooLarge } = require('./Limits')
|
const { docIsTooLarge } = require('./Limits')
|
||||||
const { addTrackedDeletesToContent } = require('./Utils')
|
const { addTrackedDeletesToContent, extractOriginOrSource } = require('./Utils')
|
||||||
const HistoryConversions = require('./HistoryConversions')
|
const HistoryConversions = require('./HistoryConversions')
|
||||||
const OError = require('@overleaf/o-error')
|
const OError = require('@overleaf/o-error')
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ const ProjectHistoryRedisManager = {
|
||||||
entityId,
|
entityId,
|
||||||
userId,
|
userId,
|
||||||
projectUpdate,
|
projectUpdate,
|
||||||
source
|
originOrSource
|
||||||
) {
|
) {
|
||||||
projectUpdate = {
|
projectUpdate = {
|
||||||
pathname: projectUpdate.pathname,
|
pathname: projectUpdate.pathname,
|
||||||
|
@ -67,7 +67,15 @@ const ProjectHistoryRedisManager = {
|
||||||
projectHistoryId,
|
projectHistoryId,
|
||||||
}
|
}
|
||||||
projectUpdate[entityType] = entityId
|
projectUpdate[entityType] = entityId
|
||||||
if (source != null) {
|
|
||||||
|
const { origin, source } = extractOriginOrSource(originOrSource)
|
||||||
|
|
||||||
|
if (origin != null) {
|
||||||
|
projectUpdate.meta.origin = origin
|
||||||
|
if (origin.kind !== 'editor') {
|
||||||
|
projectUpdate.meta.type = 'external'
|
||||||
|
}
|
||||||
|
} else if (source != null) {
|
||||||
projectUpdate.meta.source = source
|
projectUpdate.meta.source = source
|
||||||
if (source !== 'editor') {
|
if (source !== 'editor') {
|
||||||
projectUpdate.meta.type = 'external'
|
projectUpdate.meta.type = 'external'
|
||||||
|
@ -90,7 +98,7 @@ const ProjectHistoryRedisManager = {
|
||||||
entityId,
|
entityId,
|
||||||
userId,
|
userId,
|
||||||
projectUpdate,
|
projectUpdate,
|
||||||
source
|
originOrSource
|
||||||
) {
|
) {
|
||||||
let docLines = projectUpdate.docLines
|
let docLines = projectUpdate.docLines
|
||||||
let ranges
|
let ranges
|
||||||
|
@ -117,7 +125,15 @@ const ProjectHistoryRedisManager = {
|
||||||
projectUpdate.ranges = ranges
|
projectUpdate.ranges = ranges
|
||||||
}
|
}
|
||||||
projectUpdate[entityType] = entityId
|
projectUpdate[entityType] = entityId
|
||||||
if (source != null) {
|
|
||||||
|
const { origin, source } = extractOriginOrSource(originOrSource)
|
||||||
|
|
||||||
|
if (origin != null) {
|
||||||
|
projectUpdate.meta.origin = origin
|
||||||
|
if (origin.kind !== 'editor') {
|
||||||
|
projectUpdate.meta.type = 'external'
|
||||||
|
}
|
||||||
|
} else if (source != null) {
|
||||||
projectUpdate.meta.source = source
|
projectUpdate.meta.source = source
|
||||||
if (source !== 'editor') {
|
if (source !== 'editor') {
|
||||||
projectUpdate.meta.type = 'external'
|
projectUpdate.meta.type = 'external'
|
||||||
|
|
|
@ -83,10 +83,28 @@ function addTrackedDeletesToContent(content, trackedChanges) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if the given originOrSource should be treated as a source or origin
|
||||||
|
* TODO: remove this hack and remove all "source" references
|
||||||
|
*/
|
||||||
|
function extractOriginOrSource(originOrSource) {
|
||||||
|
let source = null
|
||||||
|
let origin = null
|
||||||
|
|
||||||
|
if (typeof originOrSource === 'string') {
|
||||||
|
source = originOrSource
|
||||||
|
} else if (originOrSource && typeof originOrSource === 'object') {
|
||||||
|
origin = originOrSource
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source, origin }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isInsert,
|
isInsert,
|
||||||
isDelete,
|
isDelete,
|
||||||
isComment,
|
isComment,
|
||||||
addTrackedDeletesToContent,
|
addTrackedDeletesToContent,
|
||||||
getDocLength,
|
getDocLength,
|
||||||
|
extractOriginOrSource,
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,6 @@ const RestoreManager = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async revertFile(userId, projectId, version, pathname) {
|
async revertFile(userId, projectId, version, pathname) {
|
||||||
const source = 'file-revert'
|
|
||||||
const fsPath = await RestoreManager._writeFileVersionToDisk(
|
const fsPath = await RestoreManager._writeFileVersionToDisk(
|
||||||
projectId,
|
projectId,
|
||||||
version,
|
version,
|
||||||
|
@ -71,6 +70,19 @@ const RestoreManager = {
|
||||||
})
|
})
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
|
const updates = await RestoreManager._getUpdatesFromHistory(
|
||||||
|
projectId,
|
||||||
|
version
|
||||||
|
)
|
||||||
|
const updateAtVersion = updates.find(update => update.toV === version)
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
kind: 'file-restore',
|
||||||
|
path: pathname,
|
||||||
|
version,
|
||||||
|
timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
const importInfo = await FileSystemImportManager.promises.importFile(
|
const importInfo = await FileSystemImportManager.promises.importFile(
|
||||||
fsPath,
|
fsPath,
|
||||||
pathname
|
pathname
|
||||||
|
@ -82,7 +94,7 @@ const RestoreManager = {
|
||||||
basename,
|
basename,
|
||||||
fsPath,
|
fsPath,
|
||||||
file?.element?.linkedFileData,
|
file?.element?.linkedFileData,
|
||||||
source,
|
origin,
|
||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -101,7 +113,7 @@ const RestoreManager = {
|
||||||
projectId,
|
projectId,
|
||||||
file.element._id,
|
file.element._id,
|
||||||
importInfo.type,
|
importInfo.type,
|
||||||
'revert',
|
origin,
|
||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -179,7 +191,7 @@ const RestoreManager = {
|
||||||
basename,
|
basename,
|
||||||
importInfo.lines,
|
importInfo.lines,
|
||||||
newRanges,
|
newRanges,
|
||||||
'revert',
|
origin,
|
||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
@ -226,6 +238,12 @@ const RestoreManager = {
|
||||||
}/project/${projectId}/ranges/version/${version}/${encodeURIComponent(pathname)}`
|
}/project/${projectId}/ranges/version/${version}/${encodeURIComponent(pathname)}`
|
||||||
return await fetchJson(url)
|
return await fetchJson(url)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async _getUpdatesFromHistory(projectId, version) {
|
||||||
|
const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1`
|
||||||
|
const res = await fetchJson(url)
|
||||||
|
return res.updates
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|
module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|
||||||
|
|
|
@ -12,7 +12,7 @@ export interface Meta {
|
||||||
start_ts: number
|
start_ts: number
|
||||||
end_ts: number
|
end_ts: number
|
||||||
type?: 'external' // TODO
|
type?: 'external' // TODO
|
||||||
source?: 'git-bridge' | 'file-revert' // TODO
|
source?: 'git-bridge' // TODO
|
||||||
origin?: {
|
origin?: {
|
||||||
kind:
|
kind:
|
||||||
| 'dropbox'
|
| 'dropbox'
|
||||||
|
@ -21,5 +21,6 @@ export interface Meta {
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'history-resync'
|
| 'history-resync'
|
||||||
| 'history-migration'
|
| 'history-migration'
|
||||||
|
| 'file-restore'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -285,7 +285,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (update.meta.source === 'file-revert') {
|
if (update.meta.origin?.kind === 'file-restore') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showGenericMessageModal(
|
showGenericMessageModal(
|
||||||
|
|
|
@ -286,6 +286,9 @@ describe('RestoreManager', function () {
|
||||||
changes: this.tracked_changes,
|
changes: this.tracked_changes,
|
||||||
comments: this.comments,
|
comments: this.comments,
|
||||||
})
|
})
|
||||||
|
this.RestoreManager.promises._getUpdatesFromHistory = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
|
||||||
this.EditorController.promises.addDocWithRanges = sinon
|
this.EditorController.promises.addDocWithRanges = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves(
|
.resolves(
|
||||||
|
@ -360,7 +363,12 @@ describe('RestoreManager', function () {
|
||||||
this.project_id,
|
this.project_id,
|
||||||
'mock-file-id',
|
'mock-file-id',
|
||||||
'doc',
|
'doc',
|
||||||
'revert',
|
{
|
||||||
|
kind: 'file-restore',
|
||||||
|
path: this.pathname,
|
||||||
|
version: this.version,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
this.user_id
|
this.user_id
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -389,7 +397,13 @@ describe('RestoreManager', function () {
|
||||||
this.folder_id,
|
this.folder_id,
|
||||||
'foo.tex',
|
'foo.tex',
|
||||||
['foo', 'bar', 'baz'],
|
['foo', 'bar', 'baz'],
|
||||||
{ changes: this.tracked_changes, comments: this.remappedComments }
|
{ changes: this.tracked_changes, comments: this.remappedComments },
|
||||||
|
{
|
||||||
|
kind: 'file-restore',
|
||||||
|
path: this.pathname,
|
||||||
|
version: this.version,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -418,6 +432,9 @@ describe('RestoreManager', function () {
|
||||||
this.EditorController.promises.upsertFile = sinon
|
this.EditorController.promises.upsertFile = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves({ _id: 'mock-file-id', type: 'file' })
|
.resolves({ _id: 'mock-file-id', type: 'file' })
|
||||||
|
this.RestoreManager.promises._getUpdatesFromHistory = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the created entity if file exists', async function () {
|
it('should return the created entity if file exists', async function () {
|
||||||
|
|
Loading…
Reference in a new issue