diff --git a/package-lock.json b/package-lock.json index 23576dc231..694d4b3cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49968,6 +49968,7 @@ "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/mongo-utils": "*", + "@overleaf/promise-utils": "*", "@overleaf/settings": "*", "async": "^3.2.5", "body-parser": "1.20.4", diff --git a/services/chat/app/js/Features/Messages/MessageHttpController.js b/services/chat/app/js/Features/Messages/MessageHttpController.js index 4dd36d662b..d5f145f0f7 100644 --- a/services/chat/app/js/Features/Messages/MessageHttpController.js +++ b/services/chat/app/js/Features/Messages/MessageHttpController.js @@ -3,6 +3,7 @@ import * as MessageManager from './MessageManager.js' import * as MessageFormatter from './MessageFormatter.js' import * as ThreadManager from '../Threads/ThreadManager.js' import { ObjectId } from '../../mongodb.js' +import { promiseMapWithLimit } from '@overleaf/promise-utils' const DEFAULT_MESSAGE_LIMIT = 50 const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words @@ -114,6 +115,10 @@ export async function generateThreadData(context) { return await callMessageHttpController(context, _generateThreadData) } +export async function cloneCommentThreads(context) { + return await callMessageHttpController(context, _cloneCommentThreads) +} + export async function getStatus(context) { const message = 'chat is alive' context.res.status(200).setBody(message) @@ -436,3 +441,16 @@ async function _duplicateCommentThreads(req, res) { } res.json({ newThreads: result }) } + +async function _cloneCommentThreads(req, res) { + const { projectId: sourceProjectId } = req.params + const { targetProjectId } = req.body + const rooms = await ThreadManager.cloneThreads( + sourceProjectId, + targetProjectId + ) + await promiseMapWithLimit(10, rooms, async ({ from, to }) => { + await MessageManager.duplicateRoomToOtherRoom(from, to) + }) + res.status(204) +} diff --git a/services/chat/app/js/Features/Threads/ThreadManager.js b/services/chat/app/js/Features/Threads/ThreadManager.js index 5ad05b8690..fc15b27b24 100644 --- a/services/chat/app/js/Features/Threads/ThreadManager.js +++ b/services/chat/app/js/Features/Threads/ThreadManager.js @@ -4,6 +4,29 @@ export class MissingThreadError extends Error {} export const GLOBAL_THREAD = 'GLOBAL' +/** + * @param {string} sourceProjectId + * @param {string} targetProjectId + * @return {Promise<{from:ObjectId, to: ObjectId}[]>} + */ +export async function cloneThreads(sourceProjectId, targetProjectId) { + sourceProjectId = new ObjectId(sourceProjectId) + targetProjectId = new ObjectId(targetProjectId) + const rooms = await db.rooms + .find({ project_id: sourceProjectId, thread_id: { $exists: true } }) + .toArray() + const mapping = [] + await db.rooms.insertMany( + rooms.map(room => { + const from = room._id + const to = new ObjectId() + mapping.push({ from, to }) + return { ...room, _id: to, project_id: targetProjectId } + }) + ) + return mapping +} + export async function findOrCreateThread(projectId, threadId) { let query, update projectId = new ObjectId(projectId.toString()) diff --git a/services/chat/chat.yaml b/services/chat/chat.yaml index d0b8dc6ca3..b293d1e7df 100644 --- a/services/chat/chat.yaml +++ b/services/chat/chat.yaml @@ -490,6 +490,28 @@ paths: schema: type: object description: Load threads and generate a json blob containing all messages in all the threads + "/project/{projectId}/clone-comment-threads": + parameters: + - schema: + type: string + name: projectId + in: path + required: true + post: + summary: Clone a projects comment threads + operationId: cloneCommentThreads + requestBody: + content: + application/json: + schema: + type: object + properties: + targetProjectId: + type: string + responses: + "204": + description: OK + description: Clone a projects comment threads components: schemas: Message: diff --git a/services/chat/package.json b/services/chat/package.json index 6505bebcf0..fb82eb3ab2 100644 --- a/services/chat/package.json +++ b/services/chat/package.json @@ -19,6 +19,7 @@ "@overleaf/logger": "*", "@overleaf/metrics": "*", "@overleaf/mongo-utils": "*", + "@overleaf/promise-utils": "*", "@overleaf/settings": "*", "async": "^3.2.5", "body-parser": "1.20.4", diff --git a/services/project-history/app/js/HttpController.js b/services/project-history/app/js/HttpController.js index d38856bc64..4c4931be48 100644 --- a/services/project-history/app/js/HttpController.js +++ b/services/project-history/app/js/HttpController.js @@ -44,80 +44,95 @@ export function cloneProject(req, res) { info: { targetProjectId, sourceProjectId }, }) - WebApiManager.getHistoryId(targetProjectId, (err, targetHistoryId) => { - if (err) return incrResp.fail(OError.tag(err, 'get target historyId')) - WebApiManager.getHistoryId(sourceProjectId, (err, sourceHistoryId) => { - if (err) return incrResp.fail(OError.tag(err, 'get source historyId')) - - incrResp.sendUpdate('cloning full project history data: pending') - HistoryStoreManager.cloneProject( - sourceHistoryId.toString(), - targetHistoryId.toString(), - incrResp.signal(), - (err, stream) => { - if (err) { - incrResp.fail(OError.tag(err, 'clone history-v1 data')) - return - } - - // aborted. pipeline() would throw. - if (res.destroyed) { - stream.destroy() - incrResp.fail(new Error('request aborted')) - return - } - - // The stream.pipeline callback API does not support options. - Stream.promises.pipeline(stream, res, { end: false }).then( - () => { - incrResp.sendUpdate('clone labels: pending') - LabelsManager.cloneLabels( - sourceProjectId, - targetProjectId, - err => { - if (err) { - incrResp.fail(OError.tag(err, 'clone labels')) - return - } - incrResp.sendUpdate('clone labels: done') - - incrResp.sendUpdate('clone resync state: pending') - SyncManager.cloneResyncState( - sourceProjectId, - targetProjectId, - err => { - if (err) { - incrResp.fail(OError.tag(err, 'clone resync state')) - return - } - incrResp.sendUpdate('clone resync state: done') - - incrResp.sendUpdate('clone failure record: pending') - ErrorRecorder.cloneFailure( - sourceProjectId, - targetProjectId, - err => { - if (err) { - incrResp.fail(OError.tag(err, 'clone failure')) - return - } - incrResp.sendUpdate('clone failure record: done') - - incrResp.sendUpdate('done') - incrResp.end() - } - ) - } - ) - } - ) - }, - err => { - incrResp.fail(OError.tag(err, 'stream history-v1 response')) - } - ) - } + incrResp.sendUpdate('best effort history flush: pending') + UpdatesProcessor.processUpdatesForProject(sourceProjectId, err => { + if (err) { + logger.warn( + { err, sourceProjectId }, + 'failed to flush during history clone' ) + incrResp.sendUpdate( + 'best effort history flush: failed, a resync will be required' + ) + } else { + incrResp.sendUpdate('best effort history flush: done') + } + + WebApiManager.getHistoryId(targetProjectId, (err, targetHistoryId) => { + if (err) return incrResp.fail(OError.tag(err, 'get target historyId')) + WebApiManager.getHistoryId(sourceProjectId, (err, sourceHistoryId) => { + if (err) return incrResp.fail(OError.tag(err, 'get source historyId')) + + incrResp.sendUpdate('cloning full project history data: pending') + HistoryStoreManager.cloneProject( + sourceHistoryId.toString(), + targetHistoryId.toString(), + incrResp.signal(), + (err, stream) => { + if (err) { + incrResp.fail(OError.tag(err, 'clone history-v1 data')) + return + } + + // aborted. pipeline() would throw. + if (res.destroyed) { + stream.destroy() + incrResp.fail(new Error('request aborted')) + return + } + + // The stream.pipeline callback API does not support options. + Stream.promises.pipeline(stream, res, { end: false }).then( + () => { + incrResp.sendUpdate('clone labels: pending') + LabelsManager.cloneLabels( + sourceProjectId, + targetProjectId, + err => { + if (err) { + incrResp.fail(OError.tag(err, 'clone labels')) + return + } + incrResp.sendUpdate('clone labels: done') + + incrResp.sendUpdate('clone resync state: pending') + SyncManager.cloneResyncState( + sourceProjectId, + targetProjectId, + err => { + if (err) { + incrResp.fail(OError.tag(err, 'clone resync state')) + return + } + incrResp.sendUpdate('clone resync state: done') + + incrResp.sendUpdate('clone failure record: pending') + ErrorRecorder.cloneFailure( + sourceProjectId, + targetProjectId, + err => { + if (err) { + incrResp.fail(OError.tag(err, 'clone failure')) + return + } + incrResp.sendUpdate('clone failure record: done') + + incrResp.sendUpdate('done') + incrResp.end() + } + ) + } + ) + } + ) + }, + err => { + incrResp.fail(OError.tag(err, 'stream history-v1 response')) + } + ) + } + ) + }) }) }) } diff --git a/services/web/app/src/Features/Chat/ChatApiHandler.mjs b/services/web/app/src/Features/Chat/ChatApiHandler.mjs index facf8a44cf..c9eefe2683 100644 --- a/services/web/app/src/Features/Chat/ChatApiHandler.mjs +++ b/services/web/app/src/Features/Chat/ChatApiHandler.mjs @@ -249,6 +249,21 @@ async function generateThreadData(projectId, threads) { ) } +/** + * @param {string} sourceProjectId + * @param {string} targetProjectId + * @return {Promise} + */ +async function cloneCommentThreads(sourceProjectId, targetProjectId) { + await fetchNothing( + chatApiUrl(`/project/${sourceProjectId}/clone-comment-threads`), + { + method: 'POST', + json: { targetProjectId }, + } + ) +} + /** * @param {any} path */ @@ -296,5 +311,6 @@ export default { getResolvedThreadIds, duplicateCommentThreads, generateThreadData, + cloneCommentThreads, }, } diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.mjs b/services/web/app/src/Features/Project/ProjectDuplicator.mjs index 4de3f512fd..8735fb961a 100644 --- a/services/web/app/src/Features/Project/ProjectDuplicator.mjs +++ b/services/web/app/src/Features/Project/ProjectDuplicator.mjs @@ -22,6 +22,7 @@ import _ from 'lodash' import TagsHandler from '../Tags/TagsHandler.mjs' import ClsiCacheManager from '../Compile/ClsiCacheManager.mjs' import Modules from '../../infrastructure/Modules.mjs' +import ChatApiHandler from '../Chat/ChatApiHandler.mjs' const TAG_COLOR_RED = '#f04343' const DEBUG_TAG_NAME = 'Debug' @@ -191,6 +192,12 @@ async function duplicate( newProject._id ) } + if (opts.cloneRanges) { + await ChatApiHandler.promises.cloneCommentThreads( + originalProject._id.toString(), + newProject._id.toString() + ) + } } catch (err) { // Clean up broken clone on error. // Make sure we delete the new failed project, not the original one! diff --git a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx index fdb9db9fb5..34ee1cf725 100644 --- a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx @@ -263,6 +263,7 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(