Merge pull request #20266 from overleaf/mj-revert-linked-file

[project-history+web] Restore metadata when reverting file

GitOrigin-RevId: dbfa8202a2fe0bf077d8eedb51a2a13b9f1e8a83
This commit is contained in:
Mathias Jakobsen 2024-09-06 10:20:13 +01:00 committed by Copybot
parent 13d275a353
commit 8245a95b4e
6 changed files with 324 additions and 22 deletions

View file

@ -219,6 +219,21 @@ export function getRangesSnapshot(req, res, next) {
) )
} }
export function getFileMetadataSnapshot(req, res, next) {
const { project_id: projectId, version, pathname } = req.params
SnapshotManager.getFileMetadataSnapshot(
projectId,
version,
pathname,
(err, data) => {
if (err) {
return next(OError.tag(err))
}
res.json(data)
}
)
}
export function getLatestSnapshot(req, res, next) { export function getLatestSnapshot(req, res, next) {
const { project_id: projectId } = req.params const { project_id: projectId } = req.params
WebApiManager.getHistoryId(projectId, (error, historyId) => { WebApiManager.getHistoryId(projectId, (error, historyId) => {

View file

@ -163,6 +163,11 @@ export function initialize(app) {
HttpController.getRangesSnapshot HttpController.getRangesSnapshot
) )
app.get(
'/project/:project_id/metadata/version/:version/:pathname',
HttpController.getFileMetadataSnapshot
)
app.get( app.get(
'/project/:project_id/version/:version', '/project/:project_id/version/:version',
HttpController.getProjectSnapshot HttpController.getProjectSnapshot

View file

@ -7,6 +7,7 @@ import OError from '@overleaf/o-error'
import * as HistoryStoreManager from './HistoryStoreManager.js' import * as HistoryStoreManager from './HistoryStoreManager.js'
import * as WebApiManager from './WebApiManager.js' import * as WebApiManager from './WebApiManager.js'
import * as Errors from './Errors.js' import * as Errors from './Errors.js'
import _ from 'lodash'
/** /**
* @typedef {import('stream').Readable} ReadableStream * @typedef {import('stream').Readable} ReadableStream
@ -209,6 +210,30 @@ async function getRangesSnapshot(projectId, version, pathname) {
} }
} }
/**
* Gets the file metadata at a specific version.
*
* @param {string} projectId
* @param {number} version
* @param {string} pathname
* @returns {Promise<{metadata: any}>}
*/
async function getFileMetadataSnapshot(projectId, version, pathname) {
const snapshot = await _getSnapshotAtVersion(projectId, version)
const file = snapshot.getFile(pathname)
if (!file) {
throw new Errors.NotFoundError(`${pathname} not found`, {
projectId,
version,
pathname,
})
}
const rawMetadata = file.getMetadata()
const metadata = _.isEmpty(rawMetadata) ? undefined : rawMetadata
return { metadata }
}
// Returns project snapshot containing the document content for files with // Returns project snapshot containing the document content for files with
// text operations in the relevant chunk, and hashes for unmodified/binary // text operations in the relevant chunk, and hashes for unmodified/binary
// files. Used by git bridge to get the state of the project. // files. Used by git bridge to get the state of the project.
@ -350,12 +375,14 @@ const getProjectSnapshotCb = callbackify(getProjectSnapshot)
const getLatestSnapshotCb = callbackify(getLatestSnapshot) const getLatestSnapshotCb = callbackify(getLatestSnapshot)
const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles) const getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
const getRangesSnapshotCb = callbackify(getRangesSnapshot) const getRangesSnapshotCb = callbackify(getRangesSnapshot)
const getFileMetadataSnapshotCb = callbackify(getFileMetadataSnapshot)
const getPathsAtVersionCb = callbackify(getPathsAtVersion) const getPathsAtVersionCb = callbackify(getPathsAtVersion)
export { export {
getChangesSinceCb as getChangesSince, getChangesSinceCb as getChangesSince,
getFileSnapshotStreamCb as getFileSnapshotStream, getFileSnapshotStreamCb as getFileSnapshotStream,
getProjectSnapshotCb as getProjectSnapshot, getProjectSnapshotCb as getProjectSnapshot,
getFileMetadataSnapshotCb as getFileMetadataSnapshot,
getLatestSnapshotCb as getLatestSnapshot, getLatestSnapshotCb as getLatestSnapshot,
getLatestSnapshotFilesCb as getLatestSnapshotFiles, getLatestSnapshotFilesCb as getLatestSnapshotFiles,
getRangesSnapshotCb as getRangesSnapshot, getRangesSnapshotCb as getRangesSnapshot,
@ -370,4 +397,5 @@ export const promises = {
getLatestSnapshotFiles, getLatestSnapshotFiles,
getRangesSnapshot, getRangesSnapshot,
getPathsAtVersion, getPathsAtVersion,
getFileMetadataSnapshot,
} }

View file

@ -999,6 +999,78 @@ Four five six\
}) })
}) })
describe('getFileMetadataSnapshot', function () {
beforeEach(function () {
this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
chunk: (this.chunk = {
history: {
snapshot: {
files: {
'main.tex': {
hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
metadata: {
importer_id: 'test-user-id',
imported_at: '2024-01-01T00:00:00.000Z',
},
stringLength: 41,
},
'other.tex': {
hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
stringLength: 41,
},
},
},
changes: [],
},
startVersion: 1,
authors: [
{
id: 31,
email: 'author@example.com',
name: 'Author',
},
],
}),
})
})
it('should return the metadata for the file', async function () {
const result =
await this.SnapshotManager.promises.getFileMetadataSnapshot(
this.projectId,
1,
'main.tex'
)
expect(result).to.deep.equal({
metadata: {
importer_id: 'test-user-id',
imported_at: '2024-01-01T00:00:00.000Z',
},
})
})
it('should return undefined when file does not have metadata', async function () {
const result =
await this.SnapshotManager.promises.getFileMetadataSnapshot(
this.projectId,
1,
'other.tex'
)
expect(result).to.deep.equal({ metadata: undefined })
})
it('throw an error when file does not exist', async function () {
await expect(
this.SnapshotManager.promises.getFileMetadataSnapshot(
this.projectId,
1,
'does-not-exist.tex'
)
).to.be.rejectedWith(Error)
})
})
describe('getPathsAtVersion', function () { describe('getPathsAtVersion', function () {
beforeEach(function () { beforeEach(function () {
this.WebApiManager.promises.getHistoryId.resolves(this.historyId) this.WebApiManager.promises.getHistoryId.resolves(this.historyId)

View file

@ -97,24 +97,11 @@ const RestoreManager = {
fsPath, fsPath,
pathname pathname
) )
if (importInfo.type === 'file') {
const newFile = await EditorController.promises.upsertFile(
projectId,
parentFolderId,
basename,
fsPath,
file?.element?.linkedFileData,
origin,
userId
)
return {
_id: newFile._id,
type: importInfo.type,
}
}
if (file) { if (file) {
if (file.type !== 'doc' && file.type !== 'file') {
throw new OError('unexpected file type', { type: file.type })
}
logger.debug( logger.debug(
{ projectId, fileId: file.element._id, type: importInfo.type }, { projectId, fileId: file.element._id, type: importInfo.type },
'deleting entity before reverting it' 'deleting entity before reverting it'
@ -122,12 +109,37 @@ const RestoreManager = {
await EditorController.promises.deleteEntity( await EditorController.promises.deleteEntity(
projectId, projectId,
file.element._id, file.element._id,
importInfo.type, file.type,
origin, origin,
userId userId
) )
} }
const { metadata } = await RestoreManager._getMetadataFromHistory(
projectId,
version,
pathname
)
logger.debug({ metadata }, 'metadata from history')
if (importInfo.type === 'file' || metadata) {
const newFile = await EditorController.promises.upsertFile(
projectId,
parentFolderId,
basename,
fsPath,
metadata,
origin,
userId
)
return {
_id: newFile._id,
type: 'file',
}
}
const ranges = await RestoreManager._getRangesFromHistory( const ranges = await RestoreManager._getRangesFromHistory(
projectId, projectId,
version, version,
@ -309,6 +321,13 @@ const RestoreManager = {
return await fetchJson(url) return await fetchJson(url)
}, },
async _getMetadataFromHistory(projectId, version, pathname) {
const url = `${
Settings.apis.project_history.url
}/project/${projectId}/metadata/version/${version}/${encodeURIComponent(pathname)}`
return await fetchJson(url)
},
async _getUpdatesFromHistory(projectId, version) { async _getUpdatesFromHistory(projectId, version) {
const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1` const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1`
const res = await fetchJson(url) const res = await fetchJson(url)

View file

@ -231,6 +231,9 @@ describe('RestoreManager', function () {
this.RestoreManager.promises._getRangesFromHistory = sinon this.RestoreManager.promises._getRangesFromHistory = sinon
.stub() .stub()
.rejects() .rejects()
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: undefined })
}) })
describe('reverting a project without ranges support', function () { describe('reverting a project without ranges support', function () {
@ -252,7 +255,7 @@ describe('RestoreManager', function () {
}) })
}) })
describe('reverting a document', function () { describe('reverting a document with ranges', function () {
beforeEach(function () { beforeEach(function () {
this.pathname = 'foo.tex' this.pathname = 'foo.tex'
this.comments = [ this.comments = [
@ -315,7 +318,9 @@ describe('RestoreManager', function () {
}) })
this.RestoreManager.promises._getUpdatesFromHistory = sinon this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub() .stub()
.resolves([{ toV: this.version, meta: { end_ts: Date.now() } }]) .resolves([
{ toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
])
this.EditorController.promises.addDocWithRanges = sinon this.EditorController.promises.addDocWithRanges = sinon
.stub() .stub()
.resolves((this.addedFile = { _id: 'mock-doc', type: 'doc' })) .resolves((this.addedFile = { _id: 'mock-doc', type: 'doc' }))
@ -367,6 +372,39 @@ describe('RestoreManager', function () {
}) })
describe('with an existing file in the current project', function () { describe('with an existing file in the current project', function () {
beforeEach(async function () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
.resolves({ type: 'file', element: { _id: 'mock-file-id' } })
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.data = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should delete the existing file', async function () {
expect(
this.EditorController.promises.deleteEntity
).to.have.been.calledWith(
this.project_id,
'mock-file-id',
'file',
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
})
describe('with an existing document in the current project', function () {
beforeEach(async function () { beforeEach(async function () {
this.ProjectLocator.promises.findElementByPath = sinon this.ProjectLocator.promises.findElementByPath = sinon
.stub() .stub()
@ -392,7 +430,7 @@ describe('RestoreManager', function () {
kind: 'file-restore', kind: 'file-restore',
path: this.pathname, path: this.pathname,
version: this.version, version: this.version,
timestamp: new Date().toISOString(), timestamp: new Date(this.endTs).toISOString(),
}, },
this.user_id this.user_id
) )
@ -427,7 +465,7 @@ describe('RestoreManager', function () {
kind: 'file-restore', kind: 'file-restore',
path: this.pathname, path: this.pathname,
version: this.version, version: this.version,
timestamp: new Date().toISOString(), timestamp: new Date(this.endTs).toISOString(),
} }
) )
}) })
@ -448,6 +486,130 @@ describe('RestoreManager', function () {
}) })
}) })
describe('reverting a document with metadata', function () {
beforeEach(async function () {
this.pathname = 'foo.tex'
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
this.EditorController.promises.addDocWithRanges = sinon.stub()
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([
{ toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
])
this.EditorController.promises.upsertFile = sinon
.stub()
.resolves({ _id: 'mock-file-id', type: 'file' })
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: { foo: 'bar' } })
this.result = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should revert it as a file', function () {
expect(this.result).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
})
it('should upload to the project as a file', function () {
expect(
this.EditorController.promises.upsertFile
).to.have.been.calledWith(
this.project_id,
'mock-folder-id',
'foo.tex',
this.fsPath,
{ foo: 'bar' },
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
it('should not look up ranges', function () {
expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
.been.called
})
it('should not try to add a file', function () {
expect(this.EditorController.promises.addDocWithRanges).to.not.have.been
.called
})
})
describe('reverting a file with metadata', function () {
beforeEach(async function () {
this.pathname = 'foo.png'
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
this.EditorController.promises.addDocWithRanges = sinon.stub()
this.FileSystemImportManager.promises.importFile = sinon
.stub()
.resolves({ type: 'file' })
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.resolves([
{ toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
])
this.EditorController.promises.upsertFile = sinon
.stub()
.resolves({ _id: 'mock-file-id', type: 'file' })
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: { foo: 'bar' } })
this.result = await this.RestoreManager.promises.revertFile(
this.user_id,
this.project_id,
this.version,
this.pathname
)
})
it('should revert it as a file', function () {
expect(this.result).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
})
it('should upload to the project as a file', function () {
expect(
this.EditorController.promises.upsertFile
).to.have.been.calledWith(
this.project_id,
'mock-folder-id',
'foo.png',
this.fsPath,
{ foo: 'bar' },
{
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
})
it('should not look up ranges', function () {
expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
.been.called
})
it('should not try to add a file', function () {
expect(this.EditorController.promises.addDocWithRanges).to.not.have.been
.called
})
})
describe('when reverting a binary file', function () { describe('when reverting a binary file', function () {
beforeEach(async function () { beforeEach(async function () {
this.pathname = 'foo.png' this.pathname = 'foo.png'
@ -457,6 +619,7 @@ 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.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.RestoreManager.promises._getUpdatesFromHistory = sinon this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub() .stub()
.resolves([{ toV: this.version, meta: { end_ts: Date.now() } }]) .resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
@ -465,7 +628,7 @@ describe('RestoreManager', function () {
it('should return the created entity if file exists', async function () { it('should return the created entity if file exists', async function () {
this.ProjectLocator.promises.findElementByPath = sinon this.ProjectLocator.promises.findElementByPath = sinon
.stub() .stub()
.resolves({ type: 'file' }) .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
const revertRes = await this.RestoreManager.promises.revertFile( const revertRes = await this.RestoreManager.promises.revertFile(
this.user_id, this.user_id,