From 654d96ace6680697cd92df3455ba757190fb2b28 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 20 Aug 2024 14:44:21 +0100 Subject: [PATCH] Merge pull request #19743 from overleaf/mj-restore-project [web+project-history] Add project version reverting GitOrigin-RevId: 0f77cec730393187d531c0c6561faaa652bebf29 --- .../overleaf-editor-core/lib/origin/index.js | 4 + .../lib/origin/restore_project_origin.js | 51 +++++++ .../project-history/app/js/HttpController.js | 10 ++ services/project-history/app/js/Router.js | 5 + .../project-history/app/js/SnapshotManager.js | 10 ++ .../app/js/SummarizedUpdatesManager.js | 6 + .../SnapshotManager/SnapshotManagerTests.js | 46 ++++++ .../src/Features/History/HistoryController.js | 13 ++ .../src/Features/History/RestoreManager.js | 66 ++++++++- .../src/Features/Project/ProjectController.js | 1 + services/web/app/src/router.js | 5 + .../web/frontend/extracted-translations.json | 6 + .../dropdown/history-dropdown-content.tsx | 9 ++ .../dropdown/menu-item/restore-project.tsx | 61 ++++++++ .../change-list/history-version.tsx | 28 ++-- .../change-list/label-list-item.tsx | 1 + .../change-list/project-restore-change.tsx | 23 +++ .../modals/restore-project-error-modal.tsx | 34 +++++ .../modals/restore-project-modal.tsx | 60 ++++++++ .../context/hooks/use-restore-project.ts | 36 +++++ .../js/features/history/services/api.ts | 6 + .../features/history/services/types/shared.ts | 5 + .../context/editor-manager-context.tsx | 5 +- services/web/locales/en.json | 6 + .../unit/src/History/RestoreManagerTests.js | 135 ++++++++++++++++++ 25 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 libraries/overleaf-editor-core/lib/origin/restore_project_origin.js create mode 100644 services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx create mode 100644 services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx create mode 100644 services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx create mode 100644 services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx create mode 100644 services/web/frontend/js/features/history/context/hooks/use-restore-project.ts diff --git a/libraries/overleaf-editor-core/lib/origin/index.js b/libraries/overleaf-editor-core/lib/origin/index.js index 2c00ca916c..6575157f7c 100644 --- a/libraries/overleaf-editor-core/lib/origin/index.js +++ b/libraries/overleaf-editor-core/lib/origin/index.js @@ -6,6 +6,7 @@ const assert = require('check-types').assert // dependency let RestoreOrigin = null let RestoreFileOrigin = null +let RestoreProjectOrigin = null /** * 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 === RestoreFileOrigin.KIND) return RestoreFileOrigin.fromRaw(raw) + if (raw.kind === RestoreProjectOrigin.KIND) + return RestoreProjectOrigin.fromRaw(raw) return new Origin(raw.kind) } @@ -58,3 +61,4 @@ module.exports = Origin RestoreOrigin = require('./restore_origin') RestoreFileOrigin = require('./restore_file_origin') +RestoreProjectOrigin = require('./restore_project_origin') diff --git a/libraries/overleaf-editor-core/lib/origin/restore_project_origin.js b/libraries/overleaf-editor-core/lib/origin/restore_project_origin.js new file mode 100644 index 0000000000..9db14b1eb7 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/origin/restore_project_origin.js @@ -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 diff --git a/services/project-history/app/js/HttpController.js b/services/project-history/app/js/HttpController.js index 33ad2b44aa..92b032dc76 100644 --- a/services/project-history/app/js/HttpController.js +++ b/services/project-history/app/js/HttpController.js @@ -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) { HealthChecker.check(err => { if (err != null) { diff --git a/services/project-history/app/js/Router.js b/services/project-history/app/js/Router.js index ae094fbf61..87cb12cc06 100644 --- a/services/project-history/app/js/Router.js +++ b/services/project-history/app/js/Router.js @@ -156,6 +156,11 @@ export function initialize(app) { HttpController.getProjectSnapshot ) + app.get( + '/project/:project_id/paths/version/:version', + HttpController.getPathsAtVersion + ) + app.post( '/project/:project_id/force', validate({ diff --git a/services/project-history/app/js/SnapshotManager.js b/services/project-history/app/js/SnapshotManager.js index 59b6ae867d..6fb85c027f 100644 --- a/services/project-history/app/js/SnapshotManager.js +++ b/services/project-history/app/js/SnapshotManager.js @@ -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 @@ -295,12 +302,14 @@ const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream) const getProjectSnapshotCb = callbackify(getProjectSnapshot) const getLatestSnapshotCb = callbackify(getLatestSnapshot) const getRangesSnapshotCb = callbackify(getRangesSnapshot) +const getPathsAtVersionCb = callbackify(getPathsAtVersion) export { getFileSnapshotStreamCb as getFileSnapshotStream, getProjectSnapshotCb as getProjectSnapshot, getLatestSnapshotCb as getLatestSnapshot, getRangesSnapshotCb as getRangesSnapshot, + getPathsAtVersionCb as getPathsAtVersion, } export const promises = { @@ -308,4 +317,5 @@ export const promises = { getProjectSnapshot, getLatestSnapshot, getRangesSnapshot, + getPathsAtVersion, } diff --git a/services/project-history/app/js/SummarizedUpdatesManager.js b/services/project-history/app/js/SummarizedUpdatesManager.js index c94074136b..a96c16ec3e 100644 --- a/services/project-history/app/js/SummarizedUpdatesManager.js +++ b/services/project-history/app/js/SummarizedUpdatesManager.js @@ -261,6 +261,12 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) { ) { return false } + if ( + update.meta.origin.kind === 'project-restore' && + update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp + ) { + return false + } } else { return false } diff --git a/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js b/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js index 5055bd733a..34350b1548 100644 --- a/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js +++ b/services/project-history/test/unit/js/SnapshotManager/SnapshotManagerTests.js @@ -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']) + }) + }) }) diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.js index ed0c2e04f3..6c76ba5b22 100644 --- a/services/web/app/src/Features/History/HistoryController.js +++ b/services/web/app/src/Features/History/HistoryController.js @@ -118,6 +118,7 @@ module.exports = HistoryController = { projectId, version, pathname, + {}, function (err, entity) { if (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) { const projectId = req.params.Project_id HistoryController._makeRequest( diff --git a/services/web/app/src/Features/History/RestoreManager.js b/services/web/app/src/Features/History/RestoreManager.js index 6655e00e3e..7d5cb60769 100644 --- a/services/web/app/src/Features/History/RestoreManager.js +++ b/services/web/app/src/Features/History/RestoreManager.js @@ -16,6 +16,7 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController') const ChatManager = require('../Chat/ChatManager') const OError = require('@overleaf/o-error') const ProjectGetter = require('../Project/ProjectGetter') +const ProjectEntityHandler = require('../Project/ProjectEntityHandler') const RestoreManager = { 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, { overleaf: true, }) @@ -85,7 +86,7 @@ const RestoreManager = { ) const updateAtVersion = updates.find(update => update.toV === version) - const origin = { + const origin = options.origin || { kind: 'file-restore', path: pathname, 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) { const url = `${ Settings.apis.project_history.url @@ -258,6 +314,12 @@ const RestoreManager = { const res = await fetchJson(url) 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 } diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 356af8b637..97801a6ddd 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -335,6 +335,7 @@ const _ProjectController = { 'pdfjs-40', 'personal-access-token', 'revert-file', + 'revert-project', 'review-panel-redesign', 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 650175403d..972f0b8c2a 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -784,6 +784,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthorizationMiddleware.ensureUserCanWriteProjectContent, HistoryController.revertFile ) + webRouter.post( + '/project/:project_id/revert-project', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.revertProject + ) webRouter.get( '/project/:project_id/version/:version/zip', RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision), diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index e004b43fb5..9b26a618c2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -87,6 +87,7 @@ "already_subscribed_try_refreshing_the_page": "", "also": "", "an_email_has_already_been_sent_to": "", + "an_error_occured_while_restoring_project": "", "an_error_occurred_when_verifying_the_coupon_code": "", "anonymous": "", "anyone_with_link_can_edit": "", @@ -430,6 +431,7 @@ "file_action_edited": "", "file_action_renamed": "", "file_action_restored": "", + "file_action_restored_project": "", "file_already_exists": "", "file_already_exists_in_this_location": "", "file_name": "", @@ -1144,6 +1146,8 @@ "restore_file_error_message": "", "restore_file_error_title": "", "restore_file_version": "", + "restore_project_to_this_version": "", + "restore_this_version": "", "restoring": "", "resync_completed": "", "resync_message": "", @@ -1420,6 +1424,7 @@ "then_x_price_per_month": "", "then_x_price_per_year": "", "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": "", "this_action_cannot_be_reversed": "", "this_action_cannot_be_undone": "", @@ -1712,6 +1717,7 @@ "your_affiliation_is_confirmed": "", "your_browser_does_not_support_this_feature": "", "your_compile_timed_out": "", + "your_current_project_will_revert_to_the_version_from_time": "", "your_git_access_info": "", "your_git_access_info_bullet_1": "", "your_git_access_info_bullet_2": "", diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx index 74bfa4b13d..c7f37e0325 100644 --- a/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx +++ b/services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown-content.tsx @@ -3,17 +3,20 @@ import Download from './menu-item/download' import { Version } from '../../../services/types/update' import { useCallback } from 'react' import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item' +import RestoreProject from './menu-item/restore-project' type VersionDropdownContentProps = { projectId: string version: Version closeDropdownForItem: ActiveDropdown['closeDropdownForItem'] + endTimestamp: number } function HistoryDropdownContent({ projectId, version, closeDropdownForItem, + endTimestamp, }: VersionDropdownContentProps) { const closeDropdown = useCallback(() => { closeDropdownForItem(version, 'moreOptions') @@ -31,6 +34,12 @@ function HistoryDropdownContent({ version={version} closeDropdown={closeDropdown} /> + ) } diff --git a/services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx b/services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx new file mode 100644 index 0000000000..411a7a52c3 --- /dev/null +++ b/services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx @@ -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 ( + <> + + {t('restore_project_to_this_version')} + + + + ) +} + +export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal) diff --git a/services/web/frontend/js/features/history/components/change-list/history-version.tsx b/services/web/frontend/js/features/history/components/change-list/history-version.tsx index ec0510db62..099fad3f14 100644 --- a/services/web/frontend/js/features/history/components/change-list/history-version.tsx +++ b/services/web/frontend/js/features/history/components/change-list/history-version.tsx @@ -21,6 +21,7 @@ import CompareVersionDropdown from './dropdown/compare-version-dropdown' import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content' import FileRestoreChange from './file-restore-change' import HistoryResyncChange from './history-resync-change' +import ProjectRestoreChange from './project-restore-change' type HistoryVersionProps = { update: LoadedUpdate @@ -113,6 +114,7 @@ function HistoryVersion({ {dropdownActive ? ( @@ -166,16 +168,7 @@ function HistoryVersion({ label={label} /> ))} - {update.meta.origin?.kind === 'file-restore' ? ( - - ) : update.meta.origin?.kind === 'history-resync' ? ( - - ) : ( - - )} + {update.meta.origin?.kind !== 'history-resync' ? ( <> + case 'history-resync': + return + case 'project-restore': + return + default: + return ( + + ) + } +} + export default memo(HistoryVersion) diff --git a/services/web/frontend/js/features/history/components/change-list/label-list-item.tsx b/services/web/frontend/js/features/history/components/change-list/label-list-item.tsx index 57b4ea07aa..6d8f44e7ba 100644 --- a/services/web/frontend/js/features/history/components/change-list/label-list-item.tsx +++ b/services/web/frontend/js/features/history/components/change-list/label-list-item.tsx @@ -91,6 +91,7 @@ function LabelListItem({ version={version} projectId={projectId} closeDropdownForItem={closeDropdownForItem} + endTimestamp={toVTimestamp * 1000} /> ) : null} diff --git a/services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx b/services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx new file mode 100644 index 0000000000..c4b11a8485 --- /dev/null +++ b/services/web/frontend/js/features/history/components/change-list/project-restore-change.tsx @@ -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) { + 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__. 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__ 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 how", + "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__ 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' + ) + }) + }) + }) })