Merge pull request #19743 from overleaf/mj-restore-project

[web+project-history] Add project version reverting

GitOrigin-RevId: 0f77cec730393187d531c0c6561faaa652bebf29
This commit is contained in:
Mathias Jakobsen 2024-08-20 14:44:21 +01:00 committed by Copybot
parent 54f0c24633
commit 654d96ace6
25 changed files with 619 additions and 13 deletions

View file

@ -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')

View file

@ -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

View file

@ -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) {

View file

@ -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({

View file

@ -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,
} }

View file

@ -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
} }

View file

@ -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'])
})
})
}) })

View file

@ -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(

View file

@ -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 }

View file

@ -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',

View file

@ -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),

View file

@ -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": "",

View file

@ -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}
/>
</> </>
) )
} }

View file

@ -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)

View file

@ -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)

View file

@ -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>

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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',
}
}

View file

@ -107,3 +107,9 @@ export function restoreFileToVersion(
}, },
}) })
} }
export function restoreProjectToVersion(projectId: string, version: number) {
return postJSON(`/project/${projectId}/revert-project`, {
body: { version },
})
}

View file

@ -29,4 +29,9 @@ export interface Meta {
timestamp: number timestamp: number
version: number version: number
} }
| {
kind: 'project-restore'
timestamp: number
version: number
}
} }

View file

@ -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(

View file

@ -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 dont have permission to load this page.", "restricted_no_permission": "Restricted, sorry you dont 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. Youll 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. Youll 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 doesnt support this feature. Please update your browser to its latest version.", "your_browser_does_not_support_this_feature": "Sorry, your browser doesnt 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 youre prompted for a password.", "your_git_access_info": "Your Git authentication tokens should be entered whenever youre 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, youll need to delete a token before you can generate a new one.", "your_git_access_info_bullet_2": "If you reach the maximum limit, youll need to delete a token before you can generate a new one.",

View file

@ -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'
)
})
})
})
}) })