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) {
const { project_id: projectId } = req.params
WebApiManager.getHistoryId(projectId, (error, historyId) => {

View file

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

View file

@ -7,6 +7,7 @@ import OError from '@overleaf/o-error'
import * as HistoryStoreManager from './HistoryStoreManager.js'
import * as WebApiManager from './WebApiManager.js'
import * as Errors from './Errors.js'
import _ from 'lodash'
/**
* @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
// text operations in the relevant chunk, and hashes for unmodified/binary
// 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 getLatestSnapshotFilesCb = callbackify(getLatestSnapshotFiles)
const getRangesSnapshotCb = callbackify(getRangesSnapshot)
const getFileMetadataSnapshotCb = callbackify(getFileMetadataSnapshot)
const getPathsAtVersionCb = callbackify(getPathsAtVersion)
export {
getChangesSinceCb as getChangesSince,
getFileSnapshotStreamCb as getFileSnapshotStream,
getProjectSnapshotCb as getProjectSnapshot,
getFileMetadataSnapshotCb as getFileMetadataSnapshot,
getLatestSnapshotCb as getLatestSnapshot,
getLatestSnapshotFilesCb as getLatestSnapshotFiles,
getRangesSnapshotCb as getRangesSnapshot,
@ -370,4 +397,5 @@ export const promises = {
getLatestSnapshotFiles,
getRangesSnapshot,
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 () {
beforeEach(function () {
this.WebApiManager.promises.getHistoryId.resolves(this.historyId)

View file

@ -97,24 +97,11 @@ const RestoreManager = {
fsPath,
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.type !== 'doc' && file.type !== 'file') {
throw new OError('unexpected file type', { type: file.type })
}
logger.debug(
{ projectId, fileId: file.element._id, type: importInfo.type },
'deleting entity before reverting it'
@ -122,12 +109,37 @@ const RestoreManager = {
await EditorController.promises.deleteEntity(
projectId,
file.element._id,
importInfo.type,
file.type,
origin,
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(
projectId,
version,
@ -309,6 +321,13 @@ const RestoreManager = {
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) {
const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1`
const res = await fetchJson(url)

View file

@ -231,6 +231,9 @@ describe('RestoreManager', function () {
this.RestoreManager.promises._getRangesFromHistory = sinon
.stub()
.rejects()
this.RestoreManager.promises._getMetadataFromHistory = sinon
.stub()
.resolves({ metadata: undefined })
})
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 () {
this.pathname = 'foo.tex'
this.comments = [
@ -315,7 +318,9 @@ describe('RestoreManager', function () {
})
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.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
.stub()
.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 () {
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 () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
@ -392,7 +430,7 @@ describe('RestoreManager', function () {
kind: 'file-restore',
path: this.pathname,
version: this.version,
timestamp: new Date().toISOString(),
timestamp: new Date(this.endTs).toISOString(),
},
this.user_id
)
@ -427,7 +465,7 @@ describe('RestoreManager', function () {
kind: 'file-restore',
path: this.pathname,
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 () {
beforeEach(async function () {
this.pathname = 'foo.png'
@ -457,6 +619,7 @@ describe('RestoreManager', function () {
this.EditorController.promises.upsertFile = sinon
.stub()
.resolves({ _id: 'mock-file-id', type: 'file' })
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
this.RestoreManager.promises._getUpdatesFromHistory = sinon
.stub()
.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 () {
this.ProjectLocator.promises.findElementByPath = sinon
.stub()
.resolves({ type: 'file' })
.resolves({ type: 'file', element: { _id: 'existing-file-id' } })
const revertRes = await this.RestoreManager.promises.revertFile(
this.user_id,