mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-14 20:40:17 -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)
|
||||
}
|
||||
|
||||
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) {
|
||||
const message = 'chat is alive'
|
||||
context.res.status(200).setBody(message)
|
||||
|
@ -120,6 +128,18 @@ const _getAllThreads = async (req, res) => {
|
|||
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 { projectId, threadId } = req.params
|
||||
const { user_id: userId } = req.body
|
||||
|
@ -254,3 +274,29 @@ async function _getMessages(clientThreadId, req, res) {
|
|||
logger.debug({ projectId, messages }, 'got 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
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
export class MissingThreadError extends Error {}
|
||||
|
||||
export const GLOBAL_THREAD = 'GLOBAL'
|
||||
|
||||
export async function findOrCreateThread(projectId, threadId) {
|
||||
|
@ -124,3 +126,32 @@ export async function getResolvedThreadIds(projectId) {
|
|||
.toArray()
|
||||
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
|
||||
operationId: getStatus
|
||||
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:
|
||||
schemas:
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return new URL(path, settings.apis.chat.internal_url)
|
||||
}
|
||||
|
@ -113,6 +135,8 @@ module.exports = {
|
|||
editMessage: callbackify(editMessage),
|
||||
deleteMessage: callbackify(deleteMessage),
|
||||
getResolvedThreadIds: callbackify(getResolvedThreadIds),
|
||||
duplicateCommentThreads: callbackify(duplicateCommentThreads),
|
||||
generateThreadData: callbackify(generateThreadData),
|
||||
promises: {
|
||||
getThreads,
|
||||
destroyProject,
|
||||
|
@ -125,5 +149,7 @@ module.exports = {
|
|||
editMessage,
|
||||
deleteMessage,
|
||||
getResolvedThreadIds,
|
||||
duplicateCommentThreads,
|
||||
generateThreadData,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -18,7 +18,8 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
|||
const SessionManager = require('../Authentication/SessionManager')
|
||||
const UserInfoManager = require('../User/UserInfoManager')
|
||||
const UserInfoController = require('../User/UserInfoController')
|
||||
const async = require('async')
|
||||
const ChatManager = require('./ChatManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
module.exports = ChatController = {
|
||||
sendMessage(req, res, next) {
|
||||
|
@ -68,7 +69,7 @@ module.exports = ChatController = {
|
|||
if (err != null) {
|
||||
return next(err)
|
||||
}
|
||||
return ChatController._injectUserInfoIntoThreads(
|
||||
return ChatManager.injectUserInfoIntoThreads(
|
||||
{ global: { messages } },
|
||||
function (err) {
|
||||
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 ProjectLocator = require('../Project/ProjectLocator')
|
||||
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 = {
|
||||
async restoreFileFromV2(userId, projectId, version, pathname) {
|
||||
|
@ -88,17 +93,17 @@ const RestoreManager = {
|
|||
}
|
||||
|
||||
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,
|
||||
file.element._id,
|
||||
userId,
|
||||
importInfo.lines,
|
||||
source
|
||||
importInfo.type,
|
||||
'revert',
|
||||
userId
|
||||
)
|
||||
return {
|
||||
_id: file.element._id,
|
||||
type: importInfo.type,
|
||||
}
|
||||
}
|
||||
|
||||
const ranges = await RestoreManager._getRangesFromHistory(
|
||||
|
@ -107,12 +112,73 @@ const RestoreManager = {
|
|||
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(
|
||||
projectId,
|
||||
parentFolderId,
|
||||
basename,
|
||||
importInfo.lines,
|
||||
ranges,
|
||||
newRanges,
|
||||
'revert',
|
||||
userId
|
||||
)
|
||||
|
|
|
@ -1452,6 +1452,27 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
|||
[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')
|
||||
useEffect(() => {
|
||||
|
|
|
@ -152,8 +152,7 @@ export const LaTeXLanguage = LRLanguage.define({
|
|||
'HrefCommand/ShortTextArgument/ShortArg/...': t.link,
|
||||
'HrefCommand/UrlArgument/...': t.monospace,
|
||||
'CtrlSeq Csname': t.tagName,
|
||||
'DocumentClass/OptionalArgument/ShortOptionalArg/Normal':
|
||||
t.attributeValue,
|
||||
'DocumentClass/OptionalArgument/ShortOptionalArg/...': t.attributeValue,
|
||||
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
|
||||
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
|
||||
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..
|
||||
const Tokens = TokeniseResult.tokens
|
||||
const text = TokeniseResult.text
|
||||
|
@ -348,6 +348,7 @@ const readOptionalLabel = function (TokeniseResult, k) {
|
|||
label = label + str
|
||||
if (str.match(/\]/)) {
|
||||
// breaking due to ]
|
||||
j++
|
||||
break
|
||||
}
|
||||
} else if (tok[1] === '_') {
|
||||
|
@ -800,6 +801,31 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) {
|
|||
) {
|
||||
// Environments.push({command: "end", name: "user-defined-equation", token: token});
|
||||
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 (
|
||||
seq === 'newcommand' ||
|
||||
seq === 'renewcommand' ||
|
||||
|
@ -1038,7 +1064,7 @@ const InterpretTokens = function (TokeniseResult, ErrorReporter) {
|
|||
} else if (seq === 'hyperref') {
|
||||
// try to read any optional params [LABEL].... allowing for
|
||||
// underscores, advance if found
|
||||
let newPos = readOptionalLabel(TokeniseResult, i)
|
||||
let newPos = readOptionalArgumentWithUnderscores(TokeniseResult, i)
|
||||
if (newPos instanceof Error) {
|
||||
TokenErrorFromTo(
|
||||
Tokens[i + 1],
|
||||
|
|
|
@ -484,6 +484,7 @@ ShortOptionalArg {
|
|||
( textBase
|
||||
| NonEmptyGroup<ShortOptionalArg>
|
||||
| "#" // macro character
|
||||
| "_" // underscore is used in some parameter names
|
||||
)*
|
||||
}
|
||||
|
||||
|
|
|
@ -191,11 +191,18 @@
|
|||
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
|
||||
#left-menu-modal {
|
||||
opacity: 1;
|
||||
overflow-y: hidden;
|
||||
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 {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
@ -219,5 +226,6 @@
|
|||
// 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.
|
||||
.left-menu-modal-backdrop {
|
||||
z-index: @left-menu-z-index-backdrop;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
|
|
@ -494,6 +494,23 @@ describe('LatexLinter', function () {
|
|||
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
|
||||
// %begin novalidate
|
||||
// %end novalidate
|
||||
|
|
|
@ -19,13 +19,13 @@ const modulePath = path.join(
|
|||
__dirname,
|
||||
'../../../../app/src/Features/Chat/ChatController'
|
||||
)
|
||||
const { expect } = require('chai')
|
||||
|
||||
describe('ChatController', function () {
|
||||
beforeEach(function () {
|
||||
this.user_id = 'mock-user-id'
|
||||
this.settings = {}
|
||||
this.ChatApiHandler = {}
|
||||
this.ChatManager = {}
|
||||
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
|
||||
this.SessionManager = {
|
||||
getLoggedInUserId: sinon.stub().returns(this.user_id),
|
||||
|
@ -34,6 +34,7 @@ describe('ChatController', function () {
|
|||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
'./ChatApiHandler': this.ChatApiHandler,
|
||||
'./ChatManager': this.ChatManager,
|
||||
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
|
||||
'../Authentication/SessionManager': this.SessionManager,
|
||||
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
||||
|
@ -106,7 +107,7 @@ describe('ChatController', function () {
|
|||
limit: (this.limit = '30'),
|
||||
before: (this.before = '12345'),
|
||||
}
|
||||
this.ChatController._injectUserInfoIntoThreads = sinon.stub().yields()
|
||||
this.ChatManager.injectUserInfoIntoThreads = sinon.stub().yields()
|
||||
this.ChatApiHandler.getGlobalMessages = sinon
|
||||
.stub()
|
||||
.yields(null, (this.messages = ['mock', 'messages']))
|
||||
|
@ -123,107 +124,4 @@ describe('ChatController', function () {
|
|||
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: {} }),
|
||||
'../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'
|
||||
|
@ -231,10 +240,25 @@ describe('RestoreManager', function () {
|
|||
this.DocumentUpdaterHandler.promises.setDocument = sinon
|
||||
.stub()
|
||||
.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 () {
|
||||
const revertRes = await this.RestoreManager.promises.revertFile(
|
||||
it('should delete the existing document', async function () {
|
||||
await this.RestoreManager.promises.revertFile(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version,
|
||||
|
@ -242,15 +266,14 @@ describe('RestoreManager', function () {
|
|||
)
|
||||
|
||||
expect(
|
||||
this.DocumentUpdaterHandler.promises.setDocument
|
||||
this.EditorController.promises.deleteEntity
|
||||
).to.have.been.calledWith(
|
||||
this.project_id,
|
||||
'mock-file-id',
|
||||
this.user_id,
|
||||
['foo', 'bar', 'baz'],
|
||||
'file-revert'
|
||||
'doc',
|
||||
'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 () {
|
||||
beforeEach(async function () {
|
||||
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.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 = [
|
||||
{
|
||||
op: { pos: 4, i: 'bar' },
|
||||
|
@ -308,13 +358,12 @@ describe('RestoreManager', function () {
|
|||
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
|
||||
.stub()
|
||||
.resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
|
||||
this.RestoreManager.promises._getRangesFromHistory = sinon
|
||||
.stub()
|
||||
.resolves({ changes: this.tracked_changes, comment: this.comments })
|
||||
.resolves({ changes: this.tracked_changes, comments: this.comments })
|
||||
this.EditorController.promises.addDocWithRanges = sinon
|
||||
.stub()
|
||||
.resolves(
|
||||
|
@ -336,7 +385,7 @@ describe('RestoreManager', function () {
|
|||
this.folder_id,
|
||||
'foo.tex',
|
||||
['foo', 'bar', 'baz'],
|
||||
{ changes: this.tracked_changes, comment: this.comments }
|
||||
{ changes: this.tracked_changes, comments: this.remappedComments }
|
||||
)
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue