mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #19743 from overleaf/mj-restore-project
[web+project-history] Add project version reverting GitOrigin-RevId: 0f77cec730393187d531c0c6561faaa652bebf29
This commit is contained in:
parent
54f0c24633
commit
654d96ace6
25 changed files with 619 additions and 13 deletions
|
@ -6,6 +6,7 @@ const assert = require('check-types').assert
|
||||||
// dependency
|
// dependency
|
||||||
let RestoreOrigin = null
|
let RestoreOrigin = null
|
||||||
let RestoreFileOrigin = null
|
let RestoreFileOrigin = null
|
||||||
|
let RestoreProjectOrigin = 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
|
||||||
|
@ -34,6 +35,8 @@ class Origin {
|
||||||
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
||||||
if (raw.kind === RestoreFileOrigin.KIND)
|
if (raw.kind === RestoreFileOrigin.KIND)
|
||||||
return RestoreFileOrigin.fromRaw(raw)
|
return RestoreFileOrigin.fromRaw(raw)
|
||||||
|
if (raw.kind === RestoreProjectOrigin.KIND)
|
||||||
|
return RestoreProjectOrigin.fromRaw(raw)
|
||||||
return new Origin(raw.kind)
|
return new Origin(raw.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,3 +61,4 @@ module.exports = Origin
|
||||||
|
|
||||||
RestoreOrigin = require('./restore_origin')
|
RestoreOrigin = require('./restore_origin')
|
||||||
RestoreFileOrigin = require('./restore_file_origin')
|
RestoreFileOrigin = require('./restore_file_origin')
|
||||||
|
RestoreProjectOrigin = require('./restore_project_origin')
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const assert = require('check-types').assert
|
||||||
|
|
||||||
|
const Origin = require('.')
|
||||||
|
|
||||||
|
class RestoreProjectOrigin extends Origin {
|
||||||
|
/**
|
||||||
|
* @param {number} version that was restored
|
||||||
|
* @param {Date} timestamp from the restored version
|
||||||
|
*/
|
||||||
|
constructor(version, timestamp) {
|
||||||
|
assert.integer(version, 'RestoreProjectOrigin: bad version')
|
||||||
|
assert.date(timestamp, 'RestoreProjectOrigin: bad timestamp')
|
||||||
|
|
||||||
|
super(RestoreProjectOrigin.KIND)
|
||||||
|
this.version = version
|
||||||
|
this.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromRaw(raw) {
|
||||||
|
return new RestoreProjectOrigin(raw.version, new Date(raw.timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc */
|
||||||
|
toRaw() {
|
||||||
|
return {
|
||||||
|
kind: RestoreProjectOrigin.KIND,
|
||||||
|
version: this.version,
|
||||||
|
timestamp: this.timestamp.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
getVersion() {
|
||||||
|
return this.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Date}
|
||||||
|
*/
|
||||||
|
getTimestamp() {
|
||||||
|
return this.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoreProjectOrigin.KIND = 'project-restore'
|
||||||
|
|
||||||
|
module.exports = RestoreProjectOrigin
|
|
@ -230,6 +230,16 @@ export function getProjectSnapshot(req, res, next) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPathsAtVersion(req, res, next) {
|
||||||
|
const { project_id: projectId, version } = req.params
|
||||||
|
SnapshotManager.getPathsAtVersion(projectId, version, (error, result) => {
|
||||||
|
if (error != null) {
|
||||||
|
return next(error)
|
||||||
|
}
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function healthCheck(req, res) {
|
export function healthCheck(req, res) {
|
||||||
HealthChecker.check(err => {
|
HealthChecker.check(err => {
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
|
|
|
@ -156,6 +156,11 @@ export function initialize(app) {
|
||||||
HttpController.getProjectSnapshot
|
HttpController.getProjectSnapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/project/:project_id/paths/version/:version',
|
||||||
|
HttpController.getPathsAtVersion
|
||||||
|
)
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/project/:project_id/force',
|
'/project/:project_id/force',
|
||||||
validate({
|
validate({
|
||||||
|
|
|
@ -237,6 +237,13 @@ async function getProjectSnapshot(projectId, version) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getPathsAtVersion(projectId, version) {
|
||||||
|
const snapshot = await _getSnapshotAtVersion(projectId, version)
|
||||||
|
return {
|
||||||
|
paths: snapshot.getFilePathnames(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} projectId
|
* @param {string} projectId
|
||||||
|
@ -295,12 +302,14 @@ const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
|
||||||
const getProjectSnapshotCb = callbackify(getProjectSnapshot)
|
const getProjectSnapshotCb = callbackify(getProjectSnapshot)
|
||||||
const getLatestSnapshotCb = callbackify(getLatestSnapshot)
|
const getLatestSnapshotCb = callbackify(getLatestSnapshot)
|
||||||
const getRangesSnapshotCb = callbackify(getRangesSnapshot)
|
const getRangesSnapshotCb = callbackify(getRangesSnapshot)
|
||||||
|
const getPathsAtVersionCb = callbackify(getPathsAtVersion)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getFileSnapshotStreamCb as getFileSnapshotStream,
|
getFileSnapshotStreamCb as getFileSnapshotStream,
|
||||||
getProjectSnapshotCb as getProjectSnapshot,
|
getProjectSnapshotCb as getProjectSnapshot,
|
||||||
getLatestSnapshotCb as getLatestSnapshot,
|
getLatestSnapshotCb as getLatestSnapshot,
|
||||||
getRangesSnapshotCb as getRangesSnapshot,
|
getRangesSnapshotCb as getRangesSnapshot,
|
||||||
|
getPathsAtVersionCb as getPathsAtVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const promises = {
|
export const promises = {
|
||||||
|
@ -308,4 +317,5 @@ export const promises = {
|
||||||
getProjectSnapshot,
|
getProjectSnapshot,
|
||||||
getLatestSnapshot,
|
getLatestSnapshot,
|
||||||
getRangesSnapshot,
|
getRangesSnapshot,
|
||||||
|
getPathsAtVersion,
|
||||||
}
|
}
|
||||||
|
|
|
@ -261,6 +261,12 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) {
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
update.meta.origin.kind === 'project-restore' &&
|
||||||
|
update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
@ -998,4 +998,50 @@ Four five six\
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getPathsAtVersion', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
|
||||||
|
this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
|
||||||
|
chunk: (this.chunk = {
|
||||||
|
history: {
|
||||||
|
snapshot: {
|
||||||
|
files: {
|
||||||
|
'main.tex': {
|
||||||
|
hash: (this.fileHash =
|
||||||
|
'5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
|
||||||
|
rangesHash: (this.rangesHash =
|
||||||
|
'73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
|
||||||
|
stringLength: 41,
|
||||||
|
},
|
||||||
|
'other.tex': {
|
||||||
|
hash: (this.fileHash =
|
||||||
|
'f572d396fae9206628714fb2ce00f72e94f2258f'),
|
||||||
|
stringLength: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
changes: [],
|
||||||
|
},
|
||||||
|
startVersion: 4,
|
||||||
|
authors: [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
email: 'author@example.com',
|
||||||
|
name: 'Author',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return an array of paths', async function () {
|
||||||
|
const result = await this.SnapshotManager.promises.getPathsAtVersion(
|
||||||
|
this.projectId,
|
||||||
|
4
|
||||||
|
)
|
||||||
|
expect(result.paths).to.have.length(2)
|
||||||
|
expect(result.paths).to.include.members(['main.tex', 'other.tex'])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -118,6 +118,7 @@ module.exports = HistoryController = {
|
||||||
projectId,
|
projectId,
|
||||||
version,
|
version,
|
||||||
pathname,
|
pathname,
|
||||||
|
{},
|
||||||
function (err, entity) {
|
function (err, entity) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(err)
|
return next(err)
|
||||||
|
@ -130,6 +131,18 @@ module.exports = HistoryController = {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
revertProject(req, res, next) {
|
||||||
|
const { project_id: projectId } = req.params
|
||||||
|
const { version } = req.body
|
||||||
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
|
RestoreManager.revertProject(userId, projectId, version, function (err) {
|
||||||
|
if (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
getLabels(req, res, next) {
|
getLabels(req, res, next) {
|
||||||
const projectId = req.params.Project_id
|
const projectId = req.params.Project_id
|
||||||
HistoryController._makeRequest(
|
HistoryController._makeRequest(
|
||||||
|
|
|
@ -16,6 +16,7 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
||||||
const ChatManager = require('../Chat/ChatManager')
|
const ChatManager = require('../Chat/ChatManager')
|
||||||
const OError = require('@overleaf/o-error')
|
const OError = require('@overleaf/o-error')
|
||||||
const ProjectGetter = require('../Project/ProjectGetter')
|
const ProjectGetter = require('../Project/ProjectGetter')
|
||||||
|
const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
|
||||||
|
|
||||||
const RestoreManager = {
|
const RestoreManager = {
|
||||||
async restoreFileFromV2(userId, projectId, version, pathname) {
|
async restoreFileFromV2(userId, projectId, version, pathname) {
|
||||||
|
@ -49,7 +50,7 @@ const RestoreManager = {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async revertFile(userId, projectId, version, pathname) {
|
async revertFile(userId, projectId, version, pathname, options = {}) {
|
||||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||||
overleaf: true,
|
overleaf: true,
|
||||||
})
|
})
|
||||||
|
@ -85,7 +86,7 @@ const RestoreManager = {
|
||||||
)
|
)
|
||||||
const updateAtVersion = updates.find(update => update.toV === version)
|
const updateAtVersion = updates.find(update => update.toV === version)
|
||||||
|
|
||||||
const origin = {
|
const origin = options.origin || {
|
||||||
kind: 'file-restore',
|
kind: 'file-restore',
|
||||||
path: pathname,
|
path: pathname,
|
||||||
version,
|
version,
|
||||||
|
@ -239,6 +240,61 @@ const RestoreManager = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async revertProject(userId, projectId, version) {
|
||||||
|
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||||
|
overleaf: true,
|
||||||
|
})
|
||||||
|
if (!project?.overleaf?.history?.rangesSupportEnabled) {
|
||||||
|
throw new OError('project does not have ranges support', { projectId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project paths at version
|
||||||
|
const pathsAtPastVersion = await RestoreManager._getProjectPathsAtVersion(
|
||||||
|
projectId,
|
||||||
|
version
|
||||||
|
)
|
||||||
|
|
||||||
|
const updates = await RestoreManager._getUpdatesFromHistory(
|
||||||
|
projectId,
|
||||||
|
version
|
||||||
|
)
|
||||||
|
const updateAtVersion = updates.find(update => update.toV === version)
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
kind: 'project-restore',
|
||||||
|
version,
|
||||||
|
timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pathname of pathsAtPastVersion) {
|
||||||
|
await RestoreManager.revertFile(userId, projectId, version, pathname, {
|
||||||
|
origin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitiesAtLiveVersion =
|
||||||
|
await ProjectEntityHandler.promises.getAllEntities(projectId)
|
||||||
|
|
||||||
|
const trimLeadingSlash = path => path.replace(/^\//, '')
|
||||||
|
|
||||||
|
const pathsAtLiveVersion = entitiesAtLiveVersion.docs
|
||||||
|
.map(doc => doc.path)
|
||||||
|
.concat(entitiesAtLiveVersion.files.map(file => file.path))
|
||||||
|
.map(trimLeadingSlash)
|
||||||
|
|
||||||
|
// Delete files that were not present at the reverted version
|
||||||
|
for (const path of pathsAtLiveVersion) {
|
||||||
|
if (!pathsAtPastVersion.includes(path)) {
|
||||||
|
await EditorController.promises.deleteEntityWithPath(
|
||||||
|
projectId,
|
||||||
|
path,
|
||||||
|
origin,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async _writeFileVersionToDisk(projectId, version, pathname) {
|
async _writeFileVersionToDisk(projectId, version, pathname) {
|
||||||
const url = `${
|
const url = `${
|
||||||
Settings.apis.project_history.url
|
Settings.apis.project_history.url
|
||||||
|
@ -258,6 +314,12 @@ const RestoreManager = {
|
||||||
const res = await fetchJson(url)
|
const res = await fetchJson(url)
|
||||||
return res.updates
|
return res.updates
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async _getProjectPathsAtVersion(projectId, version) {
|
||||||
|
const url = `${Settings.apis.project_history.url}/project/${projectId}/paths/version/${version}`
|
||||||
|
const res = await fetchJson(url)
|
||||||
|
return res.paths
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|
module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|
||||||
|
|
|
@ -335,6 +335,7 @@ const _ProjectController = {
|
||||||
'pdfjs-40',
|
'pdfjs-40',
|
||||||
'personal-access-token',
|
'personal-access-token',
|
||||||
'revert-file',
|
'revert-file',
|
||||||
|
'revert-project',
|
||||||
'review-panel-redesign',
|
'review-panel-redesign',
|
||||||
'track-pdf-download',
|
'track-pdf-download',
|
||||||
!anonymous && 'writefull-oauth-promotion',
|
!anonymous && 'writefull-oauth-promotion',
|
||||||
|
|
|
@ -784,6 +784,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
HistoryController.revertFile
|
HistoryController.revertFile
|
||||||
)
|
)
|
||||||
|
webRouter.post(
|
||||||
|
'/project/:project_id/revert-project',
|
||||||
|
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||||
|
HistoryController.revertProject
|
||||||
|
)
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
'/project/:project_id/version/:version/zip',
|
'/project/:project_id/version/:version/zip',
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision),
|
RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision),
|
||||||
|
|
|
@ -87,6 +87,7 @@
|
||||||
"already_subscribed_try_refreshing_the_page": "",
|
"already_subscribed_try_refreshing_the_page": "",
|
||||||
"also": "",
|
"also": "",
|
||||||
"an_email_has_already_been_sent_to": "",
|
"an_email_has_already_been_sent_to": "",
|
||||||
|
"an_error_occured_while_restoring_project": "",
|
||||||
"an_error_occurred_when_verifying_the_coupon_code": "",
|
"an_error_occurred_when_verifying_the_coupon_code": "",
|
||||||
"anonymous": "",
|
"anonymous": "",
|
||||||
"anyone_with_link_can_edit": "",
|
"anyone_with_link_can_edit": "",
|
||||||
|
@ -430,6 +431,7 @@
|
||||||
"file_action_edited": "",
|
"file_action_edited": "",
|
||||||
"file_action_renamed": "",
|
"file_action_renamed": "",
|
||||||
"file_action_restored": "",
|
"file_action_restored": "",
|
||||||
|
"file_action_restored_project": "",
|
||||||
"file_already_exists": "",
|
"file_already_exists": "",
|
||||||
"file_already_exists_in_this_location": "",
|
"file_already_exists_in_this_location": "",
|
||||||
"file_name": "",
|
"file_name": "",
|
||||||
|
@ -1144,6 +1146,8 @@
|
||||||
"restore_file_error_message": "",
|
"restore_file_error_message": "",
|
||||||
"restore_file_error_title": "",
|
"restore_file_error_title": "",
|
||||||
"restore_file_version": "",
|
"restore_file_version": "",
|
||||||
|
"restore_project_to_this_version": "",
|
||||||
|
"restore_this_version": "",
|
||||||
"restoring": "",
|
"restoring": "",
|
||||||
"resync_completed": "",
|
"resync_completed": "",
|
||||||
"resync_message": "",
|
"resync_message": "",
|
||||||
|
@ -1420,6 +1424,7 @@
|
||||||
"then_x_price_per_month": "",
|
"then_x_price_per_month": "",
|
||||||
"then_x_price_per_year": "",
|
"then_x_price_per_year": "",
|
||||||
"there_are_lots_of_options_to_edit_and_customize_your_figures": "",
|
"there_are_lots_of_options_to_edit_and_customize_your_figures": "",
|
||||||
|
"there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "",
|
||||||
"they_lose_access_to_account": "",
|
"they_lose_access_to_account": "",
|
||||||
"this_action_cannot_be_reversed": "",
|
"this_action_cannot_be_reversed": "",
|
||||||
"this_action_cannot_be_undone": "",
|
"this_action_cannot_be_undone": "",
|
||||||
|
@ -1712,6 +1717,7 @@
|
||||||
"your_affiliation_is_confirmed": "",
|
"your_affiliation_is_confirmed": "",
|
||||||
"your_browser_does_not_support_this_feature": "",
|
"your_browser_does_not_support_this_feature": "",
|
||||||
"your_compile_timed_out": "",
|
"your_compile_timed_out": "",
|
||||||
|
"your_current_project_will_revert_to_the_version_from_time": "",
|
||||||
"your_git_access_info": "",
|
"your_git_access_info": "",
|
||||||
"your_git_access_info_bullet_1": "",
|
"your_git_access_info_bullet_1": "",
|
||||||
"your_git_access_info_bullet_2": "",
|
"your_git_access_info_bullet_2": "",
|
||||||
|
|
|
@ -3,17 +3,20 @@ import Download from './menu-item/download'
|
||||||
import { Version } from '../../../services/types/update'
|
import { Version } from '../../../services/types/update'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
|
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
|
||||||
|
import RestoreProject from './menu-item/restore-project'
|
||||||
|
|
||||||
type VersionDropdownContentProps = {
|
type VersionDropdownContentProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
version: Version
|
version: Version
|
||||||
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
|
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
|
||||||
|
endTimestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryDropdownContent({
|
function HistoryDropdownContent({
|
||||||
projectId,
|
projectId,
|
||||||
version,
|
version,
|
||||||
closeDropdownForItem,
|
closeDropdownForItem,
|
||||||
|
endTimestamp,
|
||||||
}: VersionDropdownContentProps) {
|
}: VersionDropdownContentProps) {
|
||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
closeDropdownForItem(version, 'moreOptions')
|
closeDropdownForItem(version, 'moreOptions')
|
||||||
|
@ -31,6 +34,12 @@ function HistoryDropdownContent({
|
||||||
version={version}
|
version={version}
|
||||||
closeDropdown={closeDropdown}
|
closeDropdown={closeDropdown}
|
||||||
/>
|
/>
|
||||||
|
<RestoreProject
|
||||||
|
projectId={projectId}
|
||||||
|
version={version}
|
||||||
|
closeDropdown={closeDropdown}
|
||||||
|
endTimestamp={endTimestamp}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Icon from '@/shared/components/icon'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { MenuItem } from 'react-bootstrap'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
|
||||||
|
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||||
|
import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
|
||||||
|
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||||
|
import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
|
||||||
|
|
||||||
|
type RestoreProjectProps = {
|
||||||
|
projectId: string
|
||||||
|
version: number
|
||||||
|
closeDropdown: () => void
|
||||||
|
endTimestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const RestoreProject = ({
|
||||||
|
projectId,
|
||||||
|
version,
|
||||||
|
closeDropdown,
|
||||||
|
endTimestamp,
|
||||||
|
}: RestoreProjectProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const { splitTestVariants } = useSplitTestContext()
|
||||||
|
const { restoreProject, isRestoring } = useRestoreProject()
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
closeDropdown()
|
||||||
|
setShowModal(true)
|
||||||
|
}, [closeDropdown])
|
||||||
|
|
||||||
|
const onRestore = useCallback(() => {
|
||||||
|
restoreProject(projectId, version)
|
||||||
|
}, [restoreProject, version, projectId])
|
||||||
|
|
||||||
|
if (
|
||||||
|
splitTestVariants['revert-file'] !== 'enabled' ||
|
||||||
|
splitTestVariants['revert-project'] !== 'enabled'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onClick={handleClick}>
|
||||||
|
<Icon type="undo" fw /> {t('restore_project_to_this_version')}
|
||||||
|
</MenuItem>
|
||||||
|
<RestoreProjectModal
|
||||||
|
setShow={setShowModal}
|
||||||
|
show={showModal}
|
||||||
|
endTimestamp={endTimestamp}
|
||||||
|
isRestoring={isRestoring}
|
||||||
|
onRestore={onRestore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal)
|
|
@ -21,6 +21,7 @@ import CompareVersionDropdown from './dropdown/compare-version-dropdown'
|
||||||
import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
|
import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
|
||||||
import FileRestoreChange from './file-restore-change'
|
import FileRestoreChange from './file-restore-change'
|
||||||
import HistoryResyncChange from './history-resync-change'
|
import HistoryResyncChange from './history-resync-change'
|
||||||
|
import ProjectRestoreChange from './project-restore-change'
|
||||||
|
|
||||||
type HistoryVersionProps = {
|
type HistoryVersionProps = {
|
||||||
update: LoadedUpdate
|
update: LoadedUpdate
|
||||||
|
@ -113,6 +114,7 @@ function HistoryVersion({
|
||||||
{dropdownActive ? (
|
{dropdownActive ? (
|
||||||
<HistoryDropdownContent
|
<HistoryDropdownContent
|
||||||
version={update.toV}
|
version={update.toV}
|
||||||
|
endTimestamp={update.meta.end_ts}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
closeDropdownForItem={closeDropdownForItem}
|
closeDropdownForItem={closeDropdownForItem}
|
||||||
/>
|
/>
|
||||||
|
@ -166,16 +168,7 @@ function HistoryVersion({
|
||||||
label={label}
|
label={label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{update.meta.origin?.kind === 'file-restore' ? (
|
<ChangeEntry update={update} />
|
||||||
<FileRestoreChange origin={update.meta.origin} />
|
|
||||||
) : update.meta.origin?.kind === 'history-resync' ? (
|
|
||||||
<HistoryResyncChange />
|
|
||||||
) : (
|
|
||||||
<Changes
|
|
||||||
pathnames={update.pathnames}
|
|
||||||
projectOps={update.project_ops}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{update.meta.origin?.kind !== 'history-resync' ? (
|
{update.meta.origin?.kind !== 'history-resync' ? (
|
||||||
<>
|
<>
|
||||||
<MetadataUsersList
|
<MetadataUsersList
|
||||||
|
@ -193,4 +186,19 @@ function HistoryVersion({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChangeEntry({ update }: { update: LoadedUpdate }) {
|
||||||
|
switch (update.meta.origin?.kind) {
|
||||||
|
case 'file-restore':
|
||||||
|
return <FileRestoreChange origin={update.meta.origin} />
|
||||||
|
case 'history-resync':
|
||||||
|
return <HistoryResyncChange />
|
||||||
|
case 'project-restore':
|
||||||
|
return <ProjectRestoreChange origin={update.meta.origin} />
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Changes pathnames={update.pathnames} projectOps={update.project_ops} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(HistoryVersion)
|
export default memo(HistoryVersion)
|
||||||
|
|
|
@ -91,6 +91,7 @@ function LabelListItem({
|
||||||
version={version}
|
version={version}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
closeDropdownForItem={closeDropdownForItem}
|
closeDropdownForItem={closeDropdownForItem}
|
||||||
|
endTimestamp={toVTimestamp * 1000}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</HistoryDropdown>
|
</HistoryDropdown>
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { formatTime } from '@/features/utils/format-date'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LoadedUpdate } from '../../services/types/update'
|
||||||
|
|
||||||
|
function ProjectRestoreChange({
|
||||||
|
origin,
|
||||||
|
}: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (!origin || origin.kind !== 'project-restore') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-version-restore-project">
|
||||||
|
{t('file_action_restored_project', {
|
||||||
|
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectRestoreChange
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Button, Modal } from 'react-bootstrap'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export function RestoreProjectErrorModal({
|
||||||
|
resetErrorBoundary,
|
||||||
|
}: {
|
||||||
|
resetErrorBoundary: VoidFunction
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show onHide={resetErrorBoundary}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>
|
||||||
|
{t('an_error_occured_while_restoring_project')}
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{t(
|
||||||
|
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
bsStyle={null}
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resetErrorBoundary}
|
||||||
|
>
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import AccessibleModal from '@/shared/components/accessible-modal'
|
||||||
|
import { formatDate } from '@/utils/dates'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { Button, Modal } from 'react-bootstrap'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type RestoreProjectModalProps = {
|
||||||
|
setShow: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
show: boolean
|
||||||
|
isRestoring: boolean
|
||||||
|
endTimestamp: number
|
||||||
|
onRestore: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestoreProjectModal = ({
|
||||||
|
setShow,
|
||||||
|
show,
|
||||||
|
endTimestamp,
|
||||||
|
isRestoring,
|
||||||
|
onRestore,
|
||||||
|
}: RestoreProjectModalProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
setShow(false)
|
||||||
|
}, [setShow])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessibleModal onHide={() => setShow(false)} show={show}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>{t('restore_this_version')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>
|
||||||
|
{t('your_current_project_will_revert_to_the_version_from_time', {
|
||||||
|
timestamp: formatDate(endTimestamp),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
bsStyle={null}
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="btn btn-primary"
|
||||||
|
bsStyle={null}
|
||||||
|
onClick={onRestore}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
{isRestoring ? t('restoring') : t('restore')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</AccessibleModal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useErrorHandler } from 'react-error-boundary'
|
||||||
|
import { restoreProjectToVersion } from '../../services/api'
|
||||||
|
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||||
|
|
||||||
|
type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
|
||||||
|
|
||||||
|
export const useRestoreProject = () => {
|
||||||
|
const handleError = useErrorHandler()
|
||||||
|
const { setView } = useLayoutContext()
|
||||||
|
|
||||||
|
const [restorationState, setRestorationState] =
|
||||||
|
useState<RestorationState>('initial')
|
||||||
|
|
||||||
|
const restoreProject = useCallback(
|
||||||
|
(projectId: string, version: number) => {
|
||||||
|
setRestorationState('restoring')
|
||||||
|
restoreProjectToVersion(projectId, version)
|
||||||
|
.then(() => {
|
||||||
|
setRestorationState('restored')
|
||||||
|
setView('editor')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setRestorationState('error')
|
||||||
|
handleError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[handleError, setView]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
restorationState,
|
||||||
|
restoreProject,
|
||||||
|
isRestoring: restorationState === 'restoring',
|
||||||
|
}
|
||||||
|
}
|
|
@ -107,3 +107,9 @@ export function restoreFileToVersion(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function restoreProjectToVersion(projectId: string, version: number) {
|
||||||
|
return postJSON(`/project/${projectId}/revert-project`, {
|
||||||
|
body: { version },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -29,4 +29,9 @@ export interface Meta {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'project-restore'
|
||||||
|
timestamp: number
|
||||||
|
version: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -286,7 +286,10 @@ export const EditorManagerProvider: FC = ({ children }) => {
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (update.meta.origin?.kind === 'file-restore') {
|
if (
|
||||||
|
update.meta.origin?.kind === 'file-restore' ||
|
||||||
|
update.meta.origin?.kind === 'project-restore'
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showGenericMessageModal(
|
showGenericMessageModal(
|
||||||
|
|
|
@ -124,6 +124,7 @@
|
||||||
"also_available_as_on_premises": "Also available as On-Premises",
|
"also_available_as_on_premises": "Also available as On-Premises",
|
||||||
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
|
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
|
||||||
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
|
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
|
||||||
|
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
|
||||||
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
|
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
"annual": "Annual",
|
"annual": "Annual",
|
||||||
|
@ -638,6 +639,7 @@
|
||||||
"file_action_edited": "Edited",
|
"file_action_edited": "Edited",
|
||||||
"file_action_renamed": "Renamed",
|
"file_action_renamed": "Renamed",
|
||||||
"file_action_restored": "Restored __fileName__ from: __date__",
|
"file_action_restored": "Restored __fileName__ from: __date__",
|
||||||
|
"file_action_restored_project": "Restored project from __date__",
|
||||||
"file_already_exists": "A file or folder with this name already exists",
|
"file_already_exists": "A file or folder with this name already exists",
|
||||||
"file_already_exists_in_this_location": "An item named <0>__fileName__</0> already exists in this location. If you wish to move this file, rename or remove the conflicting file and try again.",
|
"file_already_exists_in_this_location": "An item named <0>__fileName__</0> already exists in this location. If you wish to move this file, rename or remove the conflicting file and try again.",
|
||||||
"file_name": "File Name",
|
"file_name": "File Name",
|
||||||
|
@ -1673,6 +1675,8 @@
|
||||||
"restore_file_error_message": "There was a problem restoring the file version. Please try again in a few moments. If the problem continues please contact us.",
|
"restore_file_error_message": "There was a problem restoring the file version. Please try again in a few moments. If the problem continues please contact us.",
|
||||||
"restore_file_error_title": "Restore File Error",
|
"restore_file_error_title": "Restore File Error",
|
||||||
"restore_file_version": "Restore this version",
|
"restore_file_version": "Restore this version",
|
||||||
|
"restore_project_to_this_version": "Restore project to this version",
|
||||||
|
"restore_this_version": "Restore this version",
|
||||||
"restoring": "Restoring",
|
"restoring": "Restoring",
|
||||||
"restricted": "Restricted",
|
"restricted": "Restricted",
|
||||||
"restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.",
|
"restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.",
|
||||||
|
@ -2034,6 +2038,7 @@
|
||||||
"then_x_price_per_month": "Then __price__ per month",
|
"then_x_price_per_month": "Then __price__ per month",
|
||||||
"then_x_price_per_year": "Then __price__ per year",
|
"then_x_price_per_year": "Then __price__ per year",
|
||||||
"there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how</0>",
|
"there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how</0>",
|
||||||
|
"there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "There was a problem restoring the project. Please try again in a few moments. Contact us of the problem persists.",
|
||||||
"there_was_an_error_opening_your_content": "There was an error creating your project",
|
"there_was_an_error_opening_your_content": "There was an error creating your project",
|
||||||
"thesis": "Thesis",
|
"thesis": "Thesis",
|
||||||
"they_lose_access_to_account": "They lose all access to this Overleaf account immediately",
|
"they_lose_access_to_account": "They lose all access to this Overleaf account immediately",
|
||||||
|
@ -2393,6 +2398,7 @@
|
||||||
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
|
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
|
||||||
"your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
|
"your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
|
||||||
"your_compile_timed_out": "Your compile timed out",
|
"your_compile_timed_out": "Your compile timed out",
|
||||||
|
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
|
||||||
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
|
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
|
||||||
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
|
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
|
||||||
"your_git_access_info_bullet_2": "If you reach the maximum limit, you’ll need to delete a token before you can generate a new one.",
|
"your_git_access_info_bullet_2": "If you reach the maximum limit, you’ll need to delete a token before you can generate a new one.",
|
||||||
|
|
|
@ -35,6 +35,9 @@ describe('RestoreManager', function () {
|
||||||
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
|
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
|
||||||
{}),
|
{}),
|
||||||
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
|
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
|
||||||
|
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
|
||||||
|
promises: {},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.user_id = 'mock-user-id'
|
this.user_id = 'mock-user-id'
|
||||||
|
@ -488,4 +491,136 @@ describe('RestoreManager', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('revertProject', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.ProjectGetter.promises.getProject = sinon.stub()
|
||||||
|
this.ProjectGetter.promises.getProject
|
||||||
|
.withArgs(this.project_id)
|
||||||
|
.resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
|
||||||
|
this.RestoreManager.promises.revertFile = sinon.stub().resolves()
|
||||||
|
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves([])
|
||||||
|
this.ProjectEntityHandler.promises.getAllEntities = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({ docs: [], files: [] })
|
||||||
|
this.EditorController.promises.deleteEntityWithPath = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves()
|
||||||
|
this.RestoreManager.promises._getUpdatesFromHistory = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves([
|
||||||
|
{ toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reverting a project without ranges support', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
|
||||||
|
overleaf: { history: { rangesSupportEnabled: false } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error', async function () {
|
||||||
|
await expect(
|
||||||
|
this.RestoreManager.promises.revertProject(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version
|
||||||
|
)
|
||||||
|
).to.eventually.be.rejectedWith('project does not have ranges support')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('for a project with overlap in current files and old files', function () {
|
||||||
|
beforeEach(async function () {
|
||||||
|
this.ProjectEntityHandler.promises.getAllEntities = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({
|
||||||
|
docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
|
||||||
|
files: [{ path: '/figures/image.png' }],
|
||||||
|
})
|
||||||
|
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
|
||||||
|
|
||||||
|
await this.RestoreManager.promises.revertProject(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version
|
||||||
|
)
|
||||||
|
this.origin = {
|
||||||
|
kind: 'project-restore',
|
||||||
|
version: this.version,
|
||||||
|
timestamp: new Date(this.end_ts).toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete the old files', function () {
|
||||||
|
expect(
|
||||||
|
this.EditorController.promises.deleteEntityWithPath
|
||||||
|
).to.have.been.calledWith(
|
||||||
|
this.project_id,
|
||||||
|
'new-file.tex',
|
||||||
|
this.origin,
|
||||||
|
this.user_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not delete the current files', function () {
|
||||||
|
expect(
|
||||||
|
this.EditorController.promises.deleteEntityWithPath
|
||||||
|
).to.not.have.been.calledWith(
|
||||||
|
this.project_id,
|
||||||
|
'main.tex',
|
||||||
|
this.origin,
|
||||||
|
this.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
this.EditorController.promises.deleteEntityWithPath
|
||||||
|
).to.not.have.been.calledWith(
|
||||||
|
this.project_id,
|
||||||
|
'figures/image.png',
|
||||||
|
this.origin,
|
||||||
|
this.user_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should revert the old files', function () {
|
||||||
|
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version,
|
||||||
|
'main.tex'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version,
|
||||||
|
'figures/image.png'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version,
|
||||||
|
'since-deleted.tex'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not revert the current files', function () {
|
||||||
|
expect(
|
||||||
|
this.RestoreManager.promises.revertFile
|
||||||
|
).to.not.have.been.calledWith(
|
||||||
|
this.user_id,
|
||||||
|
this.project_id,
|
||||||
|
this.version,
|
||||||
|
'new-file.tex'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue