[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:
Jakob Ackermann
2026-04-17 07:44:09 +02:00
committed by Copybot
parent 933c0c2943
commit 6117feef1b
9 changed files with 177 additions and 73 deletions

1
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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