) {
+ const { t } = useTranslation()
+
+ if (!origin || origin.kind !== 'project-restore') {
+ return null
+ }
+
+ return (
+
+ {t('file_action_restored_project', {
+ date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
+ })}
+
+ )
+}
+
+export default ProjectRestoreChange
diff --git a/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx b/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx
new file mode 100644
index 0000000000..c7a91fd96e
--- /dev/null
+++ b/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx
@@ -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 (
+
+
+
+ {t('an_error_occured_while_restoring_project')}
+
+
+
+ {t(
+ 'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
+ )}
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx b/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx
new file mode 100644
index 0000000000..1a59ec2a61
--- /dev/null
+++ b/services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx
@@ -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>
+ 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 (
+ setShow(false)} show={show}>
+
+ {t('restore_this_version')}
+
+
+
+ {t('your_current_project_will_revert_to_the_version_from_time', {
+ timestamp: formatDate(endTimestamp),
+ })}
+
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts b/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
new file mode 100644
index 0000000000..ec4e4a4ef8
--- /dev/null
+++ b/services/web/frontend/js/features/history/context/hooks/use-restore-project.ts
@@ -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('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',
+ }
+}
diff --git a/services/web/frontend/js/features/history/services/api.ts b/services/web/frontend/js/features/history/services/api.ts
index d5bc82ce4e..91d2573dab 100644
--- a/services/web/frontend/js/features/history/services/api.ts
+++ b/services/web/frontend/js/features/history/services/api.ts
@@ -107,3 +107,9 @@ export function restoreFileToVersion(
},
})
}
+
+export function restoreProjectToVersion(projectId: string, version: number) {
+ return postJSON(`/project/${projectId}/revert-project`, {
+ body: { version },
+ })
+}
diff --git a/services/web/frontend/js/features/history/services/types/shared.ts b/services/web/frontend/js/features/history/services/types/shared.ts
index 65c8f579f7..1e66f64afe 100644
--- a/services/web/frontend/js/features/history/services/types/shared.ts
+++ b/services/web/frontend/js/features/history/services/types/shared.ts
@@ -29,4 +29,9 @@ export interface Meta {
timestamp: number
version: number
}
+ | {
+ kind: 'project-restore'
+ timestamp: number
+ version: number
+ }
}
diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
index a06746c66a..b6cca40558 100644
--- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
@@ -286,7 +286,10 @@ export const EditorManagerProvider: FC = ({ children }) => {
) {
return
}
- if (update.meta.origin?.kind === 'file-restore') {
+ if (
+ update.meta.origin?.kind === 'file-restore' ||
+ update.meta.origin?.kind === 'project-restore'
+ ) {
return
}
showGenericMessageModal(
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ee546b8a8f..0da35425b5 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -124,6 +124,7 @@
"also_available_as_on_premises": "Also available as On-Premises",
"alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.",
"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",
"and": "and",
"annual": "Annual",
@@ -638,6 +639,7 @@
"file_action_edited": "Edited",
"file_action_renamed": "Renamed",
"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_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",
@@ -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_title": "Restore File Error",
"restore_file_version": "Restore this version",
+ "restore_project_to_this_version": "Restore project to this version",
+ "restore_this_version": "Restore this version",
"restoring": "Restoring",
"restricted": "Restricted",
"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_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 how0>",
+ "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",
"thesis": "Thesis",
"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_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_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_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.",
diff --git a/services/web/test/unit/src/History/RestoreManagerTests.js b/services/web/test/unit/src/History/RestoreManagerTests.js
index a1c3fa26df..69a6d53ad4 100644
--- a/services/web/test/unit/src/History/RestoreManagerTests.js
+++ b/services/web/test/unit/src/History/RestoreManagerTests.js
@@ -35,6 +35,9 @@ describe('RestoreManager', function () {
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
{}),
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
+ '../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
+ promises: {},
+ }),
},
})
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'
+ )
+ })
+ })
+ })
})