浏览代码

[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
Jakob Ackermann 5 月之前
父节点
当前提交
6117feef1b

+ 1 - 0
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",

+ 18 - 0
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)
+}

+ 23 - 0
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())

+ 22 - 0
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:

+ 1 - 0
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",

+ 85 - 70
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
-          }
+  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')
+    }
 
-          // aborted. pipeline() would throw.
-          if (res.destroyed) {
-            stream.destroy()
-            incrResp.fail(new Error('request aborted'))
-            return
-          }
+    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
+            }
 
-          // 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')
+            // aborted. pipeline() would throw.
+            if (res.destroyed) {
+              stream.destroy()
+              incrResp.fail(new Error('request aborted'))
+              return
+            }
 
-                          incrResp.sendUpdate('done')
-                          incrResp.end()
-                        }
-                      )
+            // 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
                     }
-                  )
-                }
-              )
-            },
-            err => {
-              incrResp.fail(OError.tag(err, 'stream history-v1 response'))
-            }
-          )
-        }
-      )
+                    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'))
+              }
+            )
+          }
+        )
+      })
     })
   })
 }

+ 16 - 0
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<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,
   },
 }

+ 7 - 0
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!

+ 1 - 0
services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx

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