mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[chat] clone comment threads when cloning project with ranges (#32852)
* [project-history] add best effort flush when cloning project * [web] update labels in clone project modal for admins * [project-history] do not shadow history flush failure * [web] fix accessible label for 'Add comment' button * [chat] clone comment threads when cloning project with ranges GitOrigin-RevId: ef30204c8a94b3d6204d56dcca2f62a46319996b
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +249,21 @@ async function generateThreadData(projectId, threads) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sourceProjectId
|
||||
* @param {string} targetProjectId
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -263,6 +263,7 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
|
||||
<button
|
||||
className="review-tooltip-menu-button review-tooltip-add-comment-button"
|
||||
onClick={handleAddCommentClick}
|
||||
aria-label={t('add_comment')}
|
||||
>
|
||||
<MaterialIcon type="chat" />
|
||||
{t('add_comment')}
|
||||
|
||||
Reference in New Issue
Block a user