mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #18803 from overleaf/revert-18801-mj-revert-big-deploy
[web+chat] Redo deploy GitOrigin-RevId: a056bf20d49a39e71e03db740f57e8506dfc6b71
This commit is contained in:
parent
e67a2b92a8
commit
c29c151c9f
19 changed files with 690 additions and 182 deletions
|
@ -82,6 +82,14 @@ export async function destroyProject(context) {
|
||||||
return await callMessageHttpController(context, _destroyProject)
|
return await callMessageHttpController(context, _destroyProject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateCommentThreads(context) {
|
||||||
|
return await callMessageHttpController(context, _duplicateCommentThreads)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateThreadData(context) {
|
||||||
|
return await callMessageHttpController(context, _generateThreadData)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStatus(context) {
|
export async function getStatus(context) {
|
||||||
const message = 'chat is alive'
|
const message = 'chat is alive'
|
||||||
context.res.status(200).setBody(message)
|
context.res.status(200).setBody(message)
|
||||||
|
@ -120,6 +128,18 @@ const _getAllThreads = async (req, res) => {
|
||||||
res.json(threads)
|
res.json(threads)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _generateThreadData = async (req, res) => {
|
||||||
|
const { projectId } = req.params
|
||||||
|
const { threads } = req.body
|
||||||
|
logger.debug({ projectId }, 'getting all threads')
|
||||||
|
const rooms = await ThreadManager.findThreadsById(projectId, threads)
|
||||||
|
const roomIds = rooms.map(r => r._id)
|
||||||
|
const messages = await MessageManager.findAllMessagesInRooms(roomIds)
|
||||||
|
logger.debug({ rooms, messages }, 'looked up messages in the rooms')
|
||||||
|
const threadData = MessageFormatter.groupMessagesByThreads(rooms, messages)
|
||||||
|
res.json(threadData)
|
||||||
|
}
|
||||||
|
|
||||||
const _resolveThread = async (req, res) => {
|
const _resolveThread = async (req, res) => {
|
||||||
const { projectId, threadId } = req.params
|
const { projectId, threadId } = req.params
|
||||||
const { user_id: userId } = req.body
|
const { user_id: userId } = req.body
|
||||||
|
@ -254,3 +274,29 @@ async function _getMessages(clientThreadId, req, res) {
|
||||||
logger.debug({ projectId, messages }, 'got messages')
|
logger.debug({ projectId, messages }, 'got messages')
|
||||||
res.status(200).setBody(messages)
|
res.status(200).setBody(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _duplicateCommentThreads(req, res) {
|
||||||
|
const { projectId } = req.params
|
||||||
|
const { threads } = req.body
|
||||||
|
const result = {}
|
||||||
|
for (const id of threads) {
|
||||||
|
logger.debug({ projectId, thread: id }, 'duplicating thread')
|
||||||
|
try {
|
||||||
|
const { oldRoom, newRoom } = await ThreadManager.duplicateThread(
|
||||||
|
projectId,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
await MessageManager.duplicateRoomToOtherRoom(oldRoom._id, newRoom._id)
|
||||||
|
result[id] = { duplicateId: newRoom.thread_id }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ThreadManager.MissingThreadError) {
|
||||||
|
// Expected error when the comment has been deleted prior to duplication
|
||||||
|
result[id] = { error: 'not found' }
|
||||||
|
} else {
|
||||||
|
logger.err({ error }, 'error duplicating thread')
|
||||||
|
result[id] = { error: 'unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ newThreads: result })
|
||||||
|
}
|
||||||
|
|
|
@ -89,3 +89,16 @@ function _ensureIdsAreObjectIds(query) {
|
||||||
}
|
}
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateRoomToOtherRoom(sourceRoomId, targetRoomId) {
|
||||||
|
const sourceMessages = await findAllMessagesInRooms([sourceRoomId])
|
||||||
|
const targetMessages = sourceMessages.map(comment => {
|
||||||
|
return _ensureIdsAreObjectIds({
|
||||||
|
room_id: targetRoomId,
|
||||||
|
content: comment.content,
|
||||||
|
timestamp: comment.timestamp,
|
||||||
|
user_id: comment.user_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await db.messages.insertMany(targetMessages)
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { db, ObjectId } from '../../mongodb.js'
|
import { db, ObjectId } from '../../mongodb.js'
|
||||||
|
|
||||||
|
export class MissingThreadError extends Error {}
|
||||||
|
|
||||||
export const GLOBAL_THREAD = 'GLOBAL'
|
export const GLOBAL_THREAD = 'GLOBAL'
|
||||||
|
|
||||||
export async function findOrCreateThread(projectId, threadId) {
|
export async function findOrCreateThread(projectId, threadId) {
|
||||||
|
@ -124,3 +126,32 @@ export async function getResolvedThreadIds(projectId) {
|
||||||
.toArray()
|
.toArray()
|
||||||
return resolvedThreadIds
|
return resolvedThreadIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateThread(projectId, threadId) {
|
||||||
|
const room = await db.rooms.findOne({
|
||||||
|
project_id: new ObjectId(projectId),
|
||||||
|
thread_id: new ObjectId(threadId),
|
||||||
|
})
|
||||||
|
if (!room) {
|
||||||
|
throw new MissingThreadError('Trying to duplicate a non-existent thread')
|
||||||
|
}
|
||||||
|
const newRoom = {
|
||||||
|
project_id: room.project_id,
|
||||||
|
thread_id: new ObjectId(),
|
||||||
|
}
|
||||||
|
if (room.resolved) {
|
||||||
|
newRoom.resolved = room.resolved
|
||||||
|
}
|
||||||
|
const confirmation = await db.rooms.insertOne(newRoom)
|
||||||
|
newRoom._id = confirmation.insertedId
|
||||||
|
return { oldRoom: room, newRoom }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findThreadsById(projectId, threadIds) {
|
||||||
|
return await db.rooms
|
||||||
|
.find({
|
||||||
|
project_id: new ObjectId(projectId),
|
||||||
|
thread_id: { $in: threadIds.map(id => new ObjectId(id)) },
|
||||||
|
})
|
||||||
|
.toArray()
|
||||||
|
}
|
||||||
|
|
|
@ -303,6 +303,64 @@ paths:
|
||||||
description: chat is alive
|
description: chat is alive
|
||||||
operationId: getStatus
|
operationId: getStatus
|
||||||
description: Check that the Chat service is alive
|
description: Check that the Chat service is alive
|
||||||
|
'/project/{projectId}/duplicate-comment-threads':
|
||||||
|
parameters:
|
||||||
|
- schema:
|
||||||
|
type: string
|
||||||
|
name: projectId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
post:
|
||||||
|
summary: Duplicate comment threads
|
||||||
|
operationId: duplicateCommentThreads
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
threads:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
newThreads:
|
||||||
|
type: object
|
||||||
|
description: Mapping of old thread ids to their duplicated thread ids
|
||||||
|
description: Duplicate a list of comment threads
|
||||||
|
'/project/{projectId}/generate-thread-data':
|
||||||
|
parameters:
|
||||||
|
- schema:
|
||||||
|
type: string
|
||||||
|
name: projectId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
post:
|
||||||
|
summary: Generate thread data to load into the frontend
|
||||||
|
operationId: generateThreadData
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
threads:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Load threads and generate a json blob containing all messages in all the threads
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
Message:
|
Message:
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { ObjectId } from '../../../app/js/mongodb.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
import * as ChatClient from './helpers/ChatClient.js'
|
||||||
|
import * as ChatApp from './helpers/ChatApp.js'
|
||||||
|
|
||||||
|
const user1Id = new ObjectId().toString()
|
||||||
|
const user2Id = new ObjectId().toString()
|
||||||
|
|
||||||
|
async function createCommentThread(projectId, threadId = new ObjectId()) {
|
||||||
|
const { response: response1 } = await ChatClient.sendMessage(
|
||||||
|
projectId,
|
||||||
|
threadId.toString(),
|
||||||
|
user1Id,
|
||||||
|
'message 1'
|
||||||
|
)
|
||||||
|
expect(response1.statusCode).to.equal(201)
|
||||||
|
const { response: response2 } = await ChatClient.sendMessage(
|
||||||
|
projectId,
|
||||||
|
threadId,
|
||||||
|
user2Id,
|
||||||
|
'message 2'
|
||||||
|
)
|
||||||
|
expect(response2.statusCode).to.equal(201)
|
||||||
|
return threadId.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Cloning comment threads', async function () {
|
||||||
|
const projectId = new ObjectId().toString()
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await ChatApp.ensureRunning()
|
||||||
|
this.thread1Id = await createCommentThread(projectId)
|
||||||
|
this.thread2Id = await createCommentThread(projectId)
|
||||||
|
this.thread3Id = await createCommentThread(projectId)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with non-orphaned threads', async function () {
|
||||||
|
before(async function () {
|
||||||
|
const {
|
||||||
|
response: { body: result, statusCode },
|
||||||
|
} = await ChatClient.duplicateCommentThreads(projectId, [this.thread3Id])
|
||||||
|
this.result = result
|
||||||
|
expect(statusCode).to.equal(200)
|
||||||
|
expect(this.result).to.have.property('newThreads')
|
||||||
|
this.newThreadId = this.result.newThreads[this.thread3Id].duplicateId
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should duplicate threads', function () {
|
||||||
|
expect(this.result.newThreads).to.have.property(this.thread3Id)
|
||||||
|
expect(this.result.newThreads[this.thread3Id]).to.have.property(
|
||||||
|
'duplicateId'
|
||||||
|
)
|
||||||
|
expect(this.result.newThreads[this.thread3Id].duplicateId).to.not.equal(
|
||||||
|
this.thread3Id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not duplicate other threads threads', function () {
|
||||||
|
expect(this.result.newThreads).to.not.have.property(this.thread1Id)
|
||||||
|
expect(this.result.newThreads).to.not.have.property(this.thread2Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should duplicate the messages in the thread', async function () {
|
||||||
|
const {
|
||||||
|
response: { body: threads },
|
||||||
|
} = await ChatClient.getThreads(projectId)
|
||||||
|
function ignoreId(comment) {
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
id: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(threads[this.thread3Id].messages.map(ignoreId)).to.deep.equal(
|
||||||
|
threads[this.newThreadId].messages.map(ignoreId)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have two separate unlinked threads', async function () {
|
||||||
|
await ChatClient.sendMessage(
|
||||||
|
projectId,
|
||||||
|
this.newThreadId,
|
||||||
|
user1Id,
|
||||||
|
'third message'
|
||||||
|
)
|
||||||
|
const {
|
||||||
|
response: { body: threads },
|
||||||
|
} = await ChatClient.getThreads(projectId)
|
||||||
|
expect(threads[this.thread3Id].messages.length).to.equal(2)
|
||||||
|
expect(threads[this.newThreadId].messages.length).to.equal(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -144,3 +144,13 @@ export async function destroyProject(projectId) {
|
||||||
url: `/project/${projectId}`,
|
url: `/project/${projectId}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateCommentThreads(projectId, threads) {
|
||||||
|
return await asyncRequest({
|
||||||
|
method: 'post',
|
||||||
|
url: `/project/${projectId}/duplicate-comment-threads`,
|
||||||
|
json: {
|
||||||
|
threads,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -97,6 +97,28 @@ async function getResolvedThreadIds(projectId) {
|
||||||
return body.resolvedThreadIds
|
return body.resolvedThreadIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicateCommentThreads(projectId, threads) {
|
||||||
|
return await fetchJson(
|
||||||
|
chatApiUrl(`/project/${projectId}/duplicate-comment-threads`),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
json: {
|
||||||
|
threads,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThreadData(projectId, threads) {
|
||||||
|
return await fetchJson(
|
||||||
|
chatApiUrl(`/project/${projectId}/generate-thread-data`),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
json: { threads },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function chatApiUrl(path) {
|
function chatApiUrl(path) {
|
||||||
return new URL(path, settings.apis.chat.internal_url)
|
return new URL(path, settings.apis.chat.internal_url)
|
||||||
}
|
}
|
||||||
|
@ -113,6 +135,8 @@ module.exports = {
|
||||||
editMessage: callbackify(editMessage),
|
editMessage: callbackify(editMessage),
|
||||||
deleteMessage: callbackify(deleteMessage),
|
deleteMessage: callbackify(deleteMessage),
|
||||||
getResolvedThreadIds: callbackify(getResolvedThreadIds),
|
getResolvedThreadIds: callbackify(getResolvedThreadIds),
|
||||||
|
duplicateCommentThreads: callbackify(duplicateCommentThreads),
|
||||||
|
generateThreadData: callbackify(generateThreadData),
|
||||||
promises: {
|
promises: {
|
||||||
getThreads,
|
getThreads,
|
||||||
destroyProject,
|
destroyProject,
|
||||||
|
@ -125,5 +149,7 @@ module.exports = {
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
getResolvedThreadIds,
|
getResolvedThreadIds,
|
||||||
|
duplicateCommentThreads,
|
||||||
|
generateThreadData,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,8 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
||||||
const SessionManager = require('../Authentication/SessionManager')
|
const SessionManager = require('../Authentication/SessionManager')
|
||||||
const UserInfoManager = require('../User/UserInfoManager')
|
const UserInfoManager = require('../User/UserInfoManager')
|
||||||
const UserInfoController = require('../User/UserInfoController')
|
const UserInfoController = require('../User/UserInfoController')
|
||||||
const async = require('async')
|
const ChatManager = require('./ChatManager')
|
||||||
|
const logger = require('@overleaf/logger')
|
||||||
|
|
||||||
module.exports = ChatController = {
|
module.exports = ChatController = {
|
||||||
sendMessage(req, res, next) {
|
sendMessage(req, res, next) {
|
||||||
|
@ -68,7 +69,7 @@ module.exports = ChatController = {
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
return next(err)
|
return next(err)
|
||||||
}
|
}
|
||||||
return ChatController._injectUserInfoIntoThreads(
|
return ChatManager.injectUserInfoIntoThreads(
|
||||||
{ global: { messages } },
|
{ global: { messages } },
|
||||||
function (err) {
|
function (err) {
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
|
@ -80,55 +81,4 @@ module.exports = ChatController = {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
_injectUserInfoIntoThreads(threads, callback) {
|
|
||||||
// There will be a lot of repitition of user_ids, so first build a list
|
|
||||||
// of unique ones to perform db look ups on, then use these to populate the
|
|
||||||
// user fields
|
|
||||||
let message, thread, threadId, userId
|
|
||||||
if (callback == null) {
|
|
||||||
callback = function () {}
|
|
||||||
}
|
|
||||||
const userIds = {}
|
|
||||||
for (threadId in threads) {
|
|
||||||
thread = threads[threadId]
|
|
||||||
if (thread.resolved) {
|
|
||||||
userIds[thread.resolved_by_user_id] = true
|
|
||||||
}
|
|
||||||
for (message of Array.from(thread.messages)) {
|
|
||||||
userIds[message.user_id] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobs = []
|
|
||||||
const users = {}
|
|
||||||
for (userId in userIds) {
|
|
||||||
const _ = userIds[userId]
|
|
||||||
;(userId =>
|
|
||||||
jobs.push(cb =>
|
|
||||||
UserInfoManager.getPersonalInfo(userId, function (error, user) {
|
|
||||||
if (error != null) return cb(error)
|
|
||||||
user = UserInfoController.formatPersonalInfo(user)
|
|
||||||
users[userId] = user
|
|
||||||
cb()
|
|
||||||
})
|
|
||||||
))(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return async.series(jobs, function (error) {
|
|
||||||
if (error != null) {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
for (threadId in threads) {
|
|
||||||
thread = threads[threadId]
|
|
||||||
if (thread.resolved) {
|
|
||||||
thread.resolved_by_user = users[thread.resolved_by_user_id]
|
|
||||||
}
|
|
||||||
for (message of Array.from(thread.messages)) {
|
|
||||||
message.user = users[message.user_id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return callback(null, threads)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
61
services/web/app/src/Features/Chat/ChatManager.js
Normal file
61
services/web/app/src/Features/Chat/ChatManager.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
const async = require('async')
|
||||||
|
const UserInfoManager = require('../User/UserInfoManager')
|
||||||
|
const UserInfoController = require('../User/UserInfoController')
|
||||||
|
const { promisify } = require('@overleaf/promise-utils')
|
||||||
|
|
||||||
|
function injectUserInfoIntoThreads(threads, callback) {
|
||||||
|
// There will be a lot of repitition of user_ids, so first build a list
|
||||||
|
// of unique ones to perform db look ups on, then use these to populate the
|
||||||
|
// user fields
|
||||||
|
let message, thread, threadId, userId
|
||||||
|
if (callback == null) {
|
||||||
|
callback = function () {}
|
||||||
|
}
|
||||||
|
const userIds = {}
|
||||||
|
for (threadId in threads) {
|
||||||
|
thread = threads[threadId]
|
||||||
|
if (thread.resolved) {
|
||||||
|
userIds[thread.resolved_by_user_id] = true
|
||||||
|
}
|
||||||
|
for (message of Array.from(thread.messages)) {
|
||||||
|
userIds[message.user_id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = []
|
||||||
|
const users = {}
|
||||||
|
for (userId in userIds) {
|
||||||
|
;(userId =>
|
||||||
|
jobs.push(cb =>
|
||||||
|
UserInfoManager.getPersonalInfo(userId, function (error, user) {
|
||||||
|
if (error != null) return cb(error)
|
||||||
|
user = UserInfoController.formatPersonalInfo(user)
|
||||||
|
users[userId] = user
|
||||||
|
cb()
|
||||||
|
})
|
||||||
|
))(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return async.series(jobs, function (error) {
|
||||||
|
if (error != null) {
|
||||||
|
return callback(error)
|
||||||
|
}
|
||||||
|
for (threadId in threads) {
|
||||||
|
thread = threads[threadId]
|
||||||
|
if (thread.resolved) {
|
||||||
|
thread.resolved_by_user = users[thread.resolved_by_user_id]
|
||||||
|
}
|
||||||
|
for (message of Array.from(thread.messages)) {
|
||||||
|
message.user = users[message.user_id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callback(null, threads)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
injectUserInfoIntoThreads,
|
||||||
|
promises: {
|
||||||
|
injectUserInfoIntoThreads: promisify(injectUserInfoIntoThreads),
|
||||||
|
},
|
||||||
|
}
|
|
@ -9,6 +9,11 @@ const { callbackifyAll } = require('@overleaf/promise-utils')
|
||||||
const { fetchJson } = require('@overleaf/fetch-utils')
|
const { fetchJson } = require('@overleaf/fetch-utils')
|
||||||
const ProjectLocator = require('../Project/ProjectLocator')
|
const ProjectLocator = require('../Project/ProjectLocator')
|
||||||
const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
|
const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
|
||||||
|
const ChatApiHandler = require('../Chat/ChatApiHandler')
|
||||||
|
const DocstoreManager = require('../Docstore/DocstoreManager')
|
||||||
|
const logger = require('@overleaf/logger')
|
||||||
|
const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
||||||
|
const ChatManager = require('../Chat/ChatManager')
|
||||||
|
|
||||||
const RestoreManager = {
|
const RestoreManager = {
|
||||||
async restoreFileFromV2(userId, projectId, version, pathname) {
|
async restoreFileFromV2(userId, projectId, version, pathname) {
|
||||||
|
@ -88,17 +93,17 @@ const RestoreManager = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
await DocumentUpdaterHandler.promises.setDocument(
|
logger.debug(
|
||||||
|
{ projectId, fileId: file.element._id, type: importInfo.type },
|
||||||
|
'deleting entity before reverting it'
|
||||||
|
)
|
||||||
|
await EditorController.promises.deleteEntity(
|
||||||
projectId,
|
projectId,
|
||||||
file.element._id,
|
file.element._id,
|
||||||
userId,
|
importInfo.type,
|
||||||
importInfo.lines,
|
'revert',
|
||||||
source
|
userId
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
_id: file.element._id,
|
|
||||||
type: importInfo.type,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranges = await RestoreManager._getRangesFromHistory(
|
const ranges = await RestoreManager._getRangesFromHistory(
|
||||||
|
@ -107,12 +112,73 @@ const RestoreManager = {
|
||||||
pathname
|
pathname
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const documentCommentIds = new Set(
|
||||||
|
ranges.comments?.map(({ op: { t } }) => t)
|
||||||
|
)
|
||||||
|
|
||||||
|
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
||||||
|
|
||||||
|
const docsWithRanges =
|
||||||
|
await DocstoreManager.promises.getAllRanges(projectId)
|
||||||
|
|
||||||
|
const nonOrphanedThreadIds = new Set()
|
||||||
|
for (const { ranges } of docsWithRanges) {
|
||||||
|
for (const comment of ranges.comments ?? []) {
|
||||||
|
nonOrphanedThreadIds.add(comment.op.t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentIdsToDuplicate = Array.from(documentCommentIds).filter(id =>
|
||||||
|
nonOrphanedThreadIds.has(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRanges = { changes: ranges.changes, comments: [] }
|
||||||
|
|
||||||
|
if (commentIdsToDuplicate.length > 0) {
|
||||||
|
const { newThreads: newCommentIds } =
|
||||||
|
await ChatApiHandler.promises.duplicateCommentThreads(
|
||||||
|
projectId,
|
||||||
|
commentIdsToDuplicate
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug({ mapping: newCommentIds }, 'replacing comment threads')
|
||||||
|
|
||||||
|
for (const comment of ranges.comments ?? []) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(newCommentIds, comment.op.t)) {
|
||||||
|
const result = newCommentIds[comment.op.t]
|
||||||
|
if (result.error) {
|
||||||
|
// We couldn't duplicate the thread, so we need to delete it from
|
||||||
|
// the resulting ranges.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// We have a new id for this comment thread
|
||||||
|
comment.op.t = result.duplicateId
|
||||||
|
newRanges.comments.push(comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newRanges.comments = ranges.comments
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCommentThreadData =
|
||||||
|
await ChatApiHandler.promises.generateThreadData(
|
||||||
|
projectId,
|
||||||
|
newRanges.comments.map(({ op: { t } }) => t)
|
||||||
|
)
|
||||||
|
await ChatManager.promises.injectUserInfoIntoThreads(newCommentThreadData)
|
||||||
|
logger.debug({ newCommentThreadData }, 'emitting new comment threads')
|
||||||
|
EditorRealTimeController.emitToRoom(
|
||||||
|
projectId,
|
||||||
|
'new-comment-threads',
|
||||||
|
newCommentThreadData
|
||||||
|
)
|
||||||
|
|
||||||
return await EditorController.promises.addDocWithRanges(
|
return await EditorController.promises.addDocWithRanges(
|
||||||
projectId,
|
projectId,
|
||||||
parentFolderId,
|
parentFolderId,
|
||||||
basename,
|
basename,
|
||||||
importInfo.lines,
|
importInfo.lines,
|
||||||
ranges,
|
newRanges,
|
||||||
'revert',
|
'revert',
|
||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
|
|
|
@ -1452,6 +1452,27 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
||||||
[getThread]
|
[getThread]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
useSocketListener(
|
||||||
|
socket,
|
||||||
|
'new-comment-threads',
|
||||||
|
useCallback(
|
||||||
|
(threads: ReviewPanelCommentThreadsApi) => {
|
||||||
|
setCommentThreads(prevState => {
|
||||||
|
const newThreads = { ...prevState }
|
||||||
|
for (const threadIdString of Object.keys(threads)) {
|
||||||
|
const threadId = threadIdString as ThreadId
|
||||||
|
const { submitting: _, ...thread } = getThread(threadId)
|
||||||
|
// Replace already loaded messages with the server provided ones
|
||||||
|
thread.messages = threads[threadId].messages.map(formatComment)
|
||||||
|
newThreads[threadId] = thread
|
||||||
|
}
|
||||||
|
return newThreads
|
||||||
|
})
|
||||||
|
handleLayoutChange({ async: true })
|
||||||
|
},
|
||||||
|
[getThread]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const openSubView = useRef<typeof subView>('cur_file')
|
const openSubView = useRef<typeof subView>('cur_file')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -152,8 +152,7 @@ export const LaTeXLanguage = LRLanguage.define({
|
||||||
'HrefCommand/ShortTextArgument/ShortArg/...': t.link,
|
'HrefCommand/ShortTextArgument/ShortArg/...': t.link,
|
||||||
'HrefCommand/UrlArgument/...': t.monospace,
|
'HrefCommand/UrlArgument/...': t.monospace,
|
||||||
'CtrlSeq Csname': t.tagName,
|
'CtrlSeq Csname': t.tagName,
|
||||||
'DocumentClass/OptionalArgument/ShortOptionalArg/Normal':
|
'DocumentClass/OptionalArgument/ShortOptionalArg/...': t.attributeValue,
|
||||||
t.attributeValue,
|
|
||||||
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
|
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
|
||||||
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
|
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
|
||||||
Number: t.number,
|
Number: t.number,
|
||||||
|
|
|
@ -322,7 +322,7 @@ const read1filename = function (TokeniseResult, k) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOptionalLabel = function (TokeniseResult, k) {
|
const readOptionalArgumentWithUnderscores = function (TokeniseResult, k) {
|
||||||
// read a label my_label:text..
|
// read a label my_label:text..
|
||||||
const Tokens = TokeniseResult.tokens
|
const Tokens = TokeniseResult.tokens
|
||||||
const text = TokeniseResult.text
|
const text = TokeniseResult.text
|
||||||
|
@ -348,6 +348,7 @@ const readOptionalLabel = function (TokeniseResult, k) {
|
||||||
label = label + str
|
label = label + str
|
||||||
if (str.match(/\]/)) {
|
if (str.match(/\]/)) {
|
||||||
// breaking due to ]
|
// breaking due to ]
|
||||||
|
j++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else if (tok[1] === '_') {
|
} else if (tok[1] === '_') {
|
||||||
|
@ -800,6 +801,31 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) {
|
||||||
) {
|
) {
|
||||||
// Environments.push({command: "end", name: "user-defined-equation", token: token});
|
// Environments.push({command: "end", name: "user-defined-equation", token: token});
|
||||||
seenUserDefinedEndEquation = true
|
seenUserDefinedEndEquation = true
|
||||||
|
} else if (seq === 'documentclass') {
|
||||||
|
// try to read any optional params [LABEL].... allowing for
|
||||||
|
// underscores, advance if found
|
||||||
|
let newPos = readOptionalArgumentWithUnderscores(TokeniseResult, i)
|
||||||
|
if (newPos instanceof Error) {
|
||||||
|
TokenErrorFromTo(
|
||||||
|
Tokens[i + 1],
|
||||||
|
Tokens[Math.min(newPos.pos, len - 1)],
|
||||||
|
'invalid documentclass option'
|
||||||
|
)
|
||||||
|
i = newPos.pos
|
||||||
|
} else if (newPos == null) {
|
||||||
|
/* do nothing */
|
||||||
|
} else {
|
||||||
|
i = newPos
|
||||||
|
}
|
||||||
|
// Read parameter {....}, ignore if missing
|
||||||
|
newPos = readDefinition(TokeniseResult, i)
|
||||||
|
if (newPos === null) {
|
||||||
|
// NOTE: We could choose to throw an error here, as the argument is
|
||||||
|
// required. However, that would show errors as you are typing. So
|
||||||
|
// maybe it's better to be lenient.
|
||||||
|
} else {
|
||||||
|
i = newPos
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
seq === 'newcommand' ||
|
seq === 'newcommand' ||
|
||||||
seq === 'renewcommand' ||
|
seq === 'renewcommand' ||
|
||||||
|
@ -1038,7 +1064,7 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) {
|
||||||
} else if (seq === 'hyperref') {
|
} else if (seq === 'hyperref') {
|
||||||
// try to read any optional params [LABEL].... allowing for
|
// try to read any optional params [LABEL].... allowing for
|
||||||
// underscores, advance if found
|
// underscores, advance if found
|
||||||
let newPos = readOptionalLabel(TokeniseResult, i)
|
let newPos = readOptionalArgumentWithUnderscores(TokeniseResult, i)
|
||||||
if (newPos instanceof Error) {
|
if (newPos instanceof Error) {
|
||||||
TokenErrorFromTo(
|
TokenErrorFromTo(
|
||||||
Tokens[i + 1],
|
Tokens[i + 1],
|
||||||
|
|
|
@ -484,6 +484,7 @@ ShortOptionalArg {
|
||||||
( textBase
|
( textBase
|
||||||
| NonEmptyGroup<ShortOptionalArg>
|
| NonEmptyGroup<ShortOptionalArg>
|
||||||
| "#" // macro character
|
| "#" // macro character
|
||||||
|
| "_" // underscore is used in some parameter names
|
||||||
)*
|
)*
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -191,11 +191,18 @@
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We want to be able to stack modals on top of the left-side menu. So we make
|
||||||
|
// it a little lower than the normal modal backdrops. We don't want to go too
|
||||||
|
// low, to avoid conflicting with dropdowns etc.
|
||||||
|
@left-menu-z-index-backdrop: @zindex-modal-background - 5;
|
||||||
|
@left-menu-z-index: @zindex-modal-background - 2;
|
||||||
|
|
||||||
// Make the Bootstrap Modal behavior as a left sidebar
|
// Make the Bootstrap Modal behavior as a left sidebar
|
||||||
#left-menu-modal {
|
#left-menu-modal {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
padding-left: 0 !important; // bootstrap modal may randomly give padding-left when zooming in / out in chrome
|
padding-left: 0 !important; // bootstrap modal may randomly give padding-left when zooming in / out in chrome
|
||||||
|
z-index: @left-menu-z-index;
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -219,5 +226,6 @@
|
||||||
// Don't disable backdrop that allows closing the Modal when clicking outside of it,
|
// Don't disable backdrop that allows closing the Modal when clicking outside of it,
|
||||||
// But match its background color with the original mask background color.
|
// But match its background color with the original mask background color.
|
||||||
.left-menu-modal-backdrop {
|
.left-menu-modal-backdrop {
|
||||||
|
z-index: @left-menu-z-index-backdrop;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
|
@ -494,6 +494,23 @@ describe('LatexLinter', function () {
|
||||||
assert.equal(errors[0].text, 'unclosed group {')
|
assert.equal(errors[0].text, 'unclosed group {')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should accept documentclass with no options', function () {
|
||||||
|
const { errors } = Parse('\\documentclass{article}')
|
||||||
|
assert.equal(errors.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept documentclass with options', function () {
|
||||||
|
const { errors } = Parse('\\documentclass[a4paper]{article}')
|
||||||
|
assert.equal(errors.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept documentclass with underscore in options', function () {
|
||||||
|
const { errors } = Parse(
|
||||||
|
'\\documentclass[my_custom_document_class_option]{my-custom-class}'
|
||||||
|
)
|
||||||
|
assert.equal(errors.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
// %novalidate
|
// %novalidate
|
||||||
// %begin novalidate
|
// %begin novalidate
|
||||||
// %end novalidate
|
// %end novalidate
|
||||||
|
|
|
@ -19,13 +19,13 @@ const modulePath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../../../../app/src/Features/Chat/ChatController'
|
'../../../../app/src/Features/Chat/ChatController'
|
||||||
)
|
)
|
||||||
const { expect } = require('chai')
|
|
||||||
|
|
||||||
describe('ChatController', function () {
|
describe('ChatController', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.user_id = 'mock-user-id'
|
this.user_id = 'mock-user-id'
|
||||||
this.settings = {}
|
this.settings = {}
|
||||||
this.ChatApiHandler = {}
|
this.ChatApiHandler = {}
|
||||||
|
this.ChatManager = {}
|
||||||
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
|
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
|
||||||
this.SessionManager = {
|
this.SessionManager = {
|
||||||
getLoggedInUserId: sinon.stub().returns(this.user_id),
|
getLoggedInUserId: sinon.stub().returns(this.user_id),
|
||||||
|
@ -34,6 +34,7 @@ describe('ChatController', function () {
|
||||||
requires: {
|
requires: {
|
||||||
'@overleaf/settings': this.settings,
|
'@overleaf/settings': this.settings,
|
||||||
'./ChatApiHandler': this.ChatApiHandler,
|
'./ChatApiHandler': this.ChatApiHandler,
|
||||||
|
'./ChatManager': this.ChatManager,
|
||||||
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
|
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
|
||||||
'../Authentication/SessionManager': this.SessionManager,
|
'../Authentication/SessionManager': this.SessionManager,
|
||||||
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
||||||
|
@ -106,7 +107,7 @@ describe('ChatController', function () {
|
||||||
limit: (this.limit = '30'),
|
limit: (this.limit = '30'),
|
||||||
before: (this.before = '12345'),
|
before: (this.before = '12345'),
|
||||||
}
|
}
|
||||||
this.ChatController._injectUserInfoIntoThreads = sinon.stub().yields()
|
this.ChatManager.injectUserInfoIntoThreads = sinon.stub().yields()
|
||||||
this.ChatApiHandler.getGlobalMessages = sinon
|
this.ChatApiHandler.getGlobalMessages = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.yields(null, (this.messages = ['mock', 'messages']))
|
.yields(null, (this.messages = ['mock', 'messages']))
|
||||||
|
@ -123,107 +124,4 @@ describe('ChatController', function () {
|
||||||
return this.res.json.calledWith(this.messages).should.equal(true)
|
return this.res.json.calledWith(this.messages).should.equal(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('_injectUserInfoIntoThreads', function () {
|
|
||||||
beforeEach(function () {
|
|
||||||
this.users = {
|
|
||||||
user_id_1: {
|
|
||||||
mock: 'user_1',
|
|
||||||
},
|
|
||||||
user_id_2: {
|
|
||||||
mock: 'user_2',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
this.UserInfoManager.getPersonalInfo = (userId, callback) => {
|
|
||||||
return callback(null, this.users[userId])
|
|
||||||
}
|
|
||||||
sinon.spy(this.UserInfoManager, 'getPersonalInfo')
|
|
||||||
return (this.UserInfoController.formatPersonalInfo = user => ({
|
|
||||||
formatted: user.mock,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should inject a user object into messaged and resolved data', function (done) {
|
|
||||||
return this.ChatController._injectUserInfoIntoThreads(
|
|
||||||
{
|
|
||||||
thread1: {
|
|
||||||
resolved: true,
|
|
||||||
resolved_by_user_id: 'user_id_1',
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
content: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user_id: 'user_id_2',
|
|
||||||
content: 'bar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
thread2: {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
content: 'baz',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(error, threads) => {
|
|
||||||
expect(threads).to.deep.equal({
|
|
||||||
thread1: {
|
|
||||||
resolved: true,
|
|
||||||
resolved_by_user_id: 'user_id_1',
|
|
||||||
resolved_by_user: { formatted: 'user_1' },
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
user: { formatted: 'user_1' },
|
|
||||||
content: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user_id: 'user_id_2',
|
|
||||||
user: { formatted: 'user_2' },
|
|
||||||
content: 'bar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
thread2: {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
user: { formatted: 'user_1' },
|
|
||||||
content: 'baz',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should only need to look up each user once', function (done) {
|
|
||||||
return this.ChatController._injectUserInfoIntoThreads(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
content: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user_id: 'user_id_1',
|
|
||||||
content: 'bar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
(error, threads) => {
|
|
||||||
this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
|
|
||||||
return done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal file
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
const SandboxedModule = require('sandboxed-module')
|
||||||
|
const path = require('path')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const modulePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../app/src/Features/Chat/ChatManager'
|
||||||
|
)
|
||||||
|
const { expect } = require('chai')
|
||||||
|
|
||||||
|
describe('ChatManager', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.user_id = 'mock-user-id'
|
||||||
|
this.ChatManager = SandboxedModule.require(modulePath, {
|
||||||
|
requires: {
|
||||||
|
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
||||||
|
'../User/UserInfoController': (this.UserInfoController = {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.req = {
|
||||||
|
params: {
|
||||||
|
project_id: this.project_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.res = {
|
||||||
|
json: sinon.stub(),
|
||||||
|
send: sinon.stub(),
|
||||||
|
sendStatus: sinon.stub(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('injectUserInfoIntoThreads', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
this.users = {
|
||||||
|
user_id_1: {
|
||||||
|
mock: 'user_1',
|
||||||
|
},
|
||||||
|
user_id_2: {
|
||||||
|
mock: 'user_2',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.UserInfoManager.getPersonalInfo = (userId, callback) => {
|
||||||
|
return callback(null, this.users[userId])
|
||||||
|
}
|
||||||
|
sinon.spy(this.UserInfoManager, 'getPersonalInfo')
|
||||||
|
return (this.UserInfoController.formatPersonalInfo = user => ({
|
||||||
|
formatted: user.mock,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should inject a user object into messaged and resolved data', function (done) {
|
||||||
|
return this.ChatManager.injectUserInfoIntoThreads(
|
||||||
|
{
|
||||||
|
thread1: {
|
||||||
|
resolved: true,
|
||||||
|
resolved_by_user_id: 'user_id_1',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
content: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'user_id_2',
|
||||||
|
content: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
thread2: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
content: 'baz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(error, threads) => {
|
||||||
|
expect(error).to.be.null
|
||||||
|
expect(threads).to.deep.equal({
|
||||||
|
thread1: {
|
||||||
|
resolved: true,
|
||||||
|
resolved_by_user_id: 'user_id_1',
|
||||||
|
resolved_by_user: { formatted: 'user_1' },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
user: { formatted: 'user_1' },
|
||||||
|
content: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'user_id_2',
|
||||||
|
user: { formatted: 'user_2' },
|
||||||
|
content: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
thread2: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
user: { formatted: 'user_1' },
|
||||||
|
content: 'baz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only need to look up each user once', function (done) {
|
||||||
|
return this.ChatManager.injectUserInfoIntoThreads(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
content: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'user_id_1',
|
||||||
|
content: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
(error, threads) => {
|
||||||
|
expect(error).to.be.null
|
||||||
|
this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
|
||||||
|
return done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -24,7 +24,16 @@ describe('RestoreManager', function () {
|
||||||
}),
|
}),
|
||||||
'../Project/ProjectLocator': (this.ProjectLocator = { promises: {} }),
|
'../Project/ProjectLocator': (this.ProjectLocator = { promises: {} }),
|
||||||
'../DocumentUpdater/DocumentUpdaterHandler':
|
'../DocumentUpdater/DocumentUpdaterHandler':
|
||||||
(this.DocumentUpdaterHandler = { promises: {} }),
|
(this.DocumentUpdaterHandler = {
|
||||||
|
promises: { flushProjectToMongo: sinon.stub().resolves() },
|
||||||
|
}),
|
||||||
|
'../Docstore/DocstoreManager': (this.DocstoreManager = {
|
||||||
|
promises: {},
|
||||||
|
}),
|
||||||
|
'../Chat/ChatApiHandler': (this.ChatApiHandler = { promises: {} }),
|
||||||
|
'../Chat/ChatManager': (this.ChatManager = { promises: {} }),
|
||||||
|
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
|
||||||
|
{}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.user_id = 'mock-user-id'
|
this.user_id = 'mock-user-id'
|
||||||
|
@ -231,10 +240,25 @@ describe('RestoreManager', function () {
|
||||||
this.DocumentUpdaterHandler.promises.setDocument = sinon
|
this.DocumentUpdaterHandler.promises.setDocument = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves()
|
.resolves()
|
||||||
|
this.EditorController.promises.deleteEntity = sinon.stub().resolves()
|
||||||
|
this.RestoreManager.promises._getRangesFromHistory = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({ changes: [], comments: [] })
|
||||||
|
this.DocstoreManager.promises.getAllRanges = sinon.stub().resolves([])
|
||||||
|
this.ChatApiHandler.promises.generateThreadData = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({})
|
||||||
|
this.ChatManager.promises.injectUserInfoIntoThreads = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves()
|
||||||
|
this.EditorRealTimeController.emitToRoom = sinon.stub()
|
||||||
|
this.EditorController.promises.addDocWithRanges = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call setDocument in document updater and revert file', async function () {
|
it('should delete the existing document', async function () {
|
||||||
const revertRes = await this.RestoreManager.promises.revertFile(
|
await this.RestoreManager.promises.revertFile(
|
||||||
this.user_id,
|
this.user_id,
|
||||||
this.project_id,
|
this.project_id,
|
||||||
this.version,
|
this.version,
|
||||||
|
@ -242,15 +266,14 @@ describe('RestoreManager', function () {
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
this.DocumentUpdaterHandler.promises.setDocument
|
this.EditorController.promises.deleteEntity
|
||||||
).to.have.been.calledWith(
|
).to.have.been.calledWith(
|
||||||
this.project_id,
|
this.project_id,
|
||||||
'mock-file-id',
|
'mock-file-id',
|
||||||
this.user_id,
|
'doc',
|
||||||
['foo', 'bar', 'baz'],
|
'revert',
|
||||||
'file-revert'
|
this.user_id
|
||||||
)
|
)
|
||||||
expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'doc' })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -297,7 +320,34 @@ describe('RestoreManager', function () {
|
||||||
describe("when reverting a file that doesn't current exist", function () {
|
describe("when reverting a file that doesn't current exist", function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
this.pathname = 'foo.tex'
|
this.pathname = 'foo.tex'
|
||||||
|
this.comments = [
|
||||||
|
(this.comment = { op: { t: 'comment-1', p: 0, c: 'foo' } }),
|
||||||
|
]
|
||||||
|
this.remappedComments = [{ op: { t: 'comment-2', p: 0, c: 'foo' } }]
|
||||||
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
|
this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
|
||||||
|
this.DocstoreManager.promises.getAllRanges = sinon.stub().resolves([
|
||||||
|
{
|
||||||
|
ranges: {
|
||||||
|
comments: [this.comment],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
this.ChatApiHandler.promises.duplicateCommentThreads = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({
|
||||||
|
newThreads: {
|
||||||
|
'comment-1': {
|
||||||
|
duplicateId: 'comment-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.ChatApiHandler.promises.generateThreadData = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({})
|
||||||
|
this.ChatManager.promises.injectUserInfoIntoThreads = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves()
|
||||||
|
this.EditorRealTimeController.emitToRoom = sinon.stub()
|
||||||
this.tracked_changes = [
|
this.tracked_changes = [
|
||||||
{
|
{
|
||||||
op: { pos: 4, i: 'bar' },
|
op: { pos: 4, i: 'bar' },
|
||||||
|
@ -308,13 +358,12 @@ describe('RestoreManager', function () {
|
||||||
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
|
metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
this.comments = [{ op: { t: 'comment-1', p: 0, c: 'foo' } }]
|
|
||||||
this.FileSystemImportManager.promises.importFile = sinon
|
this.FileSystemImportManager.promises.importFile = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
||||||
this.RestoreManager.promises._getRangesFromHistory = sinon
|
this.RestoreManager.promises._getRangesFromHistory = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves({ changes: this.tracked_changes, comment: this.comments })
|
.resolves({ changes: this.tracked_changes, comments: this.comments })
|
||||||
this.EditorController.promises.addDocWithRanges = sinon
|
this.EditorController.promises.addDocWithRanges = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves(
|
.resolves(
|
||||||
|
@ -336,7 +385,7 @@ describe('RestoreManager', function () {
|
||||||
this.folder_id,
|
this.folder_id,
|
||||||
'foo.tex',
|
'foo.tex',
|
||||||
['foo', 'bar', 'baz'],
|
['foo', 'bar', 'baz'],
|
||||||
{ changes: this.tracked_changes, comment: this.comments }
|
{ changes: this.tracked_changes, comments: this.remappedComments }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue