diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index 2444c8cb0d..f2ce93f671 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -1,5 +1,6 @@ fs = require "fs" PackageVersions = require "./app/coffee/infrastructure/PackageVersions" +require('es6-promise').polyfill() module.exports = (grunt) -> grunt.loadNpmTasks 'grunt-contrib-coffee' @@ -18,6 +19,7 @@ module.exports = (grunt) -> grunt.loadNpmTasks 'grunt-contrib-watch' grunt.loadNpmTasks 'grunt-parallel' grunt.loadNpmTasks 'grunt-exec' + grunt.loadNpmTasks 'grunt-postcss' # grunt.loadNpmTasks 'grunt-contrib-imagemin' # grunt.loadNpmTasks 'grunt-sprity' @@ -136,8 +138,14 @@ module.exports = (grunt) -> files: "public/stylesheets/style.css": "public/stylesheets/style.less" - - + postcss: + options: + map: true, + processors: [ + require('autoprefixer')({browsers: [ 'last 2 versions', 'ie >= 10' ]}) + ] + dist: + src: 'public/stylesheets/style.css' env: run: @@ -222,11 +230,11 @@ module.exports = (grunt) -> sed: version: - path: "app/views/sentry.jade" + path: "app/views/sentry.pug" pattern: '@@COMMIT@@', replacement: '<%= commit %>', release: - path: "app/views/sentry.jade" + path: "app/views/sentry.pug" pattern: "@@RELEASE@@" replacement: process.env.BUILD_NUMBER || "(unknown build)" @@ -366,7 +374,7 @@ module.exports = (grunt) -> grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server'] grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes'] - grunt.registerTask 'compile:css', 'Compile the less files to css', ['less'] + grunt.registerTask 'compile:css', 'Compile the less files to css', ['less', 'postcss:dist'] grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",] grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests'] grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests'] @@ -389,5 +397,5 @@ module.exports = (grunt) -> grunt.registerTask 'default', 'run' - grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed'] + grunt.registerTask 'version', "Write the version number into sentry.pug", ['git-rev-parse', 'sed'] diff --git a/services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee b/services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee index 65013eae46..9c3a9f4deb 100644 --- a/services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee +++ b/services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee @@ -9,11 +9,11 @@ module.exports = if !settings?.apis?.analytics?.url? or !settings.apis.blog.url? return res.json [] - user_id = AuthenticationController.getLoggedInUserId(req) - logger.log {user_id}, "getting unread announcements" - AnnouncementsHandler.getUnreadAnnouncements user_id, (err, announcements)-> + user = AuthenticationController.getSessionUser(req) + logger.log {user_id:user?._id}, "getting unread announcements" + AnnouncementsHandler.getUnreadAnnouncements user, (err, announcements)-> if err? - logger.err {err, user_id}, "unable to get unread announcements" + logger.err {err:err, user_id:user._id}, "unable to get unread announcements" next(err) else res.json announcements diff --git a/services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee b/services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee index ce41e3b96c..9934a8bf69 100644 --- a/services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee +++ b/services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee @@ -1,24 +1,46 @@ AnalyticsManager = require("../Analytics/AnalyticsManager") BlogHandler = require("../Blog/BlogHandler") -async = require("async") -_ = require("lodash") logger = require("logger-sharelatex") settings = require("settings-sharelatex") +async = require("async") +_ = require("lodash") -module.exports = +module.exports = AnnouncementsHandler = + + _domainSpecificAnnouncements : (email)-> + domainSpecific = _.filter settings?.domainAnnouncements, (domainAnnouncment)-> + matches = _.filter domainAnnouncment.domains, (domain)-> + return email.indexOf(domain) != -1 + return matches.length > 0 and domainAnnouncment.id? + return domainSpecific or [] + + + getUnreadAnnouncements : (user, callback = (err, announcements)->)-> + if !user? and !user._id? + return callback("user not supplied") - getUnreadAnnouncements : (user_id, callback = (err, announcements)->)-> async.parallel { lastEvent: (cb)-> - AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb + AnalyticsManager.getLastOccurance user._id, "announcement-alert-dismissed", cb announcements: (cb)-> BlogHandler.getLatestAnnouncements cb }, (err, results)-> if err? - logger.err err:err, user_id:user_id, "error getting unread announcements" + logger.err err:err, user_id:user._id, "error getting unread announcements" return callback(err) - announcements = _.sortBy(results.announcements, "date").reverse() + domainSpecific = AnnouncementsHandler._domainSpecificAnnouncements(user?.email) + + domainSpecific = _.map domainSpecific, (domainAnnouncment)-> + try + domainAnnouncment.date = new Date(domainAnnouncment.date) + return domainAnnouncment + catch e + return callback(e) + + announcements = results.announcements + announcements = _.union announcements, domainSpecific + announcements = _.sortBy(announcements, "date").reverse() lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId @@ -35,6 +57,6 @@ module.exports = announcement.read = read return announcement - logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements" + logger.log announcementsLength:announcements?.length, user_id:user?._id, "returning announcements" callback null, announcements diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index e406296730..485b046a85 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -148,6 +148,7 @@ module.exports = AuthenticationController = return next() else logger.log url:req.url, "user trying to access endpoint not in global whitelist" + AuthenticationController._setRedirectInSession(req) return res.redirect "/login" httpAuth: basicAuth (user, pass)-> @@ -193,8 +194,8 @@ module.exports = AuthenticationController = _setRedirectInSession: (req, value) -> if !value? - value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else req.path - if req.session? + value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}" + if req.session? && !value.match(new RegExp('^\/(socket.io|js|stylesheets|img)\/.*$')) req.session.postLoginRedirect = value _getRedirectFromSession: (req) -> diff --git a/services/web/app/coffee/Features/Chat/ChatApiHandler.coffee b/services/web/app/coffee/Features/Chat/ChatApiHandler.coffee new file mode 100644 index 0000000000..3cae19b7f3 --- /dev/null +++ b/services/web/app/coffee/Features/Chat/ChatApiHandler.coffee @@ -0,0 +1,82 @@ +request = require("request") +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + +module.exports = ChatApiHandler = + _apiRequest: (opts, callback = (error, data) ->) -> + request opts, (error, response, data) -> + return callback(error) if error? + if 200 <= response.statusCode < 300 + return callback null, data + else + error = new Error("chat api returned non-success code: #{response.statusCode}") + error.statusCode = response.statusCode + logger.error {err: error, opts}, "error sending request to chat api" + return callback error + + sendGlobalMessage: (project_id, user_id, content, callback)-> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/messages" + method: "POST" + json: {user_id, content} + }, callback + + getGlobalMessages: (project_id, limit, before, callback)-> + qs = {} + qs.limit = limit if limit? + qs.before = before if before? + + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/messages" + method: "GET" + qs: qs + json: true + }, callback + + sendComment: (project_id, thread_id, user_id, content, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages" + method: "POST" + json: {user_id, content} + }, callback + + getThreads: (project_id, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/threads" + method: "GET" + json: true + }, callback + + resolveThread: (project_id, thread_id, user_id, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/resolve" + method: "POST" + json: {user_id} + }, callback + + reopenThread: (project_id, thread_id, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen" + method: "POST" + }, callback + + deleteThread: (project_id, thread_id, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}" + method: "DELETE" + }, callback + + editMessage: (project_id, thread_id, message_id, content, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}/edit" + method: "POST" + json: + content: content + }, callback + + deleteMessage: (project_id, thread_id, message_id, callback = (error) ->) -> + ChatApiHandler._apiRequest { + url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}" + method: "DELETE" + }, callback + \ No newline at end of file diff --git a/services/web/app/coffee/Features/Chat/ChatController.coffee b/services/web/app/coffee/Features/Chat/ChatController.coffee index 35c280712a..3090f4f108 100644 --- a/services/web/app/coffee/Features/Chat/ChatController.coffee +++ b/services/web/app/coffee/Features/Chat/ChatController.coffee @@ -1,33 +1,34 @@ -ChatHandler = require("./ChatHandler") +ChatApiHandler = require("./ChatApiHandler") EditorRealTimeController = require("../Editor/EditorRealTimeController") logger = require("logger-sharelatex") AuthenticationController = require('../Authentication/AuthenticationController') +UserInfoManager = require('../User/UserInfoManager') +UserInfoController = require('../User/UserInfoController') +CommentsController = require('../Comments/CommentsController') module.exports = - - sendMessage: (req, res, next)-> - project_id = req.params.Project_id - messageContent = req.body.content + project_id = req.params.project_id + content = req.body.content user_id = AuthenticationController.getLoggedInUserId(req) if !user_id? err = new Error('no logged-in user') return next(err) - ChatHandler.sendMessage project_id, user_id, messageContent, (err, builtMessge)-> - if err? - logger.err err:err, project_id:project_id, user_id:user_id, messageContent:messageContent, "problem sending message to chat api" - return res.sendStatus(500) - EditorRealTimeController.emitToRoom project_id, "new-chat-message", builtMessge, (err)-> - res.send() + ChatApiHandler.sendGlobalMessage project_id, user_id, content, (err, message) -> + return next(err) if err? + UserInfoManager.getPersonalInfo message.user_id, (err, user) -> + return next(err) if err? + message.user = UserInfoController.formatPersonalInfo(user) + EditorRealTimeController.emitToRoom project_id, "new-chat-message", message, (err)-> + res.send(204) - getMessages: (req, res)-> - project_id = req.params.Project_id + getMessages: (req, res, next)-> + project_id = req.params.project_id query = req.query logger.log project_id:project_id, query:query, "getting messages" - ChatHandler.getMessages project_id, query, (err, messages)-> - if err? - logger.err err:err, query:query, "problem getting messages from chat api" - return res.sendStatus 500 - logger.log length:messages?.length, "sending messages to client" - res.set 'Content-Type', 'application/json' - res.send messages + ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) -> + return next(err) if err? + CommentsController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) -> + return next(err) if err? + logger.log length: messages?.length, "sending messages to client" + res.json messages diff --git a/services/web/app/coffee/Features/Chat/ChatHandler.coffee b/services/web/app/coffee/Features/Chat/ChatHandler.coffee deleted file mode 100644 index b77652bc39..0000000000 --- a/services/web/app/coffee/Features/Chat/ChatHandler.coffee +++ /dev/null @@ -1,32 +0,0 @@ -request = require("request") -settings = require("settings-sharelatex") -logger = require("logger-sharelatex") - -module.exports = - - sendMessage: (project_id, user_id, messageContent, callback)-> - opts = - method:"post" - json: - content:messageContent - user_id:user_id - uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages" - request opts, (err, response, body)-> - if err? - logger.err err:err, "problem sending new message to chat" - callback(err, body) - - - - getMessages: (project_id, query, callback)-> - qs = {} - qs.limit = query.limit if query?.limit? - qs.before = query.before if query?.before? - - opts = - uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages" - method:"get" - qs: qs - - request opts, (err, response, body)-> - callback(err, body) \ No newline at end of file diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee index bc7eb90c3f..913562f417 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee @@ -11,7 +11,7 @@ module.exports = CollaboratorsEmailHandler = "user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}" ].join("&") - notifyUserOfProjectInvite: (project_id, email, invite, callback)-> + notifyUserOfProjectInvite: (project_id, email, invite, sendingUser, callback)-> Project .findOne(_id: project_id ) .select("name owner_ref") @@ -24,4 +24,5 @@ module.exports = CollaboratorsEmailHandler = name: project.name inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite) owner: project.owner_ref + sendingUser_id: sendingUser._id EmailHandler.sendEmail "projectInvite", emailOptions, callback diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee index 9d9f4d2a5e..a2314da57f 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -10,6 +10,7 @@ EditorRealTimeController = require("../Editor/EditorRealTimeController") NotificationsBuilder = require("../Notifications/NotificationsBuilder") AnalyticsManger = require("../Analytics/AnalyticsManager") AuthenticationController = require("../Authentication/AuthenticationController") +rateLimiter = require("../../infrastructure/RateLimiter") module.exports = CollaboratorsInviteController = @@ -31,12 +32,28 @@ module.exports = CollaboratorsInviteController = callback(null, userExists) else callback(null, true) + + _checkRateLimit: (user_id, callback = (error) ->) -> + LimitationsManager.allowedNumberOfCollaboratorsForUser user_id, (err, collabLimit = 1)-> + return callback(err) if err? + if collabLimit == -1 + collabLimit = 20 + collabLimit = collabLimit * 10 + opts = + endpointName: "invite-to-project-by-user-id" + timeInterval: 60 * 30 + subjectName: user_id + throttle: collabLimit + rateLimiter.addCount opts, callback inviteToProject: (req, res, next) -> projectId = req.params.Project_id email = req.body.email sendingUser = AuthenticationController.getSessionUser(req) sendingUserId = sendingUser._id + if email == sendingUser.email + logger.log {projectId, email, sendingUserId}, "cannot invite yourself to project" + return res.json {invite: null, error: 'cannot_invite_self'} logger.log {projectId, email, sendingUserId}, "inviting to project" LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) => return next(error) if error? @@ -48,20 +65,24 @@ module.exports = CollaboratorsInviteController = if !email? or email == "" logger.log {projectId, email, sendingUserId}, "invalid email address" return res.sendStatus(400) - CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)-> - if err? - logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address" - return next(err) - if !shouldAllowInvite - logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address" - return res.json {invite: null, error: 'cannot_invite_non_user'} - CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) -> + CollaboratorsInviteController._checkRateLimit sendingUserId, (error, underRateLimit) -> + return next(error) if error? + if !underRateLimit + return res.sendStatus(429) + CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)-> if err? - logger.err {projectId, email, sendingUserId}, "error creating project invite" + logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address" return next(err) - logger.log {projectId, email, sendingUserId}, "invite created" - EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true}) - return res.json {invite: invite} + if !shouldAllowInvite + logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address" + return res.json {invite: null, error: 'cannot_invite_non_user'} + CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) -> + if err? + logger.err {projectId, email, sendingUserId}, "error creating project invite" + return next(err) + logger.log {projectId, email, sendingUserId}, "invite created" + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true}) + return res.json {invite: invite} revokeInvite: (req, res, next) -> projectId = req.params.Project_id diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee index 5ed6570c3a..ecca8ab86f 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -53,7 +53,7 @@ module.exports = CollaboratorsInviteHandler = _sendMessages: (projectId, sendingUser, invite, callback=(err)->) -> logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite" - CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)-> + CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, sendingUser, (err)-> return callback(err) if err? CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)-> return callback(err) if err? @@ -80,7 +80,7 @@ module.exports = CollaboratorsInviteHandler = # Send email and notification in background CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) -> if err? - logger.err {projectId, email}, "error sending messages for invite" + logger.err {err, projectId, email}, "error sending messages for invite" callback(null, invite) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee index 4c7cc8c76a..ea7e1f89f8 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee @@ -22,9 +22,15 @@ module.exports = webRouter.post( '/project/:Project_id/invite', RateLimiterMiddlewear.rateLimit({ - endpointName: "invite-to-project" + endpointName: "invite-to-project-by-project-id" params: ["Project_id"] - maxRequests: 200 + maxRequests: 100 + timeInterval: 60 * 10 + }), + RateLimiterMiddlewear.rateLimit({ + endpointName: "invite-to-project-by-ip" + ipOnly:true + maxRequests: 100 timeInterval: 60 * 10 }), AuthenticationController.requireLogin(), diff --git a/services/web/app/coffee/Features/Comments/CommentsController.coffee b/services/web/app/coffee/Features/Comments/CommentsController.coffee new file mode 100644 index 0000000000..bda006eb8f --- /dev/null +++ b/services/web/app/coffee/Features/Comments/CommentsController.coffee @@ -0,0 +1,111 @@ +ChatApiHandler = require("../Chat/ChatApiHandler") +EditorRealTimeController = require("../Editor/EditorRealTimeController") +logger = require("logger-sharelatex") +AuthenticationController = require('../Authentication/AuthenticationController') +UserInfoManager = require('../User/UserInfoManager') +UserInfoController = require('../User/UserInfoController') +DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler" +async = require "async" + +module.exports = CommentsController = + sendComment: (req, res, next) -> + {project_id, thread_id} = req.params + content = req.body.content + user_id = AuthenticationController.getLoggedInUserId(req) + if !user_id? + err = new Error('no logged-in user') + return next(err) + logger.log {project_id, thread_id, user_id, content}, "sending comment" + ChatApiHandler.sendComment project_id, thread_id, user_id, content, (err, comment) -> + return next(err) if err? + UserInfoManager.getPersonalInfo comment.user_id, (err, user) -> + return next(err) if err? + comment.user = UserInfoController.formatPersonalInfo(user) + EditorRealTimeController.emitToRoom project_id, "new-comment", thread_id, comment, (err) -> + res.send 204 + + getThreads: (req, res, next) -> + {project_id} = req.params + logger.log {project_id}, "getting comment threads for project" + ChatApiHandler.getThreads project_id, (err, threads) -> + return next(err) if err? + CommentsController._injectUserInfoIntoThreads threads, (error, threads) -> + return next(err) if err? + res.json threads + + resolveThread: (req, res, next) -> + {project_id, thread_id} = req.params + user_id = AuthenticationController.getLoggedInUserId(req) + logger.log {project_id, thread_id, user_id}, "resolving comment thread" + ChatApiHandler.resolveThread project_id, thread_id, user_id, (err) -> + return next(err) if err? + UserInfoManager.getPersonalInfo user_id, (err, user) -> + return next(err) if err? + EditorRealTimeController.emitToRoom project_id, "resolve-thread", thread_id, UserInfoController.formatPersonalInfo(user), (err)-> + res.send 204 + + reopenThread: (req, res, next) -> + {project_id, thread_id} = req.params + logger.log {project_id, thread_id}, "reopening comment thread" + ChatApiHandler.reopenThread project_id, thread_id, (err, threads) -> + return next(err) if err? + EditorRealTimeController.emitToRoom project_id, "reopen-thread", thread_id, (err)-> + res.send 204 + + deleteThread: (req, res, next) -> + {project_id, doc_id, thread_id} = req.params + logger.log {project_id, doc_id, thread_id}, "deleting comment thread" + DocumentUpdaterHandler.deleteThread project_id, doc_id, thread_id, (err) -> + return next(err) if err? + ChatApiHandler.deleteThread project_id, thread_id, (err, threads) -> + return next(err) if err? + EditorRealTimeController.emitToRoom project_id, "delete-thread", thread_id, (err)-> + res.send 204 + + editMessage: (req, res, next) -> + {project_id, thread_id, message_id} = req.params + {content} = req.body + logger.log {project_id, thread_id, message_id}, "editing message thread" + ChatApiHandler.editMessage project_id, thread_id, message_id, content, (err) -> + return next(err) if err? + EditorRealTimeController.emitToRoom project_id, "edit-message", thread_id, message_id, content, (err)-> + res.send 204 + + deleteMessage: (req, res, next) -> + {project_id, thread_id, message_id} = req.params + logger.log {project_id, thread_id, message_id}, "deleting message" + ChatApiHandler.deleteMessage project_id, thread_id, message_id, (err, threads) -> + return next(err) if err? + EditorRealTimeController.emitToRoom project_id, "delete-message", thread_id, message_id, (err)-> + res.send 204 + + _injectUserInfoIntoThreads: (threads, callback = (error, threads) ->) -> + userCache = {} + getUserDetails = (user_id, callback = (error, user) ->) -> + return callback(null, userCache[user_id]) if userCache[user_id]? + UserInfoManager.getPersonalInfo user_id, (err, user) -> + return callback(error) if error? + user = UserInfoController.formatPersonalInfo user + userCache[user_id] = user + callback null, user + + jobs = [] + for thread_id, thread of threads + do (thread) -> + if thread.resolved + jobs.push (cb) -> + getUserDetails thread.resolved_by_user_id, (error, user) -> + cb(error) if error? + thread.resolved_by_user = user + cb() + for message in thread.messages + do (message) -> + jobs.push (cb) -> + getUserDetails message.user_id, (error, user) -> + cb(error) if error? + message.user = user + cb() + + async.series jobs, (error) -> + return callback(error) if error? + return callback null, threads \ No newline at end of file diff --git a/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee b/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee index 772d927d78..06dd14c17b 100644 --- a/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee +++ b/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee @@ -29,6 +29,21 @@ module.exports = DocstoreManager = error = new Error("docstore api responded with non-success code: #{res.statusCode}") logger.error err: error, project_id: project_id, "error getting all docs from docstore" callback(error) + + getAllRanges: (project_id, callback = (error) ->) -> + logger.log { project_id }, "getting all doc ranges for project in docstore api" + url = "#{settings.apis.docstore.url}/project/#{project_id}/ranges" + request.get { + url: url + json: true + }, (error, res, docs) -> + return callback(error) if error? + if 200 <= res.statusCode < 300 + callback(null, docs) + else + error = new Error("docstore api responded with non-success code: #{res.statusCode}") + logger.error err: error, project_id: project_id, "error getting all doc ranges from docstore" + callback(error) getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev, version) ->) -> if typeof(options) == "function" @@ -45,13 +60,13 @@ module.exports = DocstoreManager = return callback(error) if error? if 200 <= res.statusCode < 300 logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api" - callback(null, doc.lines, doc.rev, doc.version) + callback(null, doc.lines, doc.rev, doc.version, doc.ranges) else error = new Error("docstore api responded with non-success code: #{res.statusCode}") logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore" callback(error) - updateDoc: (project_id, doc_id, lines, version, callback = (error, modified, rev) ->) -> + updateDoc: (project_id, doc_id, lines, version, ranges, callback = (error, modified, rev) ->) -> logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api" url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}" request.post { @@ -59,6 +74,7 @@ module.exports = DocstoreManager = json: lines: lines version: version + ranges: ranges }, (error, res, result) -> return callback(error) if error? if 200 <= res.statusCode < 300 diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index dcf0615b25..5c15735410 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -95,7 +95,7 @@ module.exports = DocumentUpdaterHandler = logger.error err: error, project_id: project_id, doc_id: doc_id, "document updater returned failure status code: #{res.statusCode}" return callback(error) - getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) -> + getDocument: (project_id, doc_id, fromVersion, callback = (error, doclines, version, ranges, ops) ->) -> timer = new metrics.Timer("get-document") url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}" logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater" @@ -110,7 +110,7 @@ module.exports = DocumentUpdaterHandler = body = JSON.parse(body) catch error return callback(error) - callback null, body.lines, body.version, body.ops + callback null, body.lines, body.version, body.ranges, body.ops else logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}" callback new Error("doc updater returned a non-success status code: #{res.statusCode}") @@ -137,15 +137,37 @@ module.exports = DocumentUpdaterHandler = logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}" callback new Error("doc updater returned a non-success status code: #{res.statusCode}") - getNumberOfDocsInMemory : (callback)-> - request.get "#{settings.apis.documentupdater.url}/total", (err, req, body)-> - try - body = JSON.parse body - catch err - logger.err err:err, "error parsing response from doc updater about the total number of docs" - callback(err, body?.total) - + acceptChange: (project_id, doc_id, change_id, callback = (error) ->) -> + timer = new metrics.Timer("accept-change") + url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/change/#{change_id}/accept" + logger.log {project_id, doc_id, change_id}, "accepting change in document updater" + request.post url, (error, res, body)-> + timer.done() + if error? + logger.error {err:error, project_id, doc_id, change_id}, "error accepting change in doc updater" + return callback(error) + if res.statusCode >= 200 and res.statusCode < 300 + logger.log {project_id, doc_id, change_id}, "accepted change in document updater" + return callback(null) + else + logger.error {project_id, doc_id, change_id}, "doc updater returned a non-success status code: #{res.statusCode}" + callback new Error("doc updater returned a non-success status code: #{res.statusCode}") + deleteThread: (project_id, doc_id, thread_id, callback = (error) ->) -> + timer = new metrics.Timer("delete-thread") + url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/comment/#{thread_id}" + logger.log {project_id, doc_id, thread_id}, "deleting comment range in document updater" + request.del url, (error, res, body)-> + timer.done() + if error? + logger.error {err:error, project_id, doc_id, thread_id}, "error deleting comment range in doc updater" + return callback(error) + if res.statusCode >= 200 and res.statusCode < 300 + logger.log {project_id, doc_id, thread_id}, "deleted comment rangee in document updater" + return callback(null) + else + logger.error {project_id, doc_id, thread_id}, "doc updater returned a non-success status code: #{res.statusCode}" + callback new Error("doc updater returned a non-success status code: #{res.statusCode}") PENDINGUPDATESKEY = "PendingUpdates" DOCLINESKEY = "doclines" diff --git a/services/web/app/coffee/Features/Documents/DocumentController.coffee b/services/web/app/coffee/Features/Documents/DocumentController.coffee index 560f232ba1..2042f6a218 100644 --- a/services/web/app/coffee/Features/Documents/DocumentController.coffee +++ b/services/web/app/coffee/Features/Documents/DocumentController.coffee @@ -7,7 +7,7 @@ module.exports = doc_id = req.params.doc_id plain = req?.query?.plain == 'true' logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)" - ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version) -> + ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version, ranges) -> if error? logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument" return next(error) @@ -19,14 +19,15 @@ module.exports = res.send JSON.stringify { lines: lines version: version + ranges: ranges } setDocument: (req, res, next = (error) ->) -> project_id = req.params.Project_id doc_id = req.params.doc_id - {lines, version} = req.body + {lines, version, ranges} = req.body logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)" - ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, (error) -> + ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, ranges, (error) -> if error? logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument" return next(error) diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 476ba96174..b5abab3bf9 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -7,7 +7,6 @@ ProjectDetailsHandler = require('../Project/ProjectDetailsHandler') ProjectDeleter = require("../Project/ProjectDeleter") DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') EditorRealTimeController = require("./EditorRealTimeController") -TrackChangesManager = require("../TrackChanges/TrackChangesManager") async = require('async') LockManager = require("../../infrastructure/LockManager") _ = require('underscore') diff --git a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee new file mode 100644 index 0000000000..00a878c276 --- /dev/null +++ b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee @@ -0,0 +1,49 @@ +_ = require("underscore") +settings = require "settings-sharelatex" + +module.exports = _.template """ + + +
+
+

+ <%= title %> +

+
 
+

+ <%= greeting %> +

+

+ <%= message %> +

+
 
+
+
+ + <%= ctaText %> + +
+
+ <% if (secondaryMessage) { %> +
 
+

+ <%= secondaryMessage %> +

+ <% } %> +
+<% if (gmailGoToAction) { %> + +<% } %> +""" diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index 70d11e219b..0a06a2a175 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -1,6 +1,12 @@ _ = require('underscore') + PersonalEmailLayout = require("./Layouts/PersonalEmailLayout") NotificationEmailLayout = require("./Layouts/NotificationEmailLayout") +BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") + +SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody") + + settings = require("settings-sharelatex") @@ -61,7 +67,7 @@ ShareLaTeX Co-founder templates.passwordResetRequested = subject: _.template "Password Reset - #{settings.appName}" - layout: NotificationEmailLayout + layout: BaseWithHeaderEmailLayout type:"notification" plainTextTemplate: _.template """ Password Reset @@ -78,36 +84,21 @@ Thank you #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: _.template """ -

Password Reset

-

-We got a request to reset your #{settings.appName} password. -

-

-
-
- - - Reset password - - -
-
-
- -If you ignore this message, your password won't be changed. -

-If you didn't request a password reset, let us know. - -

-

Thank you

-

#{settings.appName}

-""" + compiledTemplate: (opts) -> + SingleCTAEmailBody({ + title: "Password Reset" + greeting: "Hi," + message: "We got a request to reset your #{settings.appName} password." + secondaryMessage: "If you ignore this message, your password won't be changed.
If you didn't request a password reset, let us know." + ctaText: "Reset password" + ctaURL: opts.setNewPasswordUrl + gmailGoToAction: null + }) templates.projectInvite = subject: _.template "<%= project.name %> - shared by <%= owner.email %>" - layout: NotificationEmailLayout + layout: BaseWithHeaderEmailLayout type:"notification" plainTextTemplate: _.template """ Hi, <%= owner.email %> wants to share '<%= project.name %>' with you. @@ -118,23 +109,23 @@ Thank you #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: _.template """ -

Hi, <%= owner.email %> wants to share '<%= project.name %>' with you

-
- - - View Project - - -
-

Thank you

-

#{settings.appName}

-""" - + compiledTemplate: (opts) -> + SingleCTAEmailBody({ + title: "#{ opts.project.name } – shared by #{ opts.owner.email }" + greeting: "Hi," + message: "#{ opts.owner.email } wants to share “#{ opts.project.name }” with you." + secondaryMessage: null + ctaText: "View project" + ctaURL: opts.inviteUrl + gmailGoToAction: + target: opts.inviteUrl + name: "View project" + description: "Join #{ opts.project.name } at ShareLaTeX" + }) templates.completeJoinGroupAccount = subject: _.template "Verify Email to join <%= group_name %> group" - layout: NotificationEmailLayout + layout: BaseWithHeaderEmailLayout type:"notification" plainTextTemplate: _.template """ Hi, please verify your email to join the <%= group_name %> and get your free premium account @@ -145,22 +136,39 @@ Thank You #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: _.template """ -

Hi, please verify your email to join the <%= group_name %> and get your free premium account

-
-
-
- - - Verify now - - -
-
-
-

Thank you

-

#{settings.appName}

+ compiledTemplate: (opts) -> + SingleCTAEmailBody({ + title: "Verify Email to join #{ opts.group_name } group" + greeting: "Hi," + message: "please verify your email to join the #{ opts.group_name } group and get your free premium account." + secondaryMessage: null + ctaText: "Verify now" + ctaURL: opts.completeJoinUrl + gmailGoToAction: null + }) + + +templates.testEmail = + subject: _.template "A Test Email from ShareLaTeX" + layout: BaseWithHeaderEmailLayout + type:"notification" + plainTextTemplate: _.template """ +Hi, + +This is a test email sent from ShareLaTeX. + +#{settings.appName} - <%= siteUrl %> """ + compiledTemplate: (opts) -> + SingleCTAEmailBody({ + title: "A Test Email from ShareLaTeX" + greeting: "Hi," + message: "This is a test email sent from ShareLaTeX" + secondaryMessage: null + ctaText: "Open ShareLaTeX" + ctaURL: "/" + gmailGoToAction: null + }) module.exports = diff --git a/services/web/app/coffee/Features/Email/EmailSender.coffee b/services/web/app/coffee/Features/Email/EmailSender.coffee index a7bcc82ed7..69574c8276 100644 --- a/services/web/app/coffee/Features/Email/EmailSender.coffee +++ b/services/web/app/coffee/Features/Email/EmailSender.coffee @@ -4,7 +4,7 @@ Settings = require('settings-sharelatex') nodemailer = require("nodemailer") sesTransport = require('nodemailer-ses-transport') sgTransport = require('nodemailer-sendgrid-transport') - +rateLimiter = require('../../infrastructure/RateLimiter') _ = require("underscore") if Settings.email? and Settings.email.fromAddress? @@ -39,24 +39,39 @@ if nm_client? else logger.warn "Failed to create email transport. Please check your settings. No email will be sent." +checkCanSendEmail = (options, callback)-> + if !options.sendingUser_id? #email not sent from user, not rate limited + return callback(null, true) + opts = + endpointName: "send_email" + timeInterval: 60 * 60 * 3 + subjectName: options.sendingUser_id + throttle: 100 + rateLimiter.addCount opts, callback module.exports = sendEmail : (options, callback = (error) ->)-> logger.log receiver:options.to, subject:options.subject, "sending email" - metrics.inc "email" - options = - to: options.to - from: defaultFromAddress - subject: options.subject - html: options.html - text: options.text - replyTo: options.replyTo || Settings.email.replyToAddress - socketTimeout: 30 * 1000 - if Settings.email.textEncoding? - opts.textEncoding = textEncoding - client.sendMail options, (err, res)-> + checkCanSendEmail options, (err, canContinue)-> if err? - logger.err err:err, "error sending message" - else - logger.log "Message sent to #{options.to}" - callback(err) + return callback(err) + if !canContinue + logger.log sendingUser_id:options.sendingUser_id, to:options.to, subject:options.subject, canContinue:canContinue, "rate limit hit for sending email, not sending" + return callback("rate limit hit sending email") + metrics.inc "email" + options = + to: options.to + from: defaultFromAddress + subject: options.subject + html: options.html + text: options.text + replyTo: options.replyTo || Settings.email.replyToAddress + socketTimeout: 30 * 1000 + if Settings.email.textEncoding? + opts.textEncoding = textEncoding + client.sendMail options, (err, res)-> + if err? + logger.err err:err, "error sending message" + else + logger.log "Message sent to #{options.to}" + callback(err) diff --git a/services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee b/services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee new file mode 100644 index 0000000000..6d25df2197 --- /dev/null +++ b/services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee @@ -0,0 +1,380 @@ +_ = require("underscore") +settings = require "settings-sharelatex" + +module.exports = _.template """ + + + + + + + + + Project invite + + + + + + + + +
+
+ +
+
+ + +
+
+

+ SHARELATEX +

+
+
+
+
 
+
+
 
+ + <%= body %> + +
+
 
+

+ #{ settings.appName} • #{ settings.siteUrl } +

+
+
+ +
+
+ +
                                                           
+ + + +""" diff --git a/services/web/app/coffee/Features/History/HistoryController.coffee b/services/web/app/coffee/Features/History/HistoryController.coffee new file mode 100644 index 0000000000..d4f42b38b1 --- /dev/null +++ b/services/web/app/coffee/Features/History/HistoryController.coffee @@ -0,0 +1,20 @@ +logger = require "logger-sharelatex" +request = require "request" +settings = require "settings-sharelatex" +AuthenticationController = require "../Authentication/AuthenticationController" + +module.exports = HistoryController = + proxyToHistoryApi: (req, res, next = (error) ->) -> + user_id = AuthenticationController.getLoggedInUserId req + url = settings.apis.trackchanges.url + req.url + logger.log url: url, "proxying to track-changes api" + getReq = request( + url: url + method: req.method + headers: + "X-User-Id": user_id + ) + getReq.pipe(res) + getReq.on "error", (error) -> + logger.error err: error, "track-changes API error" + next(error) diff --git a/services/web/app/coffee/Features/History/HistoryManager.coffee b/services/web/app/coffee/Features/History/HistoryManager.coffee new file mode 100644 index 0000000000..ea3f492613 --- /dev/null +++ b/services/web/app/coffee/Features/History/HistoryManager.coffee @@ -0,0 +1,28 @@ +settings = require "settings-sharelatex" +request = require "request" +logger = require "logger-sharelatex" + +module.exports = HistoryManager = + flushProject: (project_id, callback = (error) ->) -> + logger.log project_id: project_id, "flushing project in track-changes api" + url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush" + request.post url, (error, res, body) -> + return callback(error) if error? + if 200 <= res.statusCode < 300 + callback(null) + else + error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}") + logger.error err: error, project_id: project_id, "error flushing project in track-changes api" + callback(error) + + archiveProject: (project_id, callback = ()->)-> + logger.log project_id: project_id, "archving project in track-changes api" + url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive" + request.post url, (error, res, body) -> + return callback(error) if error? + if 200 <= res.statusCode < 300 + callback(null) + else + error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}") + logger.error err: error, project_id: project_id, "error archving project in track-changes api" + callback(error) \ No newline at end of file diff --git a/services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee b/services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee index a2afee0573..5c984dcb5d 100644 --- a/services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee +++ b/services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee @@ -5,8 +5,6 @@ DocstoreManager = require("../Docstore/DocstoreManager") ProjectGetter = require("../Project/ProjectGetter") ProjectUpdateHandler = require("../Project/ProjectUpdateHandler") Project = require("../../models/Project").Project -TrackChangesManager = require("../TrackChanges/TrackChangesManager") - MILISECONDS_IN_DAY = 86400000 module.exports = InactiveProjectManager = @@ -52,7 +50,6 @@ module.exports = InactiveProjectManager = logger.log project_id:project_id, "deactivating inactive project" jobs = [ (cb)-> DocstoreManager.archiveProject project_id, cb - # (cb)-> TrackChangesManager.archiveProject project_id, cb (cb)-> ProjectUpdateHandler.markAsInactive project_id, cb ] async.series jobs, (err)-> diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee index ec5371f0f2..618e9e0a7d 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee @@ -53,7 +53,11 @@ module.exports = if req.body.login_after UserGetter.getUser user_id, {email: 1}, (err, user) -> return next(err) if err? - AuthenticationController.doLogin {email:user.email, password: password}, req, res, next + AuthenticationController.afterLoginSessionSetup req, user, (err) -> + if err? + logger.err {err, email: user.email}, "Error setting up session after setting password" + return next(err) + res.json {redir: AuthenticationController._getRedirectFromSession(req) || "/project"} else res.sendStatus 200 else diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 1d975ea5b3..3e6beaa0fb 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -197,11 +197,11 @@ module.exports = ProjectController = user_id = null project_id = req.params.Project_id - logger.log project_id:project_id, "loading editor" + logger.log project_id:project_id, anonymous:anonymous, user_id:user_id, "loading editor" async.parallel { project: (cb)-> - ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb + ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1, track_changes: 1 }, cb user: (cb)-> if !user_id? cb null, defaultSettingsForAnonymousUser(user_id) @@ -267,6 +267,7 @@ module.exports = ProjectController = pdfViewer : user.ace.pdfViewer syntaxValidation: user.ace.syntaxValidation } + trackChangesEnabled: !!project.track_changes privilegeLevel: privilegeLevel chatUrl: Settings.apis.chat.url anonymous: anonymous diff --git a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee index 1a10321544..5da582aa4c 100644 --- a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee @@ -19,6 +19,11 @@ module.exports = ProjectEditorHandler = if !result.invites? result.invites = [] + + trackChangesVisible = false + for member in members + if member.privilegeLevel == "owner" and member.user?.featureSwitches?.track_changes + trackChangesVisible = true {owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members) result.owner = owner @@ -32,6 +37,8 @@ module.exports = ProjectEditorHandler = compileGroup:"standard" templates: false references: false + trackChanges: false + trackChangesVisible: trackChangesVisible }) return result diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 21932cefc9..eefaeab6ab 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -126,7 +126,7 @@ module.exports = ProjectEntityHandler = doc = new Doc name: docName # Put doc in docstore first, so that if it errors, we don't have a doc_id in the project # which hasn't been created in docstore. - DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, 0, (err, modified, rev) -> + DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, 0, {}, (err, modified, rev) -> return callback(err) if err? ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=> @@ -292,7 +292,7 @@ module.exports = ProjectEntityHandler = return callback(err) callback(err, folder, parentFolder_id) - updateDocLines : (project_id, doc_id, lines, version, callback = (error) ->)-> + updateDocLines : (project_id, doc_id, lines, version, ranges, callback = (error) ->)-> ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> return callback(err) if err? return callback(new Errors.NotFoundError("project not found")) if !project? @@ -307,7 +307,7 @@ module.exports = ProjectEntityHandler = return callback(error) logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc" - DocstoreManager.updateDoc project_id, doc_id, lines, version, (err, modified, rev) -> + DocstoreManager.updateDoc project_id, doc_id, lines, version, ranges, (err, modified, rev) -> if err? logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore" return callback(err) diff --git a/services/web/app/coffee/Features/Security/LoginRateLimiter.coffee b/services/web/app/coffee/Features/Security/LoginRateLimiter.coffee index a8453f9b81..20943628ed 100644 --- a/services/web/app/coffee/Features/Security/LoginRateLimiter.coffee +++ b/services/web/app/coffee/Features/Security/LoginRateLimiter.coffee @@ -1,23 +1,21 @@ -Settings = require('settings-sharelatex') -redis = require("redis-sharelatex") -rclient = redis.createClient(Settings.redis.web) +RateLimiter = require('../../infrastructure/RateLimiter') -buildKey = (k)-> - return "LoginRateLimit:#{k}" ONE_MIN = 60 ATTEMPT_LIMIT = 10 + module.exports = - processLoginRequest: (email, callback)-> - multi = rclient.multi() - multi.incr(buildKey(email)) - multi.get(buildKey(email)) - multi.expire(buildKey(email), ONE_MIN * 2) - multi.exec (err, results)-> - loginCount = results[1] - allow = loginCount <= ATTEMPT_LIMIT - callback err, allow + + processLoginRequest: (email, callback) -> + opts = + endpointName: 'login' + throttle: ATTEMPT_LIMIT + timeInterval: ONE_MIN * 2 + subjectName: email + RateLimiter.addCount opts, (err, shouldAllow) -> + callback(err, shouldAllow) recordSuccessfulLogin: (email, callback = ->)-> - rclient.del buildKey(email), callback \ No newline at end of file + RateLimiter.clearRateLimit 'login', email, callback + diff --git a/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee b/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee index f486e94493..04b81581bf 100644 --- a/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee +++ b/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee @@ -19,12 +19,15 @@ module.exports = RateLimiterMiddlewear = user_id = AuthenticationController.getLoggedInUserId(req) || req.ip params = (opts.params or []).map (p) -> req.params[p] params.push user_id + subjectName = params.join(":") + if opts.ipOnly + subjectName = req.ip if !opts.endpointName? throw new Error("no endpointName provided") options = { endpointName: opts.endpointName timeInterval: opts.timeInterval or 60 - subjectName: params.join(":") + subjectName: subjectName throttle: opts.maxRequests or 6 } RateLimiter.addCount options, (error, canContinue)-> diff --git a/services/web/app/coffee/Features/ServerAdmin/AdminController.coffee b/services/web/app/coffee/Features/ServerAdmin/AdminController.coffee index 005f2d23d3..b813878335 100755 --- a/services/web/app/coffee/Features/ServerAdmin/AdminController.coffee +++ b/services/web/app/coffee/Features/ServerAdmin/AdminController.coffee @@ -6,8 +6,6 @@ Project = require('../../models/Project').Project DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') Settings = require('settings-sharelatex') util = require('util') -redis = require("redis-sharelatex") -rclient = redis.createClient(Settings.redis.web) RecurlyWrapper = require('../Subscription/RecurlyWrapper') SubscriptionHandler = require('../Subscription/SubscriptionHandler') projectEntityHandler = require('../Project/ProjectEntityHandler') diff --git a/services/web/app/coffee/Features/StaticPages/HomeController.coffee b/services/web/app/coffee/Features/StaticPages/HomeController.coffee index 6675d55333..c1a8c46323 100755 --- a/services/web/app/coffee/Features/StaticPages/HomeController.coffee +++ b/services/web/app/coffee/Features/StaticPages/HomeController.coffee @@ -7,7 +7,7 @@ fs = require "fs" ErrorController = require "../Errors/ErrorController" AuthenticationController = require('../Authentication/AuthenticationController') -homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade") +homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.pug") module.exports = HomeController = index : (req,res)-> @@ -28,10 +28,10 @@ module.exports = HomeController = externalPage: (page, title) -> return (req, res, next = (error) ->) -> - path = Path.resolve(__dirname + "/../../../views/external/#{page}.jade") + path = Path.resolve(__dirname + "/../../../views/external/#{page}.pug") fs.exists path, (exists) -> # No error in this callback - old method in Node.js! if exists - res.render "external/#{page}.jade", + res.render "external/#{page}.pug", title: title else ErrorController.notFound(req, res, next) diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee index 59a0748f36..ec29b9257a 100644 --- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee +++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee @@ -1,20 +1,25 @@ logger = require("logger-sharelatex") Project = require("../../models/Project").Project -User = require("../../models/User").User +UserGetter = require("../User/UserGetter") SubscriptionLocator = require("./SubscriptionLocator") Settings = require("settings-sharelatex") CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler") module.exports = - allowedNumberOfCollaboratorsInProject: (project_id, callback) -> - getOwnerOfProject project_id, (error, owner)-> + Project.findById project_id, 'owner_ref', (error, project) => return callback(error) if error? - if owner.features? and owner.features.collaborators? - callback null, owner.features.collaborators + @allowedNumberOfCollaboratorsForUser project.owner_ref, callback + + allowedNumberOfCollaboratorsForUser: (user_id, callback) -> + UserGetter.getUser user_id, {features: 1}, (error, user) -> + return callback(error) if error? + if user.features? and user.features.collaborators? + callback null, user.features.collaborators else callback null, Settings.defaultPlanCode.collaborators + canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) -> @allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) => @@ -63,8 +68,4 @@ module.exports = logger.log user_id:user_id, limitReached:limitReached, currentTotal: subscription.member_ids.length, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached" callback(err, limitReached, subscription) -getOwnerOfProject = (project_id, callback)-> - Project.findById project_id, 'owner_ref', (error, project) -> - return callback(error) if error? - User.findById project.owner_ref, (error, owner) -> - callback(error, owner) +getOwnerIdOfProject = (project_id, callback)-> diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index 5e20c2b515..bce0befe22 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -4,7 +4,7 @@ editorController = require('../Editor/EditorController') logger = require('logger-sharelatex') Settings = require('settings-sharelatex') FileTypeManager = require('../Uploads/FileTypeManager') -uuid = require('node-uuid') +uuid = require('uuid') fs = require('fs') module.exports = diff --git a/services/web/app/coffee/Features/TrackChanges/RangesManager.coffee b/services/web/app/coffee/Features/TrackChanges/RangesManager.coffee new file mode 100644 index 0000000000..09e6b52ed1 --- /dev/null +++ b/services/web/app/coffee/Features/TrackChanges/RangesManager.coffee @@ -0,0 +1,23 @@ +DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler" +DocstoreManager = require "../Docstore/DocstoreManager" +UserInfoManager = require "../User/UserInfoManager" +async = require "async" + +module.exports = RangesManager = + getAllRanges: (project_id, callback = (error, docs) ->) -> + DocumentUpdaterHandler.flushProjectToMongo project_id, (error) -> + return callback(error) if error? + DocstoreManager.getAllRanges project_id, callback + + getAllChangesUsers: (project_id, callback = (error, users) ->) -> + user_ids = {} + RangesManager.getAllRanges project_id, (error, docs) -> + return callback(error) if error? + jobs = [] + for doc in docs + for change in doc.ranges?.changes or [] + user_ids[change.metadata.user_id] = true + + async.mapSeries Object.keys(user_ids), (user_id, cb) -> + UserInfoManager.getPersonalInfo user_id, cb + , callback \ No newline at end of file diff --git a/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee b/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee index bc6e00a29a..d71481a7fd 100644 --- a/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee +++ b/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee @@ -1,20 +1,42 @@ +RangesManager = require "./RangesManager" logger = require "logger-sharelatex" -request = require "request" -settings = require "settings-sharelatex" -AuthenticationController = require "../Authentication/AuthenticationController" +UserInfoController = require "../User/UserInfoController" +DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler" +EditorRealTimeController = require("../Editor/EditorRealTimeController") +TrackChangesManager = require "./TrackChangesManager" module.exports = TrackChangesController = - proxyToTrackChangesApi: (req, res, next = (error) ->) -> - user_id = AuthenticationController.getLoggedInUserId req - url = settings.apis.trackchanges.url + req.url - logger.log url: url, "proxying to track-changes api" - getReq = request( - url: url - method: req.method - headers: - "X-User-Id": user_id - ) - getReq.pipe(res) - getReq.on "error", (error) -> - logger.error err: error, "track-changes API error" - next(error) + getAllRanges: (req, res, next) -> + project_id = req.params.project_id + logger.log {project_id}, "request for project ranges" + RangesManager.getAllRanges project_id, (error, docs = []) -> + return next(error) if error? + docs = ({id: d._id, ranges: d.ranges} for d in docs) + res.json docs + + getAllChangesUsers: (req, res, next) -> + project_id = req.params.project_id + logger.log {project_id}, "request for project range users" + RangesManager.getAllChangesUsers project_id, (error, users) -> + return next(error) if error? + users = (UserInfoController.formatPersonalInfo(user) for user in users) + # Get rid of any anonymous/deleted user objects + users = users.filter (u) -> u?.id? + res.json users + + acceptChange: (req, res, next) -> + {project_id, doc_id, change_id} = req.params + logger.log {project_id, doc_id, change_id}, "request to accept change" + DocumentUpdaterHandler.acceptChange project_id, doc_id, change_id, (error) -> + return next(error) if error? + EditorRealTimeController.emitToRoom project_id, "accept-change", doc_id, change_id, (err)-> + res.send 204 + + toggleTrackChanges: (req, res, next) -> + {project_id} = req.params + track_changes_on = !!req.body.on + logger.log {project_id, track_changes_on}, "request to toggle track changes" + TrackChangesManager.toggleTrackChanges project_id, track_changes_on, (error) -> + return next(error) if error? + EditorRealTimeController.emitToRoom project_id, "toggle-track-changes", track_changes_on, (err)-> + res.send 204 diff --git a/services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee b/services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee index ddcfe3e44a..8eb7c10c29 100644 --- a/services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee +++ b/services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee @@ -1,28 +1,5 @@ -settings = require "settings-sharelatex" -request = require "request" -logger = require "logger-sharelatex" +Project = require("../../models/Project").Project module.exports = TrackChangesManager = - flushProject: (project_id, callback = (error) ->) -> - logger.log project_id: project_id, "flushing project in track-changes api" - url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush" - request.post url, (error, res, body) -> - return callback(error) if error? - if 200 <= res.statusCode < 300 - callback(null) - else - error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}") - logger.error err: error, project_id: project_id, "error flushing project in track-changes api" - callback(error) - - archiveProject: (project_id, callback = ()->)-> - logger.log project_id: project_id, "archving project in track-changes api" - url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive" - request.post url, (error, res, body) -> - return callback(error) if error? - if 200 <= res.statusCode < 300 - callback(null) - else - error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}") - logger.error err: error, project_id: project_id, "error archving project in track-changes api" - callback(error) \ No newline at end of file + toggleTrackChanges: (project_id, track_changes_on, callback = (error) ->) -> + Project.update {_id: project_id}, {track_changes: track_changes_on}, callback diff --git a/services/web/app/coffee/Features/User/UserInfoController.coffee b/services/web/app/coffee/Features/User/UserInfoController.coffee index 92f111bda7..8054f48afe 100644 --- a/services/web/app/coffee/Features/User/UserInfoController.coffee +++ b/services/web/app/coffee/Features/User/UserInfoController.coffee @@ -26,17 +26,14 @@ module.exports = UserController = UserController.sendFormattedPersonalInfo(user, res, next) sendFormattedPersonalInfo: (user, res, next = (error) ->) -> - UserController._formatPersonalInfo user, (error, info) -> - return next(error) if error? - res.send JSON.stringify(info) + info = UserController.formatPersonalInfo(user) + res.send JSON.stringify(info) - _formatPersonalInfo: (user, callback = (error, info) ->) -> - callback null, { - id: user._id.toString() - first_name: user.first_name - last_name: user.last_name - email: user.email - signUpDate: user.signUpDate - role: user.role - institution: user.institution - } + formatPersonalInfo: (user, callback = (error, info) ->) -> + if !user? + return {} + formatted_user = { id: user._id.toString() } + for key in ["first_name", "last_name", "email", "signUpDate", "role", "institution"] + if user[key]? + formatted_user[key] = user[key] + return formatted_user diff --git a/services/web/app/coffee/Features/User/UserInfoManager.coffee b/services/web/app/coffee/Features/User/UserInfoManager.coffee new file mode 100644 index 0000000000..90971e31a5 --- /dev/null +++ b/services/web/app/coffee/Features/User/UserInfoManager.coffee @@ -0,0 +1,5 @@ +UserGetter = require "./UserGetter" + +module.exports = UserInfoManager = + getPersonalInfo: (user_id, callback = (error) ->) -> + UserGetter.getUser user_id, { _id: true, first_name: true, last_name: true, email: true }, callback \ No newline at end of file diff --git a/services/web/app/coffee/Features/User/UserSessionsManager.coffee b/services/web/app/coffee/Features/User/UserSessionsManager.coffee index 78016e8a09..2cd3b17e7f 100644 --- a/services/web/app/coffee/Features/User/UserSessionsManager.coffee +++ b/services/web/app/coffee/Features/User/UserSessionsManager.coffee @@ -1,5 +1,4 @@ Settings = require('settings-sharelatex') -redis = require('redis-sharelatex') logger = require("logger-sharelatex") Async = require('async') _ = require('underscore') diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index e469df9422..8124d13d93 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -189,6 +189,7 @@ module.exports = (app, webRouter, apiRouter)-> return AuthenticationController.isUserLoggedIn(req) res.locals.getSessionUser = -> return AuthenticationController.getSessionUser(req) + next() webRouter.use (req, res, next) -> @@ -244,6 +245,8 @@ module.exports = (app, webRouter, apiRouter)-> for key, value of Settings.nav res.locals.nav[key] = _.clone(Settings.nav[key]) res.locals.templates = Settings.templateLinks + if res.locals.nav.header + console.error {}, "The `nav.header` setting is no longer supported, use `nav.header_extras` instead" next() webRouter.use (req, res, next) -> diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee index 1b3c2ea9a5..e7d521fa56 100644 --- a/services/web/app/coffee/infrastructure/Modules.coffee +++ b/services/web/app/coffee/infrastructure/Modules.coffee @@ -1,6 +1,6 @@ fs = require "fs" Path = require "path" -jade = require "jade" +pug = require "pug" async = require "async" MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules") @@ -29,7 +29,7 @@ module.exports = Modules = for module in @modules for view, partial of module.viewIncludes or {} @viewIncludes[view] ||= [] - @viewIncludes[view].push jade.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade")), doctype: "html") + @viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html") moduleIncludes: (view, locals) -> compiledPartials = Modules.viewIncludes[view] or [] diff --git a/services/web/app/coffee/infrastructure/RateLimiter.coffee b/services/web/app/coffee/infrastructure/RateLimiter.coffee index 7c84fc9db7..c749fa7e83 100644 --- a/services/web/app/coffee/infrastructure/RateLimiter.coffee +++ b/services/web/app/coffee/infrastructure/RateLimiter.coffee @@ -1,15 +1,27 @@ settings = require("settings-sharelatex") -redis = require("redis-sharelatex") -rclient = redis.createClient(settings.redis.web) -redback = require("redback").use(rclient) +RedisWrapper = require('./RedisWrapper') +rclient = RedisWrapper.client('ratelimiter') +RollingRateLimiter = require('rolling-rate-limiter') -module.exports = - addCount: (opts, callback = (opts, shouldProcess)->)-> - ratelimit = redback.createRateLimit(opts.endpointName) - ratelimit.addCount opts.subjectName, opts.timeInterval, (err, callCount)-> - shouldProcess = callCount < opts.throttle - callback(err, shouldProcess) - +module.exports = RateLimiter = + + addCount: (opts, callback = (err, shouldProcess)->)-> + namespace = "RateLimit:#{opts.endpointName}:" + k = "{#{opts.subjectName}}" + limiter = RollingRateLimiter({ + redis: rclient, + namespace: namespace, + interval: opts.timeInterval * 1000, + maxInInterval: opts.throttle + }) + limiter k, (err, timeLeft, actionsLeft) -> + if err? + return callback(err) + allowed = timeLeft == 0 + callback(null, allowed) + clearRateLimit: (endpointName, subject, callback) -> - rclient.del "#{endpointName}:#{subject}", callback \ No newline at end of file + # same as the key which will be built by RollingRateLimiter (namespace+k) + keyName = "RateLimit:#{endpointName}:{#{subject}}" + rclient.del keyName, callback diff --git a/services/web/app/coffee/infrastructure/RedisWrapper.coffee b/services/web/app/coffee/infrastructure/RedisWrapper.coffee new file mode 100644 index 0000000000..5d8b5836b5 --- /dev/null +++ b/services/web/app/coffee/infrastructure/RedisWrapper.coffee @@ -0,0 +1,28 @@ +Settings = require 'settings-sharelatex' +redis = require 'redis-sharelatex' +ioredis = require 'ioredis' +logger = require 'logger-sharelatex' + + +# A per-feature interface to Redis, +# looks up the feature in `settings.redis` +# and returns an appropriate client. +# Necessary because we don't want to migrate web over +# to redis-cluster all at once. + +# TODO: consider merging into `redis-sharelatex` + + +module.exports = Redis = + + # feature = 'websessions' | 'ratelimiter' | ... + client: (feature) -> + redisFeatureSettings = Settings.redis[feature] or Settings.redis.web + if redisFeatureSettings?.cluster? + logger.log {feature}, "creating redis-cluster client" + rclient = new ioredis.Cluster(redisFeatureSettings.cluster) + rclient.__is_redis_cluster = true + else + logger.log {feature}, "creating redis client" + rclient = redis.createClient(redisFeatureSettings) + return rclient diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee index 43683bdd4e..2218b72ecb 100644 --- a/services/web/app/coffee/infrastructure/Server.coffee +++ b/services/web/app/coffee/infrastructure/Server.coffee @@ -7,7 +7,6 @@ crawlerLogger = require('./CrawlerLogger') expressLocals = require('./ExpressLocals') Router = require('../router') metrics.inc("startup") -redis = require("redis-sharelatex") UserSessionsRedis = require('../Features/User/UserSessionsRedis') sessionsRedisClient = UserSessionsRedis.client() @@ -62,7 +61,7 @@ if Settings.behindProxy webRouter.use express.static(__dirname + '/../../../public', {maxAge: staticCacheAge }) app.set 'views', __dirname + '/../../views' -app.set 'view engine', 'jade' +app.set 'view engine', 'pug' Modules.loadViewIncludes app diff --git a/services/web/app/coffee/models/Project.coffee b/services/web/app/coffee/models/Project.coffee index 1d53999bd9..18387bdc0b 100644 --- a/services/web/app/coffee/models/Project.coffee +++ b/services/web/app/coffee/models/Project.coffee @@ -32,6 +32,7 @@ ProjectSchema = new Schema archived : { type: Boolean } deletedDocs : [DeletedDocSchema] imageName : { type: String } + track_changes : { type: Boolean } ProjectSchema.statics.getProject = (project_or_id, fields, callback)-> if project_or_id._id? diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index f120422128..e4097aaa67 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -2,7 +2,7 @@ Project = require('./Project').Project Settings = require 'settings-sharelatex' _ = require('underscore') mongoose = require('mongoose') -uuid = require('node-uuid') +uuid = require('uuid') Schema = mongoose.Schema ObjectId = Schema.ObjectId @@ -37,9 +37,10 @@ UserSchema = new Schema compileGroup: { type:String, default: Settings.defaultFeatures.compileGroup } templates: { type:Boolean, default: Settings.defaultFeatures.templates } references: { type:Boolean, default: Settings.defaultFeatures.references } + trackChanges: { type:Boolean, default: Settings.defaultFeatures.trackChanges } } featureSwitches : { - pdfng: { type: Boolean } + track_changes: { type: Boolean } } referal_id : {type:String, default:() -> uuid.v4().split("-")[0]} refered_users: [ type:ObjectId, ref:'User' ] diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 14ac3b8d22..62d5ec0865 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -25,7 +25,7 @@ ClsiCookieManager = require("./Features/Compile/ClsiCookieManager") HealthCheckController = require("./Features/HealthCheck/HealthCheckController") ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController" FileStoreController = require("./Features/FileStore/FileStoreController") -TrackChangesController = require("./Features/TrackChanges/TrackChangesController") +HistoryController = require("./Features/History/HistoryController") PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter") StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter") ChatController = require("./Features/Chat/ChatController") @@ -40,6 +40,8 @@ AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlew BetaProgramController = require('./Features/BetaProgram/BetaProgramController') AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter') AnnouncementsController = require("./Features/Announcements/AnnouncementsController") +TrackChangesController = require("./Features/TrackChanges/TrackChangesController") +CommentsController = require "./Features/Comments/CommentsController" logger = require("logger-sharelatex") _ = require("underscore") @@ -171,9 +173,14 @@ module.exports = class Router webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject - webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi - webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi - webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi + webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi + webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi + webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi + + webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges + webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers + webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange + webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects @@ -223,8 +230,17 @@ module.exports = class Router webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi - webRouter.get "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages - webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage + webRouter.get "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages + webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage + + # Note: Read only users can still comment + webRouter.post "/project/:project_id/thread/:thread_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.sendComment + webRouter.get "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads + webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread + webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread + webRouter.delete "/project/:project_id/doc/:doc_id/thread/:thread_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteThread + webRouter.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.editMessage + webRouter.delete "/project/:project_id/thread/:thread_id/messages/:message_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteMessage webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll diff --git a/services/web/app/views/admin/index.jade b/services/web/app/views/admin/index.pug similarity index 97% rename from services/web/app/views/admin/index.jade rename to services/web/app/views/admin/index.pug index 0fa9017cc1..050829e9d7 100644 --- a/services/web/app/views/admin/index.jade +++ b/services/web/app/views/admin/index.pug @@ -28,10 +28,10 @@ block content tab(heading="Open Sockets") .row-spaced ul - -each agents, url in openSockets + each agents, url in openSockets li #{url} - total : #{agents.length} ul - -each agent in agents + each agent in agents li #{agent} tab(heading="Close Editor") diff --git a/services/web/app/views/admin/register.jade b/services/web/app/views/admin/register.pug similarity index 100% rename from services/web/app/views/admin/register.jade rename to services/web/app/views/admin/register.pug diff --git a/services/web/app/views/beta_program/opt_in.jade b/services/web/app/views/beta_program/opt_in.pug similarity index 100% rename from services/web/app/views/beta_program/opt_in.jade rename to services/web/app/views/beta_program/opt_in.pug diff --git a/services/web/app/views/blog/blog_holder.jade b/services/web/app/views/blog/blog_holder.pug similarity index 100% rename from services/web/app/views/blog/blog_holder.jade rename to services/web/app/views/blog/blog_holder.pug diff --git a/services/web/app/views/contact-us-modal.jade b/services/web/app/views/contact-us-modal.pug similarity index 82% rename from services/web/app/views/contact-us-modal.jade rename to services/web/app/views/contact-us-modal.pug index f4fd8938b7..d3e5aa0e87 100644 --- a/services/web/app/views/contact-us-modal.jade +++ b/services/web/app/views/contact-us-modal.pug @@ -24,10 +24,10 @@ script(type='text/ng-template', id='supportModalTemplate') a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank") span(ng-bind-html="suggestion.name") i.fa.fa-angle-right - label.desc(ng-show="'#{getUserEmail()}'.length < 1") + label.desc(ng-show="'"+getUserEmail()+"'.length < 1") | #{translate("email")} - .form-group(ng-show="'#{getUserEmail()}'.length < 1") - input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + .form-group(ng-show="'"+getUserEmail()+"'.length < 1") + input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '"+getUserEmail()+"'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2') label#title12.desc | #{translate("project_url")} (#{translate("optional")}) .form-group @@ -37,6 +37,6 @@ script(type='text/ng-template', id='supportModalTemplate') .form-group textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='') .form-group.text-center - input.btn-success.btn.btn-lg(type='submit', ng-disabled="sending", ng-click="contactUs()" value='#{translate("contact_us")}') + input.btn-success.btn.btn-lg(type='submit', ng-disabled="sending", ng-click="contactUs()" value=translate("contact_us")) span(ng-show="sent") - p #{translate("request_sent_thank_you")} \ No newline at end of file + p #{translate("request_sent_thank_you")} diff --git a/services/web/app/views/general/404.jade b/services/web/app/views/general/404.pug similarity index 100% rename from services/web/app/views/general/404.jade rename to services/web/app/views/general/404.pug diff --git a/services/web/app/views/general/500.jade b/services/web/app/views/general/500.pug similarity index 100% rename from services/web/app/views/general/500.jade rename to services/web/app/views/general/500.pug diff --git a/services/web/app/views/general/closed.jade b/services/web/app/views/general/closed.pug similarity index 91% rename from services/web/app/views/general/closed.jade rename to services/web/app/views/general/closed.pug index 9f21372c81..4a27e84681 100644 --- a/services/web/app/views/general/closed.jade +++ b/services/web/app/views/general/closed.pug @@ -11,4 +11,4 @@ block content | Sorry, ShareLaTeX is briefly down for maintenance. | We should be back within minutes, but if not, or you have | an urgent request, please contact us at - | support@sharelatex.com + | #{settings.adminEmail} diff --git a/services/web/app/views/layout.jade b/services/web/app/views/layout.pug similarity index 97% rename from services/web/app/views/layout.jade rename to services/web/app/views/layout.pug index 8f4d1263db..75c96ff276 100644 --- a/services/web/app/views/layout.jade +++ b/services/web/app/views/layout.pug @@ -21,6 +21,8 @@ html(itemscope, itemtype='http://schema.org/Product') link(rel="icon", href="/favicon.ico") link(rel='stylesheet', href=buildCssPath('/style.css')) + block _headLinks + if settings.i18n.subdomainLang each subdomainDetails in settings.i18n.subdomainLang if !subdomainDetails.hide @@ -30,7 +32,7 @@ html(itemscope, itemtype='http://schema.org/Product') meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor") -if (typeof(meta) == "undefined") - meta(itemprop="description", name="description", content='#{translate("site_description")}') + meta(itemprop="description", name="description", content=translate("site_description")) -else meta(itemprop="description", name="description" , content=meta) diff --git a/services/web/app/views/layout/footer.jade b/services/web/app/views/layout/footer.pug similarity index 87% rename from services/web/app/views/layout/footer.jade rename to services/web/app/views/layout/footer.pug index efd64b6f6e..62a98ecfaa 100644 --- a/services/web/app/views/layout/footer.jade +++ b/services/web/app/views/layout/footer.pug @@ -13,9 +13,9 @@ footer.site-footer data-toggle="dropdown", aria-haspopup="true", aria-expanded="false", - tooltip="#{translate('language')}" + tooltip=translate('language') ) - figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}") + figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode) ul.dropdown-menu(role="menu") li.dropdown-header #{translate("language")} @@ -23,7 +23,7 @@ footer.site-footer if !subdomainDetails.hide li.lngOption a.menu-indent(href=subdomainDetails.url+currentUrl) - figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}") + figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode) | #{translate(subdomainDetails.lngCode)} //- img(src="/img/flags/24/.png") each item in nav.left_footer diff --git a/services/web/app/views/layout/navbar.jade b/services/web/app/views/layout/navbar.pug similarity index 60% rename from services/web/app/views/layout/navbar.jade rename to services/web/app/views/layout/navbar.pug index 3cd6587592..54509d6565 100644 --- a/services/web/app/views/layout/navbar.jade +++ b/services/web/app/views/layout/navbar.pug @@ -4,7 +4,7 @@ nav.navbar.navbar-default button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}") i.fa.fa-bars if settings.nav.custom_logo - a(href='/', style='background-image:url("#{settings.nav.custom_logo}")').navbar-brand + a(href='/', style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand else if (nav.title) a(href='/').navbar-title #{nav.title} else @@ -24,7 +24,10 @@ nav.navbar.navbar-default li a(href="/admin/user") Manage Users - each item in nav.header + + // loop over header_extras + each item in nav.header_extras + if ((item.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in)) if item.dropdown li.dropdown(class=item.class, dropdown) @@ -35,9 +38,6 @@ nav.navbar.navbar-default each child in item.dropdown if child.divider li.divider - else if child.user_email - li - div.subdued #{getUserEmail()} else li if child.url @@ -50,7 +50,35 @@ nav.navbar.navbar-default a(href=item.url, class=item.class) !{translate(item.text)} else | !{translate(item.text)} - - - + // logged out + if !getSessionUser() + // register link + if !externalAuthenticationSystemUsed() + li + a(href="/register") #{translate('register')} + + // login link + li + a(href="/login") #{translate('log_in')} + + // projects link and account menu + if getSessionUser() + li + a(href="/project") #{translate('Projects')} + li.dropdown(dropdown) + a.dropbodw-toggle(href, dropdown-toggle) + | #{translate('Account')} + b.caret + ul.dropdown-menu + li + div.subdued #{getUserEmail()} + li.divider + li + a(href="/user/settings") #{translate('Account Settings')} + if nav.showSubscriptionLink + li + a(href="/user/subscription") #{translate('subscription')} + li.divider + li + a(href="/logout") #{translate('log_out')} diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.pug similarity index 98% rename from services/web/app/views/project/editor.jade rename to services/web/app/views/project/editor.pug index 01e1a8b88f..54c742fd15 100644 --- a/services/web/app/views/project/editor.jade +++ b/services/web/app/views/project/editor.pug @@ -107,6 +107,7 @@ block requirejs window.csrfToken = "!{csrfToken}"; window.anonymous = #{anonymous}; window.maxDocLength = #{maxDocLength}; + window.trackChangesEnabled = #{trackChangesEnabled}; window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)}; window.requirejs = { "paths" : { diff --git a/services/web/app/views/project/editor/binary-file.jade b/services/web/app/views/project/editor/binary-file.pug similarity index 100% rename from services/web/app/views/project/editor/binary-file.jade rename to services/web/app/views/project/editor/binary-file.pug diff --git a/services/web/app/views/project/editor/chat.jade b/services/web/app/views/project/editor/chat.pug similarity index 97% rename from services/web/app/views/project/editor/chat.jade rename to services/web/app/views/project/editor/chat.pug index 47a1752834..fcd47a81e3 100644 --- a/services/web/app/views/project/editor/chat.jade +++ b/services/web/app/views/project/editor/chat.pug @@ -50,7 +50,7 @@ aside.chat( .new-message textarea( - placeholder="#{translate('your_message')}...", + placeholder=translate('your_message')+"...", on-enter="sendMessage()", ng-model="newMessageContent", ng-click="resetUnreadMessages()" diff --git a/services/web/app/views/project/editor/editor.jade b/services/web/app/views/project/editor/editor.pug similarity index 86% rename from services/web/app/views/project/editor/editor.jade rename to services/web/app/views/project/editor/editor.pug index 50da35d08a..9924fe1221 100644 --- a/services/web/app/views/project/editor/editor.jade +++ b/services/web/app/views/project/editor/editor.pug @@ -17,7 +17,9 @@ div.full-size( 'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\ 'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\ 'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\ - 'rp-size-expanded': ui.reviewPanelOpen\ + 'rp-size-expanded': ui.reviewPanelOpen,\ + 'rp-layout-left': reviewPanel.layoutToLeft,\ + 'rp-loading-threads': reviewPanel.loadingThreads\ }" ) .loading-panel(ng-show="!editor.sharejs_doc || editor.opening") @@ -51,12 +53,12 @@ div.full-size( syntax-validation="settings.syntaxValidation", review-panel="reviewPanel", events-bridge="reviewPanelEventsBridge" - track-changes-enabled="trackChangesFeatureFlag", - track-new-changes= "reviewPanel.trackNewChanges", - changes-tracker="reviewPanel.changesTracker", + track-changes-enabled="project.features.trackChangesVisible", + track-changes= "editor.trackChanges", doc-id="editor.open_doc_id" + renderer-data="reviewPanel.rendererData" ) - + include ./review-panel .ui-layout-east @@ -68,7 +70,7 @@ div.full-size( ng-controller="PdfSynctexController" ) a.btn.btn-default.btn-xs( - tooltip="#{translate('go_to_code_location_in_pdf')}" + tooltip=translate('go_to_code_location_in_pdf') tooltip-placement="right" tooltip-append-to-body="true" ng-click="syncToPdf()" @@ -76,7 +78,7 @@ div.full-size( i.fa.fa-long-arrow-right br a.btn.btn-default.btn-xs( - tooltip-html="'#{translate('go_to_pdf_location_in_code')}'" + tooltip-html="'"+translate('go_to_pdf_location_in_code')+"'" tooltip-placement="right" tooltip-append-to-body="true" ng-click="syncToCode()" @@ -88,4 +90,4 @@ div.full-size( ng-show="ui.view == 'pdf'" ) include ./pdf - \ No newline at end of file + diff --git a/services/web/app/views/project/editor/feature-onboarding.jade b/services/web/app/views/project/editor/feature-onboarding.pug similarity index 100% rename from services/web/app/views/project/editor/feature-onboarding.jade rename to services/web/app/views/project/editor/feature-onboarding.pug diff --git a/services/web/app/views/project/editor/file-tree.jade b/services/web/app/views/project/editor/file-tree.pug similarity index 97% rename from services/web/app/views/project/editor/file-tree.jade rename to services/web/app/views/project/editor/file-tree.pug index 92af8b627d..03c2bd79b7 100644 --- a/services/web/app/views/project/editor/file-tree.jade +++ b/services/web/app/views/project/editor/file-tree.pug @@ -3,21 +3,21 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected' a( href, ng-click="openNewDocModal()", - tooltip-html="'#{translate('new_file').replace(' ', '
')}'", + tooltip-html="'"+translate('new_file').replace(' ', '
')+"'", tooltip-placement="bottom" ) i.fa.fa-file a( href, ng-click="openNewFolderModal()", - tooltip-html="'#{translate('new_folder').replace(' ', '
')}'", + tooltip-html="'"+translate('new_folder').replace(' ', '
')+"'", tooltip-placement="bottom" ) i.fa.fa-folder a( href, ng-click="openUploadFileModal()", - tooltip="#{translate('upload')}", + tooltip=translate('upload'), tooltip-placement="bottom" ) i.fa.fa-upload @@ -26,7 +26,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected' a( href, ng-click="startRenamingSelected()", - tooltip="#{translate('rename')}", + tooltip=translate('rename'), tooltip-placement="bottom", ng-show="multiSelectedCount == 0" ) @@ -34,7 +34,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected' a( href, ng-click="openDeleteModalForSelected()", - tooltip="#{translate('delete')}", + tooltip=translate('delete'), tooltip-placement="bottom", tooltip-append-to-body="true" ) @@ -431,4 +431,4 @@ script(type='text/ng-template', id='invalidFileNameModalTemplate') .modal-footer button.btn.btn-default( ng-click="$close()" - ) #{translate('ok')} \ No newline at end of file + ) #{translate('ok')} diff --git a/services/web/app/views/project/editor/header.jade b/services/web/app/views/project/editor/header.pug similarity index 95% rename from services/web/app/views/project/editor/header.jade rename to services/web/app/views/project/editor/header.pug index d1305d28b9..85397fa83f 100644 --- a/services/web/app/views/project/editor/header.jade +++ b/services/web/app/views/project/editor/header.pug @@ -45,7 +45,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( ng-if="permissions.admin", href='#', tooltip-placement="bottom", - tooltip="#{translate('rename')}", + tooltip=translate('rename'), tooltip-append-to-body="true", ng-click="startRenaming()", ng-show="!state.renaming" @@ -71,7 +71,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4") span.online-user.online-user-multi( dropdown-toggle, - tooltip="#{translate('connected_users')}", + tooltip=translate('connected_users'), tooltip-placement="left" ) strong {{ onlineUsersArray.length }} @@ -87,7 +87,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( a.btn.btn-full-height( href, - ng-if="trackChangesFeatureFlag", + ng-if="project.features.trackChangesVisible", ng-class="{ active: ui.reviewPanelOpen }" ng-click="toggleReviewPanel()" ) @@ -121,4 +121,4 @@ header.toolbar.toolbar-header.toolbar-with-labels( span.label.label-info( ng-show="unreadMessages > 0" ) {{ unreadMessages }} - p.toolbar-label #{translate("chat")} \ No newline at end of file + p.toolbar-label #{translate("chat")} diff --git a/services/web/app/views/project/editor/history.jade b/services/web/app/views/project/editor/history.pug similarity index 100% rename from services/web/app/views/project/editor/history.jade rename to services/web/app/views/project/editor/history.pug diff --git a/services/web/app/views/project/editor/hotkeys.jade b/services/web/app/views/project/editor/hotkeys.pug similarity index 100% rename from services/web/app/views/project/editor/hotkeys.jade rename to services/web/app/views/project/editor/hotkeys.pug diff --git a/services/web/app/views/project/editor/left-menu.jade b/services/web/app/views/project/editor/left-menu.pug similarity index 96% rename from services/web/app/views/project/editor/left-menu.jade rename to services/web/app/views/project/editor/left-menu.pug index 80d5c606ab..4e86b31620 100644 --- a/services/web/app/views/project/editor/left-menu.jade +++ b/services/web/app/views/project/editor/left-menu.pug @@ -24,7 +24,7 @@ aside#left-menu.full-size( | PDF div.link-disabled( ng-if="!pdf.url" - tooltip="#{translate('please_compile_pdf_before_download')}" + tooltip=translate('please_compile_pdf_before_download') tooltip-placement="bottom" ) i.fa.fa-file-pdf-o.fa-2x @@ -47,7 +47,7 @@ aside#left-menu.full-size( a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()") i.fa.fa-fw.fa-eye span    #{translate("word_count")} - a.link-disabled(href, ng-if="!pdf.url" , tooltip="#{translate('please_compile_pdf_before_word_count')}") + a.link-disabled(href, ng-if="!pdf.url" , tooltip=translate('please_compile_pdf_before_word_count')) i.fa.fa-fw.fa-eye span.link-disabled    #{translate("word_count")} @@ -200,7 +200,7 @@ script(type='text/ng-template', id='wordCountModalTemplate') span   #{translate("loading")}... div.pdf-disabled( ng-if="!pdf.url" - tooltip="#{translate('please_compile_pdf_before_word_count')}" + tooltip=translate('please_compile_pdf_before_word_count') tooltip-placement="bottom" ) div(ng-if="!status.loading") diff --git a/services/web/app/views/project/editor/pdf.jade b/services/web/app/views/project/editor/pdf.pug similarity index 97% rename from services/web/app/views/project/editor/pdf.jade rename to services/web/app/views/project/editor/pdf.pug index 074856bd7c..ba03f068a6 100644 --- a/services/web/app/views/project/editor/pdf.jade +++ b/services/web/app/views/project/editor/pdf.pug @@ -2,7 +2,7 @@ div.full-size.pdf(ng-controller="PdfController") .toolbar.toolbar-tall .btn-group( dropdown, - tooltip-html="'#{translate('recompile_pdf')} ({{modifierKey}} + Enter)'" + tooltip-html="'"+translate('recompile_pdf')+" ({{modifierKey}} + Enter)'" tooltip-class="keyboard-tooltip" tooltip-popup-delay="500" tooltip-append-to-body="true" @@ -53,7 +53,7 @@ div.full-size.pdf(ng-controller="PdfController") href ng-click="stop()" ng-show="pdf.compiling", - tooltip="#{translate('stop_compile')}" + tooltip=translate('stop_compile') tooltip-placement="bottom" ) i.fa.fa-stop() @@ -61,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController") href ng-click="toggleLogs()" ng-class="{ 'active': shouldShowLogs == true }" - tooltip="#{translate('logs_and_output_files')}" + tooltip=translate('logs_and_output_files') tooltip-placement="bottom" ) i.fa.fa-file-text-o @@ -77,7 +77,7 @@ div.full-size.pdf(ng-controller="PdfController") ng-href="{{pdf.downloadUrl || pdf.url}}" target="_blank" ng-if="pdf.url" - tooltip="#{translate('download_pdf')}" + tooltip=translate('download_pdf') tooltip-placement="bottom" ) i.fa.fa-download @@ -87,7 +87,7 @@ div.full-size.pdf(ng-controller="PdfController") href, ng-click="switchToFlatLayout()" ng-show="ui.pdfLayout == 'sideBySide'" - tooltip="#{translate('full_screen')}" + tooltip=translate('full_screen') tooltip-placement="bottom" tooltip-append-to-body="true" ) @@ -96,7 +96,7 @@ div.full-size.pdf(ng-controller="PdfController") href, ng-click="switchToSideBySideLayout()" ng-show="ui.pdfLayout == 'flat'" - tooltip="#{translate('split_screen')}" + tooltip=translate('split_screen') tooltip-placement="bottom" tooltip-append-to-body="true" ) @@ -233,7 +233,7 @@ div.full-size.pdf(ng-controller="PdfController") .files-dropdown-container a.btn.btn-default.btn-sm( href, - tooltip="#{translate('clear_cached_files')}", + tooltip=translate('clear_cached_files'), tooltip-placement="top", tooltip-append-to-body="true", ng-click="openClearCacheModal()" diff --git a/services/web/app/views/project/editor/publish-template.jade b/services/web/app/views/project/editor/publish-template.pug similarity index 100% rename from services/web/app/views/project/editor/publish-template.jade rename to services/web/app/views/project/editor/publish-template.pug diff --git a/services/web/app/views/project/editor/review-panel.jade b/services/web/app/views/project/editor/review-panel.jade deleted file mode 100644 index dbb3a34631..0000000000 --- a/services/web/app/views/project/editor/review-panel.jade +++ /dev/null @@ -1,219 +0,0 @@ -#review-panel - .review-panel-toolbar - span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = true;", ng-if="reviewPanel.trackNewChanges === false") Track Changes is - strong off - span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = false;", ng-if="reviewPanel.trackNewChanges === true") Track Changes is - strong on - review-panel-toggle(ng-model="reviewPanel.trackNewChanges") - - .rp-entry-list( - review-panel-sorted - ng-if="reviewPanel.subView === SubViews.CUR_FILE" - ) - .rp-entry-list-inner - .rp-entry-wrapper( - ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]" - ) - div(ng-if="entry.type === 'insert' || entry.type === 'delete'") - change-entry( - entry="entry" - user="users[entry.metadata.user_id]" - on-reject="rejectChange(entry_id);" - on-accept="acceptChange(entry_id);" - on-indicator-click="toggleReviewPanel();" - ) - - div(ng-if="entry.type === 'comment'") - comment-entry( - entry="entry" - users="users" - on-resolve="resolveComment(entry, entry_id)" - on-unresolve="unresolveComment(entry_id)" - on-show-thread="showThread(entry)" - on-hide-thread="hideThread(entry)" - on-delete="deleteComment(entry_id)" - on-reply="submitReply(entry, entry_id);" - on-indicator-click="toggleReviewPanel();" - ) - - div(ng-if="entry.type === 'add-comment'") - add-comment-entry( - on-start-new="startNewComment();" - on-submit="submitNewComment(content);" - on-cancel="cancelNewComment();" - on-indicator-click="toggleReviewPanel();" - ) - - .rp-entry-list( - ng-if="reviewPanel.subView === SubViews.OVERVIEW" - ) - .rp-overview-file( - ng-repeat="(doc_id, entries) in reviewPanel.entries" - ) - .rp-overview-file-header - | {{ getFileName(doc_id) }} - .rp-entry-wrapper( - ng-repeat="(entry_id, entry) in entries | orderOverviewEntries" - ) - div(ng-if="entry.type === 'insert' || entry.type === 'delete'") - change-entry( - entry="entry" - user="users[entry.metadata.user_id]" - on-reject="rejectChange(entry.id);" - on-accept="acceptChange(entry.id);" - on-indicator-click="toggleReviewPanel();" - ng-click="gotoEntry(doc_id, entry)" - ) - - div(ng-if="entry.type === 'comment'") - comment-entry( - entry="entry" - users="users" - on-resolve="resolveComment(entry, entry.id)" - on-unresolve="unresolveComment(entry.id)" - on-delete="deleteComment(entry.id)" - on-reply="submitReply(entry, entry_id);" - on-indicator-click="toggleReviewPanel();" - ng-click="gotoEntry(doc_id, entry)" - ) - - .rp-nav - a.rp-nav-item( - href - ng-click="setSubView(SubViews.CUR_FILE);" - ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.CUR_FILE }" - ) - i.fa.fa-file-text-o - span.rp-nav-label Current file - a.rp-nav-item( - href - ng-click="setSubView(SubViews.OVERVIEW);" - ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.OVERVIEW }" - ) - i.fa.fa-list - span.rp-nav-label Overview - - -script(type='text/ng-template', id='changeEntryTemplate') - div - .rp-entry-callout( - ng-class="'rp-entry-callout-' + entry.type" - ) - .rp-entry-indicator( - ng-switch="entry.type" - ng-class="{ 'rp-entry-indicator-focused': entry.focused }" - ng-click="onIndicatorClick();" - ) - i.fa.fa-pencil(ng-switch-when="insert") - i.rp-icon-delete(ng-switch-when="delete") - .rp-entry( - ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]" - ) - .rp-entry-header - .rp-entry-action-icon(ng-switch="entry.type") - i.fa.fa-pencil(ng-switch-when="insert") - i.rp-icon-delete(ng-switch-when="delete") - .rp-entry-metadata - p.rp-entry-metadata-line(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} - p.rp-entry-metadata-line {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }} - .rp-avatar(style="background-color: hsl({{ user.hue }}, 70%, 50%);") {{ user.avatar_text | limitTo : 1 }} - .rp-entry-body(ng-switch="entry.type") - span(ng-switch-when="insert") Added  - ins.rp-content-highlight {{ entry.content }} - span(ng-switch-when="delete") Deleted  - del.rp-content-highlight {{ entry.content }} - .rp-entry-actions - a.rp-entry-button(href, ng-click="onReject();") - i.fa.fa-times - |  Reject - a.rp-entry-button(href, ng-click="onAccept();") - i.fa.fa-check - |  Accept - -script(type='text/ng-template', id='commentEntryTemplate') - div - .rp-entry-callout.rp-entry-callout-comment(ng-if="!entry.resolved") - .rp-entry-indicator( - ng-class="{ 'rp-entry-indicator-focused': entry.focused }" - ng-click="onIndicatorClick();" - ) - i.fa.fa-comment - .rp-entry.rp-entry-comment( - ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolved': entry.resolved}" - ) - .rp-comment( - ng-if="!entry.resolved || entry.showWhenResolved" - ng-repeat="comment in entry.thread" - ng-class="users[comment.user_id].isSelf ? 'rp-comment-self' : '';" - ) - .rp-avatar( - ng-if="!users[comment.user_id].isSelf;" - style="background-color: hsl({{ users[comment.user_id].hue }}, 70%, 50%);" - ) {{ users[comment.user_id].avatar_text | limitTo : 1 }} - .rp-comment-body(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 90%);") - p.rp-comment-content {{ comment.content }} - p.rp-comment-metadata - | {{ comment.ts | date : 'MMM d, y h:mm a' }} - |  •  - span(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 40%);") {{ users[comment.user_id].name }} - .rp-comment-reply(ng-if="!entry.resolved || entry.showWhenResolved") - textarea.rp-comment-input( - ng-model="entry.replyContent" - ng-keypress="handleCommentReplyKeyPress($event);" - stop-propagation="click" - placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}" - ) - .rp-comment-resolved-description(ng-if="entry.resolved && !entry.showWhenResolved") - div - | Comment resolved by - span(style="color: hsl({{ users[entry.resolved_data.user_id].hue }}, 70%, 40%);") {{ users[entry.resolved_data.user_id].name }} - div {{ entry.resolved_data.ts | date : 'MMM d, y h:mm a' }} - .rp-entry-actions - a.rp-entry-button(href, ng-click="onResolve();", ng-if="!entry.resolved") - i.fa.fa-check - |  Mark as resolved - a.rp-entry-button(href, ng-click="onShowThread();", ng-if="entry.resolved && !entry.showWhenResolved") - |  Show - a.rp-entry-button(href, ng-click="onHideThread();", ng-if="entry.resolved && entry.showWhenResolved") - |  Hide - a.rp-entry-button(href, ng-click="onUnresolve();", ng-if="entry.resolved") - |  Re-open - a.rp-entry-button(href, ng-click="onDelete();", ng-if="entry.resolved") - |  Delete - - -script(type='text/ng-template', id='addCommentEntryTemplate') - div - .rp-entry-callout.rp-entry-callout-add-comment - .rp-entry-indicator( - ng-if="!commentState.adding" - ng-click="startNewComment(); onIndicatorClick();" - tooltip="Add a comment" - tooltip-placement="right" - tooltip-append-to-body="true" - ) - i.fa.fa-commenting - .rp-entry.rp-entry-add-comment( - ng-class="[ (state.isAdding ? 'rp-entry-adding-comment' : ''), (entry.focused ? 'rp-entry-focused' : '')]" - ) - a.rp-add-comment-btn( - href - ng-if="!state.isAdding" - ng-click="startNewComment();" - ) - i.fa.fa-comment - |  Add comment - div(ng-if="state.isAdding") - .rp-new-comment - textarea.rp-comment-input( - ng-model="state.content" - ng-keypress="handleCommentKeyPress($event);" - placeholder="Add your comment here" - ) - .rp-entry-actions - a.rp-entry-button(href, ng-click="cancelNewComment();") - i.fa.fa-times - |  Cancel - a.rp-entry-button(href, ng-click="submitNewComment()") - i.fa.fa-comment - |  Comment \ No newline at end of file diff --git a/services/web/app/views/project/editor/review-panel.pug b/services/web/app/views/project/editor/review-panel.pug new file mode 100644 index 0000000000..25ac9afd79 --- /dev/null +++ b/services/web/app/views/project/editor/review-panel.pug @@ -0,0 +1,426 @@ +#review-panel + a.rp-track-changes-indicator( + href + ng-if="editor.wantTrackChanges" + ng-click="toggleReviewPanel();" + ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }" + ) Track changes is + strong on + + .review-panel-toolbar + resolved-comments-dropdown( + class="rp-flex-block" + entries="reviewPanel.resolvedComments" + threads="reviewPanel.commentThreads" + resolved-ids="reviewPanel.resolvedThreadIds" + docs="docs" + on-open="refreshResolvedCommentsDropdown();" + on-unresolve="unresolveComment(threadId);" + on-delete="deleteThread(entryId, docId, threadId);" + is-loading="reviewPanel.dropdown.loading" + permissions="permissions" + ) + span.review-panel-toolbar-label(ng-if="permissions.write") + span(ng-click="toggleTrackChanges(true)", ng-if="editor.wantTrackChanges === false") Track Changes is + strong off + span(ng-click="toggleTrackChanges(false)", ng-if="editor.wantTrackChanges === true") Track Changes is + strong on + review-panel-toggle( + ng-if="editor.wantTrackChanges == editor.trackChanges" + ng-model="editor.wantTrackChanges" + on-toggle="toggleTrackChanges" + disabled="!project.features.trackChanges" + on-disabled-click="openTrackChangesUpgradeModal" + ) + span.review-panel-toolbar-label.review-panel-toolbar-label-disabled(ng-if="!permissions.write") + span(ng-if="editor.wantTrackChanges === false") Track Changes is + strong off + span(ng-if="editor.wantTrackChanges === true") Track Changes is + strong on + span.review-panel-toolbar-spinner(ng-if="editor.wantTrackChanges != editor.trackChanges") + i.fa.fa-spin.fa-spinner + + .rp-entry-list( + review-panel-sorted + ng-if="reviewPanel.subView === SubViews.CUR_FILE" + ) + .rp-entry-list-inner + .rp-entry-wrapper( + ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]" + ) + div(ng-if="entry.type === 'insert' || entry.type === 'delete'") + change-entry( + entry="entry" + user="users[entry.metadata.user_id]" + on-reject="rejectChange(entry_id);" + on-accept="acceptChange(entry_id);" + on-indicator-click="toggleReviewPanel();" + on-body-click="gotoEntry(editor.open_doc_id, entry)" + permissions="permissions" + ) + + div(ng-if="entry.type === 'comment'") + comment-entry( + entry="entry" + threads="reviewPanel.commentThreads" + on-resolve="resolveComment(entry, entry_id)" + on-reply="submitReply(entry, entry_id);" + on-indicator-click="toggleReviewPanel();" + on-save-edit="saveEdit(entry.thread_id, comment)" + on-delete="deleteComment(entry.thread_id, comment)" + on-body-click="gotoEntry(editor.open_doc_id, entry)" + permissions="permissions" + ng-if="!reviewPanel.loadingThreads" + ) + + div(ng-if="entry.type === 'add-comment' && permissions.comment") + add-comment-entry( + on-start-new="startNewComment();" + on-submit="submitNewComment(content);" + on-cancel="cancelNewComment();" + on-indicator-click="toggleReviewPanel();" + ) + + .rp-entry-list( + ng-if="reviewPanel.subView === SubViews.OVERVIEW" + ) + .rp-loading(ng-if="reviewPanel.overview.loading") + i.fa.fa-spinner.fa-spin + .rp-overview-file( + ng-repeat="doc in docs" + ng-if="!reviewPanel.overview.loading" + ) + .rp-overview-file-header( + ng-if="reviewPanel.entries[doc.doc.id] | notEmpty" + ) + | {{ doc.path }} + .rp-entry-wrapper( + ng-repeat="(entry_id, entry) in reviewPanel.entries[doc.doc.id] | orderOverviewEntries" + ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)" + ) + div(ng-if="entry.type === 'insert' || entry.type === 'delete'") + change-entry( + entry="entry" + user="users[entry.metadata.user_id]" + on-indicator-click="toggleReviewPanel();" + ng-click="gotoEntry(doc.doc.id, entry)" + permissions="permissions" + ) + + div(ng-if="entry.type === 'comment'") + comment-entry( + entry="entry" + threads="reviewPanel.commentThreads" + on-reply="submitReply(entry, entry_id);" + on-save-edit="saveEdit(entry.thread_id, comment)" + on-delete="deleteComment(entry.thread_id, comment)" + on-indicator-click="toggleReviewPanel();" + ng-click="gotoEntry(doc.doc.id, entry)" + permissions="permissions" + ) + + .rp-nav + a.rp-nav-item( + href + ng-click="setSubView(SubViews.CUR_FILE);" + ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.CUR_FILE }" + ) + i.fa.fa-file-text-o + span.rp-nav-label Current file + a.rp-nav-item( + href + ng-click="setSubView(SubViews.OVERVIEW);" + ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.OVERVIEW }" + ) + i.fa.fa-list + span.rp-nav-label Overview + + +script(type='text/ng-template', id='changeEntryTemplate') + div + .rp-entry-callout( + ng-class="'rp-entry-callout-' + entry.type" + ) + .rp-entry-indicator( + ng-switch="entry.type" + ng-class="{ 'rp-entry-indicator-focused': entry.focused }" + ng-click="onIndicatorClick();" + ) + i.fa.fa-pencil(ng-switch-when="insert") + i.rp-icon-delete(ng-switch-when="delete") + .rp-entry( + ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]" + ) + .rp-entry-body + .rp-entry-action-icon(ng-switch="entry.type") + i.fa.fa-pencil(ng-switch-when="insert") + i.rp-icon-delete(ng-switch-when="delete") + .rp-entry-details + .rp-entry-description(ng-switch="entry.type") + span(ng-switch-when="insert") Added  + ins.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }} + a.rp-collapse-toggle( + href + ng-if="needsCollapsing" + ng-click="toggleCollapse();" + ) {{ isCollapsed ? '... (show all)' : ' (show less)' }} + span(ng-switch-when="delete") Deleted  + del.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }} + a.rp-collapse-toggle( + href + ng-if="needsCollapsing" + ng-click="toggleCollapse();" + ) {{ isCollapsed ? '... (show all)' : ' (show less)' }} + .rp-entry-metadata + | {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }} •  + span.rp-entry-user(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} + .rp-entry-actions(ng-if="permissions.write") + a.rp-entry-button(href, ng-click="onReject();") + i.fa.fa-times + |  Reject + a.rp-entry-button(href, ng-click="onAccept();") + i.fa.fa-check + |  Accept + +script(type='text/ng-template', id='commentEntryTemplate') + .rp-comment-wrapper( + ng-class="{ 'rp-comment-wrapper-resolving': state.animating }" + ) + .rp-entry-callout.rp-entry-callout-comment + .rp-entry-indicator( + ng-class="{ 'rp-entry-indicator-focused': entry.focused }" + ng-click="onIndicatorClick();" + ) + i.fa.fa-comment + .rp-entry.rp-entry-comment( + ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolving': state.animating }" + ) + + .rp-loading(ng-if="!threads[entry.thread_id].submitting && (!threads[entry.thread_id] || threads[entry.thread_id].messages.length == 0)") + | No comments + .rp-comment-loaded + .rp-comment( + ng-repeat="comment in threads[entry.thread_id].messages track by comment.id" + ) + p.rp-comment-content + span(ng-if="!comment.editing") + span.rp-entry-user( + style="color: hsl({{ comment.user.hue }}, 70%, 40%);", + ) {{ comment.user.name }}:  + span(ng-bind-html="comment.content | linky:'_blank'") + textarea.rp-comment-input( + expandable-text-area + ng-if="comment.editing" + ng-model="comment.content" + ng-keypress="saveEditOnEnter($event, comment);" + ng-blur="saveEdit(comment)" + autofocus + stop-propagation="click" + ) + .rp-entry-metadata(ng-if="!comment.editing") + span(ng-if="!comment.deleting") {{ comment.timestamp | date : 'MMM d, y h:mm a' }} + span.rp-comment-actions(ng-if="comment.user.isSelf && !comment.deleting") + |  •  + a(href, ng-click="startEditing(comment)") Edit + span(ng-if="threads[entry.thread_id].messages.length > 1") + |  •  + a(href, ng-click="confirmDelete(comment)") Delete + span.rp-confim-delete(ng-if="comment.user.isSelf && comment.deleting") + | Are you sure? + | •  + a(href, ng-click="doDelete(comment)") Delete + |  •  + a(href, ng-click="cancelDelete(comment)") Cancel + + .rp-loading(ng-if="threads[entry.thread_id].submitting") + i.fa.fa-spinner.fa-spin + .rp-comment-reply(ng-if="permissions.comment") + textarea.rp-comment-input( + expandable-text-area + ng-model="entry.replyContent" + ng-keypress="handleCommentReplyKeyPress($event);" + stop-propagation="click" + placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}" + ) + .rp-entry-actions + button.rp-entry-button( + ng-click="animateAndCallOnResolve();" + ng-if="permissions.comment && permissions.write" + ) + i.fa.fa-inbox + |  Resolve + button.rp-entry-button( + ng-click="onReply();" + ng-if="permissions.comment" + ng-disabled="!entry.replyContent.length" + ) + i.fa.fa-reply + |  Reply + +script(type='text/ng-template', id='resolvedCommentEntryTemplate') + .rp-resolved-comment + div + .rp-resolved-comment-context + | Quoted text on  + span.rp-resolved-comment-context-file {{ thread.docName }} + p.rp-resolved-comment-context-quote + span {{ thread.content | limitTo:(isCollapsed ? contentLimit : thread.content.length)}} + a.rp-collapse-toggle( + href + ng-if="needsCollapsing" + ng-click="toggleCollapse();" + )  {{ isCollapsed ? '... (show all)' : ' (show less)' }} + .rp-comment( + ng-repeat="comment in thread.messages track by comment.id" + ) + p.rp-comment-content + span.rp-entry-user( + style="color: hsl({{ comment.user.hue }}, 70%, 40%);" + ng-if="$first || comment.user.id !== thread.messages[$index - 1].user.id" + ) {{ comment.user.name }}:  + span(ng-bind-html="comment.content | linky:'_blank'") + .rp-entry-metadata + | {{ comment.timestamp | date : 'MMM d, y h:mm a' }} + .rp-comment.rp-comment-resolver + p.rp-comment-resolver-content + span.rp-entry-user( + style="color: hsl({{ thread.resolved_by_user.hue }}, 70%, 40%);" + ) {{ thread.resolved_by_user.name }}:  + | Marked as resolved. + .rp-entry-metadata + | {{ thread.resolved_at | date : 'MMM d, y h:mm a' }} + + .rp-entry-actions(ng-if="permissions.comment && permissions.write") + a.rp-entry-button( + href + ng-click="onUnresolve({ 'threadId': thread.threadId });" + ) + |  Re-open + a.rp-entry-button( + href + ng-click="onDelete({ 'entryId': thread.entryId, 'docId': thread.docId, 'threadId': thread.threadId });" + ) + |  Delete + + +script(type='text/ng-template', id='addCommentEntryTemplate') + div + .rp-entry-callout.rp-entry-callout-add-comment + .rp-entry-indicator( + ng-if="!commentState.adding" + ng-click="startNewComment(); onIndicatorClick();" + tooltip="Add a comment" + tooltip-placement="right" + tooltip-append-to-body="true" + ) + i.fa.fa-commenting + .rp-entry.rp-entry-add-comment( + ng-class="[ (state.isAdding ? 'rp-entry-adding-comment' : ''), (entry.focused ? 'rp-entry-focused' : '')]" + ) + a.rp-add-comment-btn( + href + ng-if="!state.isAdding" + ng-click="startNewComment();" + ) + i.fa.fa-comment + |  Add comment + div(ng-if="state.isAdding") + .rp-new-comment + textarea.rp-comment-input( + expandable-text-area + ng-model="state.content" + ng-keypress="handleCommentKeyPress($event);" + placeholder="Add your comment here" + focus-on="comment:new:open" + ) + .rp-entry-actions + button.rp-entry-button( + ng-click="cancelNewComment();" + ) + i.fa.fa-times + |  Cancel + button.rp-entry-button( + ng-click="submitNewComment()" + ng-disabled="!state.content.length" + ) + i.fa.fa-comment + |  Comment + +script(type='text/ng-template', id='resolvedCommentsDropdownTemplate') + .resolved-comments + .resolved-comments-backdrop( + ng-class="{ 'resolved-comments-backdrop-visible' : state.isOpen }" + ng-click="state.isOpen = false" + ) + a.resolved-comments-toggle( + href + ng-click="toggleOpenState();" + tooltip="Resolved Comments" + tooltip-placement="bottom" + tooltip-append-to-body="true" + ) + i.fa.fa-inbox + .resolved-comments-dropdown( + ng-class="{ 'resolved-comments-dropdown-open' : state.isOpen }" + ) + .rp-loading(ng-if="isLoading") + i.fa.fa-spinner.fa-spin + .resolved-comments-scroller( + ng-if="!isLoading" + ) + resolved-comment-entry( + ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true" + thread="thread" + on-unresolve="handleUnresolve(threadId);" + on-delete="handleDelete(entryId, docId, threadId);" + permissions="permissions" + ) + .rp-loading(ng-if="!resolvedComments.length") + | No resolved threads. + +script(type="text/ng-template", id="trackChangesUpgradeModalTemplate") + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="cancel()" + ) × + h3 Upgrade to Track Changes + .modal-body + .teaser-video-container + video.teaser-video(autoplay, loop) + source(src="/img/teasers/track-changes/teaser-track-changes.mp4", type="video/mp4") + img(src="/img/teasers/track-changes/teaser-track-changes.gif") + + h4.teaser-title See changes in your documents, live + + p.small(ng-show="startedFreeTrial") + | #{translate("refresh_page_after_starting_free_trial")} + + .row + .col-md-10.col-md-offset-1 + ul.list-unstyled + li + i.fa.fa-check   + | Track any change, in real-time + + li + i.fa.fa-check   + | Review your peers' work + + li + i.fa.fa-check   + | Accept or reject each change individually + + + .row.text-center(ng-controller="FreeTrialModalController") + a.btn.btn-success( + href + ng-click="startFreeTrial('track-changes')" + ) Try it for free + + .modal-footer() + button.btn.btn-default( + ng-click="cancel()" + ) + span #{translate("close")} \ No newline at end of file diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.pug similarity index 95% rename from services/web/app/views/project/editor/share.jade rename to services/web/app/views/project/editor/share.pug index 62de414064..32e0404afc 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.pug @@ -38,7 +38,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') .col-xs-1 a( href - tooltip="#{translate('remove_collaborator')}" + tooltip=translate('remove_collaborator') tooltip-placement="bottom" ng-click="removeMember(member)" ) @@ -55,7 +55,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') .col-xs-1 a( href - tooltip="#{translate('revoke_invite')}" + tooltip=translate('revoke_invite') tooltip-placement="bottom" ng-click="revokeInvite(invite)" ) @@ -66,7 +66,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') .form-group tags-input( template="shareTagTemplate" - placeholder="#{settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'}" + placeholder=settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...' ng-model="inputs.contacts" focus-on="open" display-property="display" @@ -144,6 +144,8 @@ script(type='text/ng-template', id='shareProjectModalTemplate') span(ng-switch="state.errorReason") span(ng-switch-when="cannot_invite_non_user") | #{translate("cannot_invite_non_user")} + span(ng-switch-when="cannot_invite_self") + | #{translate("cannot_invite_self")} span(ng-switch-default) | #{translate("generic_something_went_wrong")} button.btn.btn-default( diff --git a/services/web/app/views/project/invite/not-valid.jade b/services/web/app/views/project/invite/not-valid.pug similarity index 100% rename from services/web/app/views/project/invite/not-valid.jade rename to services/web/app/views/project/invite/not-valid.pug diff --git a/services/web/app/views/project/invite/show.jade b/services/web/app/views/project/invite/show.pug similarity index 83% rename from services/web/app/views/project/invite/show.jade rename to services/web/app/views/project/invite/show.pug index eed30d3d19..d129fe015d 100644 --- a/services/web/app/views/project/invite/show.jade +++ b/services/web/app/views/project/invite/show.pug @@ -20,12 +20,12 @@ block content form.form( name="acceptForm", method="POST", - action="/project/#{invite.projectId}/invite/token/#{invite.token}/accept" + action="/project/"+invite.projectId+"/invite/token/"+invite.token+"/accept" ) input(name='_csrf', type='hidden', value=csrfToken) - input(name='token', type='hidden', value="#{invite.token}") + input(name='token', type='hidden', value=invite.token) .form-group.text-center button.btn.btn-lg.btn-primary(type="submit") | #{translate("join_project")} .form-group.text-center - \ No newline at end of file + diff --git a/services/web/app/views/project/list.jade b/services/web/app/views/project/list.pug similarity index 98% rename from services/web/app/views/project/list.jade rename to services/web/app/views/project/list.pug index d9fbf0e6b1..f707cd9411 100644 --- a/services/web/app/views/project/list.jade +++ b/services/web/app/views/project/list.pug @@ -73,4 +73,4 @@ block content .col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8 include ./list/empty-project-list - include ./list/modals \ No newline at end of file + include ./list/modals diff --git a/services/web/app/views/project/list/empty-project-list.jade b/services/web/app/views/project/list/empty-project-list.pug similarity index 100% rename from services/web/app/views/project/list/empty-project-list.jade rename to services/web/app/views/project/list/empty-project-list.pug diff --git a/services/web/app/views/project/list/modals.jade b/services/web/app/views/project/list/modals.pug similarity index 100% rename from services/web/app/views/project/list/modals.jade rename to services/web/app/views/project/list/modals.pug diff --git a/services/web/app/views/project/list/notifications.jade b/services/web/app/views/project/list/notifications.pug similarity index 100% rename from services/web/app/views/project/list/notifications.jade rename to services/web/app/views/project/list/notifications.pug diff --git a/services/web/app/views/project/list/project-list.jade b/services/web/app/views/project/list/project-list.pug similarity index 96% rename from services/web/app/views/project/list/project-list.jade rename to services/web/app/views/project/list/project-list.pug index 01007213d0..0bb93e8336 100644 --- a/services/web/app/views/project/list/project-list.jade +++ b/services/web/app/views/project/list/project-list.pug @@ -6,7 +6,7 @@ form.project-search.form-horizontal(role="form") .form-group.has-feedback.has-feedback-left.col-md-7.col-xs-12 input.form-control.col-md-7.col-xs-12( - placeholder="#{translate('search_projects')}…", + placeholder=translate('search_projects')+"…", autofocus='autofocus', ng-model="searchText.value", focus-on='search:clear', @@ -25,7 +25,7 @@ .btn-group(ng-hide="selectedProjects.length < 1") a.btn.btn-default( href, - tooltip="#{translate('download')}", + tooltip=translate('download'), tooltip-placement="bottom", tooltip-append-to-body="true", ng-click="downloadSelectedProjects()" @@ -33,7 +33,7 @@ i.fa.fa-cloud-download a.btn.btn-default( href, - tooltip="#{translate('delete')}", + tooltip=translate('delete'), tooltip-placement="bottom", tooltip-append-to-body="true", ng-click="openArchiveProjectsModal()" @@ -45,7 +45,7 @@ href, data-toggle="dropdown", dropdown-toggle, - tooltip="#{translate('add_to_folders')}", + tooltip=translate('add_to_folders'), tooltip-append-to-body="true", tooltip-placement="bottom" ) diff --git a/services/web/app/views/project/list/side-bar.jade b/services/web/app/views/project/list/side-bar.pug similarity index 100% rename from services/web/app/views/project/list/side-bar.jade rename to services/web/app/views/project/list/side-bar.pug diff --git a/services/web/app/views/referal/bonus.jade b/services/web/app/views/referal/bonus.pug similarity index 87% rename from services/web/app/views/referal/bonus.jade rename to services/web/app/views/referal/bonus.pug index 5b0183d9c1..f3a65c6bc4 100644 --- a/services/web/app/views/referal/bonus.jade +++ b/services/web/app/views/referal/bonus.pug @@ -24,7 +24,7 @@ block content .row .col-md-8.col-md-offset-2.bonus-banner .title - a(href='https://twitter.com/share?text=is%20trying%20out%20the%20online%20LaTeX%20Editor%20ShareLaTeX&url=#{encodeURIComponent(buildReferalUrl("t"))}&counturl=https://www.sharelatex.com', target="_blank").twitter Tweet + a(href='https://twitter.com/share?text=is%20trying%20out%20the%20online%20LaTeX%20Editor%20ShareLaTeX&url='+encodeURIComponent(buildReferalUrl("t"))+'&counturl=https://www.sharelatex.com', target="_blank").twitter Tweet .row .col-md-8.col-md-offset-2.bonus-banner @@ -34,12 +34,12 @@ block content .row .col-md-8.col-md-offset-2.bonus-banner .title - a(href="https://plus.google.com/share?url=#{encodeURIComponent(buildReferalUrl('gp'))}", onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;").google-plus #{translate("share_us_on_googleplus")} + a(href="https://plus.google.com/share?url="+encodeURIComponent(buildReferalUrl('gp')), onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;").google-plus #{translate("share_us_on_googleplus")} .row .col-md-8.col-md-offset-2.bonus-banner .title - a(href='mailto:?subject=Online LaTeX editor you may like &body=Hey, I have been using the online LaTeX editor ShareLaTeX recently and thought you might like to check it out. #{encodeURIComponent(buildReferalUrl("e"))}', title='Share by Email').email #{translate("email_us_to_your_friends")} + a(href='mailto:?subject=Online LaTeX editor you may like &body=Hey, I have been using the online LaTeX editor ShareLaTeX recently and thought you might like to check it out. '+encodeURIComponent(buildReferalUrl("e")), title='Share by Email').email #{translate("email_us_to_your_friends")} .row .col-md-8.col-md-offset-2.bonus-banner @@ -58,9 +58,9 @@ block content .col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 30px; margin-top: 20px;") - for (var i = 0; i <= 10; i++) { - if (refered_user_count == i) - .number(style="left: #{i}0%").active #{i} + .number(style="left: "+i+"0%").active #{i} - else - .number(style="left: #{i}0%") #{i} + .number(style="left: "+i+"0%") #{i} - } .row.ab-bonus @@ -68,7 +68,7 @@ block content .progress - if (refered_user_count == 0) div(style="text-align: center; padding: 4px;") #{translate("spread_the_word_and_fill_bar")} - .progress-bar.progress-bar-info(style="width: #{refered_user_count}0%") + .progress-bar.progress-bar-info(style="width: "+refered_user_count+"0%") .row.ab-bonus .col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 70px;") diff --git a/services/web/app/views/restore.jade b/services/web/app/views/restore.pug similarity index 84% rename from services/web/app/views/restore.jade rename to services/web/app/views/restore.pug index 51c0ee03fc..1cd7ad458a 100644 --- a/services/web/app/views/restore.jade +++ b/services/web/app/views/restore.pug @@ -19,11 +19,11 @@ block content .row-fluid table.table - -each project in projects + each project in projects tr - project_id = project._id.toString() td(width="50%") #{project.name} td(width="25%") - a.btn(href="/project/#{project_id}/zip") Download latest version as Zip + a.btn(href="/project/"+project_id+"/zip") Download latest version as Zip include general/small-footer diff --git a/services/web/app/views/scribtex-modal.jade b/services/web/app/views/scribtex-modal.pug similarity index 71% rename from services/web/app/views/scribtex-modal.jade rename to services/web/app/views/scribtex-modal.pug index 1d4e2ce005..3efbf19314 100644 --- a/services/web/app/views/scribtex-modal.jade +++ b/services/web/app/views/scribtex-modal.pug @@ -5,4 +5,4 @@ script(type='text/ng-template', id='scribtexModalTemplate') p ScribTeX has moved to https://scribtex.sharelatex.com. Please update your bookmarks. p(style="text-align: center") You can find the page you were looking for here: p(style="text-align: center") - a(href="https://scribtex.sharelatex.com#{scribtexPath}", style="font-size: 16px") https://scribtex.sharelatex.com#{scribtexPath} \ No newline at end of file + a(href="https://scribtex.sharelatex.com"+scribtexPath, style="font-size: 16px") https://scribtex.sharelatex.com#{scribtexPath} diff --git a/services/web/app/views/sentry.jade b/services/web/app/views/sentry.pug similarity index 96% rename from services/web/app/views/sentry.jade rename to services/web/app/views/sentry.pug index 0a51686015..9e10d7837e 100644 --- a/services/web/app/views/sentry.jade +++ b/services/web/app/views/sentry.pug @@ -1,8 +1,8 @@ - if (typeof(sentrySrc) != "undefined") - if (sentrySrc.match(/^([a-z]+:)?\/\//i)) - script(src="#{sentrySrc}") + script(src=sentrySrc) - else - script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false})) + script(src=buildJsPath("libs/"+sentrySrc, {fingerprint:false})) - if (typeof(sentrySrc) != "undefined") script(type="text/javascript"). if (typeof(Raven) != "undefined" && Raven.config) { diff --git a/services/web/app/views/subscriptions/custom_account.jade b/services/web/app/views/subscriptions/custom_account.pug similarity index 100% rename from services/web/app/views/subscriptions/custom_account.jade rename to services/web/app/views/subscriptions/custom_account.pug diff --git a/services/web/app/views/subscriptions/dashboard.jade b/services/web/app/views/subscriptions/dashboard.pug similarity index 91% rename from services/web/app/views/subscriptions/dashboard.jade rename to services/web/app/views/subscriptions/dashboard.pug index 3448dc9072..fb4f834c34 100644 --- a/services/web/app/views/subscriptions/dashboard.jade +++ b/services/web/app/views/subscriptions/dashboard.pug @@ -12,7 +12,7 @@ block scripts mixin printPlan(plan) -if (!plan.hideFromUsers) - tr(ng-controller="ChangePlanFormController", ng-init="plan=#{JSON.stringify(plan)}", ng-show="shouldShowPlan(plan.planCode)") + tr(ng-controller="ChangePlanFormController", ng-init="plan="+JSON.stringify(plan), ng-show="shouldShowPlan(plan.planCode)") td strong #{plan.name} td {{refreshPrice(plan.planCode)}} @@ -22,18 +22,18 @@ mixin printPlan(plan) | {{prices[plan.planCode]}} / #{translate("month")} td -if (subscription.state == "free-trial") - a(href="/user/subscription/new?planCode=#{plan.planCode}").btn.btn-success #{translate("subscribe_to_this_plan")} + a(href="/user/subscription/new?planCode="+plan.planCode).btn.btn-success #{translate("subscribe_to_this_plan")} -else if (typeof(subscription.planCode) != "undefined" && plan.planCode == subscription.planCode.split("_")[0]) button.btn.disabled #{translate("your_plan")} -else form - input(type="hidden", ng-model="plan_code", name="plan_code", value="#{plan.planCode}") + input(type="hidden", ng-model="plan_code", name="plan_code", value=plan.planCode) input(type="submit", ng-click="changePlan()", value=translate("change_to_this_plan")).btn.btn-success mixin printPlans(plans) - -each plan in plans - mixin printPlan(plan) + each plan in plans + +printPlan(plan) block content .content.content-alt(ng-cloak) @@ -46,7 +46,7 @@ block content |   | #{translate("your_billing_details_were_saved")} .card(ng-if="view == 'overview'") - .page-header(x-current-plan="#{subscription.planCode}") + .page-header(x-current-plan=subscription.planCode) h1 #{translate("your_subscription")} - if (subscription && user._id+'' == subscription.admin_id+'') @@ -97,9 +97,9 @@ block content th !{translate("name")} th !{translate("price")} th - mixin printPlans(plans.studentAccounts) - mixin printPlans(plans.individualMonthlyPlans) - mixin printPlans(plans.individualAnnualPlans) + +printPlans(plans.studentAccounts) + +printPlans(plans.individualMonthlyPlans) + +printPlans(plans.individualAnnualPlans) each groupSubscription in groupSubscriptions @@ -107,7 +107,7 @@ block content div p !{translate("member_of_group_subscription", {admin_email: "" + groupSubscription.admin_id.email + ""})} span - button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")} + button.btn.btn-danger(ng-click="removeSelfFromGroup('"+groupSubscription.admin_id._id+"')") #{translate("leave_group")} -if(subscription.groupPlan && user._id+'' == subscription.admin_id+'') div diff --git a/services/web/app/views/subscriptions/edit-billing-details.jade b/services/web/app/views/subscriptions/edit-billing-details.pug similarity index 100% rename from services/web/app/views/subscriptions/edit-billing-details.jade rename to services/web/app/views/subscriptions/edit-billing-details.pug diff --git a/services/web/app/views/subscriptions/group/invite.jade b/services/web/app/views/subscriptions/group/invite.pug similarity index 100% rename from services/web/app/views/subscriptions/group/invite.jade rename to services/web/app/views/subscriptions/group/invite.pug diff --git a/services/web/app/views/subscriptions/group/successful_join.jade b/services/web/app/views/subscriptions/group/successful_join.pug similarity index 100% rename from services/web/app/views/subscriptions/group/successful_join.jade rename to services/web/app/views/subscriptions/group/successful_join.pug diff --git a/services/web/app/views/subscriptions/group_admin.jade b/services/web/app/views/subscriptions/group_admin.pug similarity index 100% rename from services/web/app/views/subscriptions/group_admin.jade rename to services/web/app/views/subscriptions/group_admin.pug diff --git a/services/web/app/views/subscriptions/new.jade b/services/web/app/views/subscriptions/new.pug similarity index 99% rename from services/web/app/views/subscriptions/new.jade rename to services/web/app/views/subscriptions/new.pug index 1465b24c82..2d410f8fba 100644 --- a/services/web/app/views/subscriptions/new.jade +++ b/services/web/app/views/subscriptions/new.pug @@ -164,7 +164,7 @@ block content ng-change="updateCountry()" required ) - mixin countries_options() + +countries_options() span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }} if (showVatField) diff --git a/services/web/app/views/subscriptions/plans.jade b/services/web/app/views/subscriptions/plans.pug similarity index 100% rename from services/web/app/views/subscriptions/plans.jade rename to services/web/app/views/subscriptions/plans.pug diff --git a/services/web/app/views/subscriptions/successful_subscription.jade b/services/web/app/views/subscriptions/successful_subscription.pug similarity index 100% rename from services/web/app/views/subscriptions/successful_subscription.jade rename to services/web/app/views/subscriptions/successful_subscription.pug diff --git a/services/web/app/views/subscriptions/upgradeToAnnual.jade b/services/web/app/views/subscriptions/upgradeToAnnual.pug similarity index 93% rename from services/web/app/views/subscriptions/upgradeToAnnual.jade rename to services/web/app/views/subscriptions/upgradeToAnnual.pug index d92290ad0e..0ce41856f7 100644 --- a/services/web/app/views/subscriptions/upgradeToAnnual.jade +++ b/services/web/app/views/subscriptions/upgradeToAnnual.pug @@ -6,7 +6,7 @@ block content .container(ng-controller="AnnualUpgradeController") .row(ng-cloak) .col-md-6.col-md-offset-3 - .card(ng-init="planName = #{JSON.stringify(planName)}") + .card(ng-init="planName = "+JSON.stringify(planName)) .page-header h1.text-centered #{translate("move_to_annual_billing")} div(ng-hide="upgradeComplete") diff --git a/services/web/app/views/tests.jade b/services/web/app/views/tests.pug similarity index 100% rename from services/web/app/views/tests.jade rename to services/web/app/views/tests.pug diff --git a/services/web/app/views/translations/translation_message.jade b/services/web/app/views/translations/translation_message.pug similarity index 85% rename from services/web/app/views/translations/translation_message.jade rename to services/web/app/views/translations/translation_message.pug index 225ad3ea2c..635a8c9265 100644 --- a/services/web/app/views/translations/translation_message.jade +++ b/services/web/app/views/translations/translation_message.pug @@ -2,7 +2,7 @@ span(ng-controller="TranslationsPopupController", ng-cloak) .translations-message(ng-hide="hidei18nNotification") a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"" + translate(recomendSubdomain.lngCode) + ""})} - img(src=buildImgPath("flags/24/#{recomendSubdomain.lngCode}.png")) + img(src=buildImgPath("flags/24/" + recomendSubdomain.lngCode + ".png")) button(ng-click="dismiss()").close.pull-right span(aria-hidden="true") × span.sr-only #{translate("close")} \ No newline at end of file diff --git a/services/web/app/views/university/case_study.jade b/services/web/app/views/university/case_study.pug similarity index 100% rename from services/web/app/views/university/case_study.jade rename to services/web/app/views/university/case_study.pug diff --git a/services/web/app/views/university/university_holder.jade b/services/web/app/views/university/university_holder.pug similarity index 100% rename from services/web/app/views/university/university_holder.jade rename to services/web/app/views/university/university_holder.pug diff --git a/services/web/app/views/user/activate.jade b/services/web/app/views/user/activate.pug similarity index 97% rename from services/web/app/views/user/activate.jade rename to services/web/app/views/user/activate.pug index 7961876389..8b60b10471 100644 --- a/services/web/app/views/user/activate.jade +++ b/services/web/app/views/user/activate.pug @@ -36,7 +36,7 @@ block content placeholder="email@example.com" required, ng-model="email", - ng-init="email = #{JSON.stringify(email)}", + ng-init="email = "+JSON.stringify(email), ng-model-options="{ updateOn: 'blur' }", disabled ) diff --git a/services/web/app/views/user/login.jade b/services/web/app/views/user/login.pug similarity index 96% rename from services/web/app/views/user/login.jade rename to services/web/app/views/user/login.pug index 8339f27189..823299d660 100644 --- a/services/web/app/views/user/login.jade +++ b/services/web/app/views/user/login.pug @@ -19,7 +19,7 @@ block content placeholder='email@example.com', ng-model="email", ng-model-options="{ updateOn: 'blur' }", - ng-init="email = #{JSON.stringify(email)}", + ng-init="email = "+JSON.stringify(email), focus="true" ) span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty") diff --git a/services/web/app/views/user/passwordReset.jade b/services/web/app/views/user/passwordReset.pug similarity index 100% rename from services/web/app/views/user/passwordReset.jade rename to services/web/app/views/user/passwordReset.pug diff --git a/services/web/app/views/user/register.jade b/services/web/app/views/user/register.pug similarity index 100% rename from services/web/app/views/user/register.jade rename to services/web/app/views/user/register.pug diff --git a/services/web/app/views/user/restricted.jade b/services/web/app/views/user/restricted.pug similarity index 100% rename from services/web/app/views/user/restricted.jade rename to services/web/app/views/user/restricted.pug diff --git a/services/web/app/views/user/sessions.jade b/services/web/app/views/user/sessions.pug similarity index 100% rename from services/web/app/views/user/sessions.jade rename to services/web/app/views/user/sessions.pug diff --git a/services/web/app/views/user/setPassword.jade b/services/web/app/views/user/setPassword.pug similarity index 100% rename from services/web/app/views/user/setPassword.jade rename to services/web/app/views/user/setPassword.pug diff --git a/services/web/app/views/user/settings.jade b/services/web/app/views/user/settings.pug similarity index 99% rename from services/web/app/views/user/settings.jade rename to services/web/app/views/user/settings.pug index 310912cf07..a4217d8ca4 100644 --- a/services/web/app/views/user/settings.jade +++ b/services/web/app/views/user/settings.pug @@ -28,7 +28,7 @@ block content placeholder="email@example.com" required, ng-model="email", - ng-init="email = #{JSON.stringify(user.email)}", + ng-init="email = "+JSON.stringify(user.email), ng-model-options="{ updateOn: 'blur' }" ) span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") diff --git a/services/web/app/views/view_templates/bonus_templates.jade b/services/web/app/views/view_templates/bonus_templates.pug similarity index 100% rename from services/web/app/views/view_templates/bonus_templates.jade rename to services/web/app/views/view_templates/bonus_templates.pug diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index ccfec59235..b24c2568ab 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -48,6 +48,16 @@ module.exports = settings = # {host: 'localhost', port: 7005} # ] + # ratelimiter: + # cluster: [ + # {host: 'localhost', port: 7000} + # {host: 'localhost', port: 7001} + # {host: 'localhost', port: 7002} + # {host: 'localhost', port: 7003} + # {host: 'localhost', port: 7004} + # {host: 'localhost', port: 7005} + # ] + api: host: "localhost" port: "6379" @@ -169,6 +179,7 @@ module.exports = settings = compileGroup: "standard" references: true templates: true + trackChanges: true plans: plans = [{ planCode: "personal" @@ -335,35 +346,11 @@ module.exports = settings = url: "https://github.com/sharelatex/sharelatex" }] - header: [{ - text: "Register" - url: "/register" - only_when_logged_out: true - }, { - text: "Log In" - url: "/login" - only_when_logged_out: true - }, { - text: "Projects" - url: "/project" - only_when_logged_in: true - }, { - text: "Account" - only_when_logged_in: true - dropdown: [{ - user_email: true - },{ - divider: true - }, { - text: "Account Settings" - url: "/user/settings" - }, { - divider: true - }, { - text: "Log out" - url: "/logout" - }] - }] + showSubscriptionLink: false + + header_extras: [] + # Example: + # header_extras: [{text: "Some Page", url: "http://example.com/some/page", class: "subdued"}] customisation: {} diff --git a/services/web/modules/.gitignore b/services/web/modules/.gitignore index b90beee9f7..1d30263cb3 100644 --- a/services/web/modules/.gitignore +++ b/services/web/modules/.gitignore @@ -2,3 +2,11 @@ */test/unit/js */index.js ldap +admin-panel +groovehq +launchpad +learn-wiki +references-search +sharelatex-saml +templates +tpr-webmodule diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json index 196eec0a44..0dd0cab400 100644 --- a/services/web/npm-shrinkwrap.json +++ b/services/web/npm-shrinkwrap.json @@ -1,3209 +1,4 @@ { "name": "web-sharelatex", - "version": "0.1.4", - "dependencies": { - "abbrev": { - "version": "1.0.9", - "from": "abbrev@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz" - }, - "accepts": { - "version": "1.2.13", - "from": "accepts@>=1.2.9 <1.3.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.2.13.tgz" - }, - "addressparser": { - "version": "0.2.1", - "from": "addressparser@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.2.1.tgz" - }, - "amdefine": { - "version": "1.0.1", - "from": "amdefine@>=0.0.4", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" - }, - "ansi-regex": { - "version": "2.0.0", - "from": "ansi-regex@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" - }, - "ansi-styles": { - "version": "2.2.1", - "from": "ansi-styles@>=2.2.1 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" - }, - "aproba": { - "version": "1.0.4", - "from": "aproba@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.0.4.tgz" - }, - "archiver": { - "version": "0.9.0", - "from": "archiver@0.9.0", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-0.9.0.tgz", - "dependencies": { - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - } - } - }, - "are-we-there-yet": { - "version": "1.1.2", - "from": "are-we-there-yet@>=1.1.2 <1.2.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "readable-stream": { - "version": "2.2.2", - "from": "readable-stream@>=2.0.0 <3.0.0||>=1.1.13 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.2.tgz" - } - } - }, - "argparse": { - "version": "0.1.16", - "from": "argparse@>=0.1.11 <0.2.0", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz", - "dependencies": { - "underscore": { - "version": "1.7.0", - "from": "underscore@>=1.7.0 <1.8.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz" - }, - "underscore.string": { - "version": "2.4.0", - "from": "underscore.string@>=2.4.0 <2.5.0", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz" - } - } - }, - "array-flatten": { - "version": "1.1.0", - "from": "array-flatten@1.1.0", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.0.tgz" - }, - "asn1": { - "version": "0.2.3", - "from": "asn1@>=0.2.3 <0.3.0", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz" - }, - "assert-plus": { - "version": "0.2.0", - "from": "assert-plus@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" - }, - "assertion-error": { - "version": "1.0.2", - "from": "assertion-error@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz" - }, - "async": { - "version": "0.6.2", - "from": "async@0.6.2", - "resolved": "https://registry.npmjs.org/async/-/async-0.6.2.tgz" - }, - "asynckit": { - "version": "0.4.0", - "from": "asynckit@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" - }, - "aws-sdk": { - "version": "2.7.16", - "from": "aws-sdk@>=2.6.12 <3.0.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.7.16.tgz", - "dependencies": { - "uuid": { - "version": "3.0.0", - "from": "uuid@3.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.0.tgz" - }, - "xml2js": { - "version": "0.4.15", - "from": "xml2js@0.4.15", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.15.tgz" - } - } - }, - "aws-sign2": { - "version": "0.6.0", - "from": "aws-sign2@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz" - }, - "aws4": { - "version": "1.5.0", - "from": "aws4@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.5.0.tgz" - }, - "axo": { - "version": "0.0.2", - "from": "axo@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/axo/-/axo-0.0.2.tgz" - }, - "babel-runtime": { - "version": "6.3.19", - "from": "babel-runtime@>=6.3.19 <6.4.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.3.19.tgz" - }, - "backoff": { - "version": "2.5.0", - "from": "backoff@>=2.5.0 <3.0.0", - "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz" - }, - "balanced-match": { - "version": "0.4.2", - "from": "balanced-match@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz" - }, - "base64-js": { - "version": "1.2.0", - "from": "base64-js@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.0.tgz" - }, - "base64-stream": { - "version": "0.1.3", - "from": "base64-stream@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/base64-stream/-/base64-stream-0.1.3.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "readable-stream": { - "version": "2.2.2", - "from": "readable-stream@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.2.tgz" - } - } - }, - "base64-url": { - "version": "1.3.3", - "from": "base64-url@1.3.3", - "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.3.3.tgz" - }, - "basic-auth-connect": { - "version": "1.0.0", - "from": "basic-auth-connect@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz" - }, - "bcrypt": { - "version": "1.0.1", - "from": "bcrypt@1.0.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-1.0.1.tgz" - }, - "bcrypt-pbkdf": { - "version": "1.0.0", - "from": "bcrypt-pbkdf@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz", - "optional": true - }, - "bcryptjs": { - "version": "2.3.0", - "from": "bcryptjs@2.3.0", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.3.0.tgz" - }, - "bindings": { - "version": "1.2.1", - "from": "bindings@1.2.1", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz" - }, - "bl": { - "version": "0.6.0", - "from": "bl@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-0.6.0.tgz" - }, - "block-stream": { - "version": "0.0.9", - "from": "block-stream@*", - "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz" - }, - "bluebird": { - "version": "3.4.6", - "from": "bluebird@>=3.3.4 <4.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz" - }, - "body-parser": { - "version": "1.15.2", - "from": "body-parser@>=1.13.1 <2.0.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.15.2.tgz", - "dependencies": { - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "iconv-lite": { - "version": "0.4.13", - "from": "iconv-lite@0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "qs": { - "version": "6.2.0", - "from": "qs@6.2.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.0.tgz" - } - } - }, - "boom": { - "version": "2.10.1", - "from": "boom@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz" - }, - "brace-expansion": { - "version": "1.1.6", - "from": "brace-expansion@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.6.tgz" - }, - "bson": { - "version": "1.0.1", - "from": "bson@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.0.1.tgz" - }, - "bson-ext": { - "version": "0.1.13", - "from": "bson-ext@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/bson-ext/-/bson-ext-0.1.13.tgz", - "optional": true, - "dependencies": { - "nan": { - "version": "2.0.9", - "from": "nan@>=2.0.9 <2.1.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.0.9.tgz", - "optional": true - } - } - }, - "buffer": { - "version": "4.9.1", - "from": "buffer@4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - } - } - }, - "buffer-crc32": { - "version": "0.2.13", - "from": "buffer-crc32@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz" - }, - "buffer-shims": { - "version": "1.0.0", - "from": "buffer-shims@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz" - }, - "bufferedstream": { - "version": "1.6.0", - "from": "bufferedstream@1.6.0", - "resolved": "https://registry.npmjs.org/bufferedstream/-/bufferedstream-1.6.0.tgz" - }, - "buildmail": { - "version": "3.3.2", - "from": "buildmail@3.3.2", - "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-3.3.2.tgz", - "dependencies": { - "addressparser": { - "version": "1.0.0", - "from": "addressparser@1.0.0", - "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.0.tgz" - } - } - }, - "bunyan": { - "version": "0.22.1", - "from": "bunyan@0.22.1", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-0.22.1.tgz" - }, - "busboy": { - "version": "0.2.13", - "from": "busboy@>=0.2.9 <0.3.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.13.tgz", - "dependencies": { - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "bytes": { - "version": "2.4.0", - "from": "bytes@2.4.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz" - }, - "camelcase": { - "version": "1.2.1", - "from": "camelcase@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz" - }, - "caseless": { - "version": "0.11.0", - "from": "caseless@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz" - }, - "chai": { - "version": "3.5.0", - "from": "chai@latest", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz" - }, - "chai-spies": { - "version": "0.7.1", - "from": "chai-spies@latest", - "resolved": "https://registry.npmjs.org/chai-spies/-/chai-spies-0.7.1.tgz" - }, - "chalk": { - "version": "1.1.3", - "from": "chalk@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "dependencies": { - "supports-color": { - "version": "2.0.0", - "from": "supports-color@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" - } - } - }, - "character-parser": { - "version": "1.2.0", - "from": "character-parser@1.2.0", - "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-1.2.0.tgz" - }, - "clone": { - "version": "1.0.2", - "from": "clone@1.0.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz" - }, - "cluster-key-slot": { - "version": "1.0.8", - "from": "cluster-key-slot@>=1.0.6 <2.0.0", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.0.8.tgz" - }, - "code-point-at": { - "version": "1.1.0", - "from": "code-point-at@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz" - }, - "coffee-script": { - "version": "1.3.3", - "from": "coffee-script@>=1.3.3 <1.4.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.3.3.tgz" - }, - "colors": { - "version": "0.6.2", - "from": "colors@>=0.6.2 <0.7.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz" - }, - "combined-stream": { - "version": "1.0.5", - "from": "combined-stream@>=1.0.5 <1.1.0", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz" - }, - "commander": { - "version": "2.9.0", - "from": "commander@>=2.9.0 <3.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" - }, - "concat-map": { - "version": "0.0.1", - "from": "concat-map@0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" - }, - "connect-redis": { - "version": "3.1.0", - "from": "connect-redis@>=3.1.0 <4.0.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-3.1.0.tgz", - "dependencies": { - "debug": { - "version": "2.4.5", - "from": "debug@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.4.5.tgz" - }, - "ms": { - "version": "0.7.2", - "from": "ms@0.7.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" - }, - "redis": { - "version": "2.6.3", - "from": "redis@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/redis/-/redis-2.6.3.tgz" - } - } - }, - "console-control-strings": { - "version": "1.1.0", - "from": "console-control-strings@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz" - }, - "constantinople": { - "version": "2.0.1", - "from": "constantinople@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-2.0.1.tgz" - }, - "content-disposition": { - "version": "0.5.0", - "from": "content-disposition@0.5.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.0.tgz" - }, - "content-type": { - "version": "1.0.2", - "from": "content-type@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz" - }, - "contentful": { - "version": "3.8.0", - "from": "contentful@>=3.3.14 <4.0.0", - "resolved": "https://registry.npmjs.org/contentful/-/contentful-3.8.0.tgz", - "dependencies": { - "lodash": { - "version": "4.2.1", - "from": "lodash@>=4.2.0 <4.3.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.2.1.tgz" - } - } - }, - "contentful-sdk-core": { - "version": "2.5.0", - "from": "contentful-sdk-core@>=2.5.0 <2.6.0", - "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-2.5.0.tgz" - }, - "cookie": { - "version": "0.2.4", - "from": "cookie@>=0.2.3 <0.3.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.2.4.tgz" - }, - "cookie-parser": { - "version": "1.3.5", - "from": "cookie-parser@1.3.5", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.3.5.tgz", - "dependencies": { - "cookie": { - "version": "0.1.3", - "from": "cookie@0.1.3", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz" - } - } - }, - "cookie-signature": { - "version": "1.0.6", - "from": "cookie-signature@1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" - }, - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - }, - "core-util-is": { - "version": "1.0.2", - "from": "core-util-is@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" - }, - "crc": { - "version": "3.4.1", - "from": "crc@3.4.1", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.4.1.tgz" - }, - "crc32-stream": { - "version": "0.2.0", - "from": "crc32-stream@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-0.2.0.tgz" - }, - "cross-env": { - "version": "3.1.3", - "from": "cross-env@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-3.1.3.tgz" - }, - "cross-spawn": { - "version": "3.0.1", - "from": "cross-spawn@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", - "dependencies": { - "lru-cache": { - "version": "4.0.2", - "from": "lru-cache@>=4.0.1 <5.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz" - }, - "which": { - "version": "1.2.12", - "from": "which@>=1.2.9 <2.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.12.tgz" - } - } - }, - "cryptiles": { - "version": "2.0.5", - "from": "cryptiles@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz" - }, - "crypto-browserify": { - "version": "1.0.9", - "from": "crypto-browserify@1.0.9", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz" - }, - "csrf": { - "version": "3.0.4", - "from": "csrf@>=3.0.3 <3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.0.4.tgz" - }, - "css": { - "version": "1.0.8", - "from": "css@>=1.0.8 <1.1.0", - "resolved": "https://registry.npmjs.org/css/-/css-1.0.8.tgz" - }, - "css-parse": { - "version": "1.0.4", - "from": "css-parse@1.0.4", - "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-1.0.4.tgz" - }, - "css-stringify": { - "version": "1.0.5", - "from": "css-stringify@1.0.5", - "resolved": "https://registry.npmjs.org/css-stringify/-/css-stringify-1.0.5.tgz" - }, - "csurf": { - "version": "1.9.0", - "from": "csurf@>=1.8.3 <2.0.0", - "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.9.0.tgz", - "dependencies": { - "cookie": { - "version": "0.3.1", - "from": "cookie@0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz" - } - } - }, - "dashdash": { - "version": "1.14.1", - "from": "dashdash@>=1.12.0 <2.0.0", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "from": "assert-plus@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" - } - } - }, - "dateformat": { - "version": "1.0.4-1.2.3", - "from": "dateformat@1.0.4-1.2.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.4-1.2.3.tgz" - }, - "debug": { - "version": "1.0.4", - "from": "debug@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz" - }, - "decamelize": { - "version": "1.2.0", - "from": "decamelize@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" - }, - "deep-eql": { - "version": "0.1.3", - "from": "deep-eql@^0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "dependencies": { - "type-detect": { - "version": "0.1.1", - "from": "type-detect@0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz" - } - } - }, - "deep-extend": { - "version": "0.4.1", - "from": "deep-extend@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.1.tgz" - }, - "deflate-crc32-stream": { - "version": "0.1.2", - "from": "deflate-crc32-stream@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/deflate-crc32-stream/-/deflate-crc32-stream-0.1.2.tgz" - }, - "delayed-stream": { - "version": "1.0.0", - "from": "delayed-stream@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" - }, - "delegates": { - "version": "1.0.0", - "from": "delegates@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz" - }, - "depd": { - "version": "1.1.0", - "from": "depd@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz" - }, - "destroy": { - "version": "1.0.3", - "from": "destroy@1.0.3", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.3.tgz" - }, - "dicer": { - "version": "0.2.5", - "from": "dicer@0.2.5", - "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", - "dependencies": { - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "diff": { - "version": "1.0.7", - "from": "diff@1.0.7", - "resolved": "https://registry.npmjs.org/diff/-/diff-1.0.7.tgz" - }, - "dottie": { - "version": "1.1.1", - "from": "dottie@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/dottie/-/dottie-1.1.1.tgz" - }, - "double-ended-queue": { - "version": "2.1.0-0", - "from": "double-ended-queue@>=2.1.0-0 <3.0.0", - "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz" - }, - "each-series": { - "version": "1.0.0", - "from": "each-series@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/each-series/-/each-series-1.0.0.tgz" - }, - "ecc-jsbn": { - "version": "0.1.1", - "from": "ecc-jsbn@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", - "optional": true - }, - "ee-first": { - "version": "1.1.1", - "from": "ee-first@1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" - }, - "ejs": { - "version": "0.8.8", - "from": "ejs@>=0.8.3 <0.9.0", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-0.8.8.tgz" - }, - "encoding": { - "version": "0.1.12", - "from": "encoding@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", - "dependencies": { - "iconv-lite": { - "version": "0.4.15", - "from": "iconv-lite@>=0.4.13 <0.5.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz" - } - } - }, - "end-of-stream": { - "version": "0.1.5", - "from": "end-of-stream@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz" - }, - "es6-promise": { - "version": "3.2.1", - "from": "es6-promise@3.2.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz" - }, - "escape-html": { - "version": "1.0.2", - "from": "escape-html@1.0.2", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.2.tgz" - }, - "escape-string-regexp": { - "version": "1.0.5", - "from": "escape-string-regexp@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" - }, - "esprima": { - "version": "1.0.4", - "from": "esprima@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" - }, - "etag": { - "version": "1.7.0", - "from": "etag@>=1.7.0 <1.8.0", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz" - }, - "eventemitter2": { - "version": "0.4.14", - "from": "eventemitter2@>=0.4.13 <0.5.0", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz" - }, - "eventemitter3": { - "version": "1.2.0", - "from": "eventemitter3@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz" - }, - "exit": { - "version": "0.1.2", - "from": "exit@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz" - }, - "express": { - "version": "4.13.0", - "from": "express@4.13.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.13.0.tgz", - "dependencies": { - "cookie": { - "version": "0.1.3", - "from": "cookie@0.1.3", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz" - }, - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "depd": { - "version": "1.0.1", - "from": "depd@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "qs": { - "version": "2.4.2", - "from": "qs@2.4.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz" - } - } - }, - "express-session": { - "version": "1.14.2", - "from": "express-session@>=1.14.2 <2.0.0", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.14.2.tgz", - "dependencies": { - "cookie": { - "version": "0.3.1", - "from": "cookie@0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz" - }, - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - } - } - }, - "extend": { - "version": "3.0.0", - "from": "extend@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.0.tgz" - }, - "extendible": { - "version": "0.1.1", - "from": "extendible@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/extendible/-/extendible-0.1.1.tgz" - }, - "extsprintf": { - "version": "1.0.2", - "from": "extsprintf@1.0.2", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz" - }, - "failure": { - "version": "1.1.1", - "from": "failure@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/failure/-/failure-1.1.1.tgz" - }, - "file-utils": { - "version": "0.1.5", - "from": "file-utils@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/file-utils/-/file-utils-0.1.5.tgz", - "dependencies": { - "lodash": { - "version": "2.1.0", - "from": "lodash@>=2.1.0 <2.2.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.1.0.tgz" - } - } - }, - "finalhandler": { - "version": "0.4.0", - "from": "finalhandler@0.4.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.4.0.tgz", - "dependencies": { - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - } - } - }, - "findup-sync": { - "version": "0.1.3", - "from": "findup-sync@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz", - "dependencies": { - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - } - } - }, - "flexbuffer": { - "version": "0.0.6", - "from": "flexbuffer@0.0.6", - "resolved": "https://registry.npmjs.org/flexbuffer/-/flexbuffer-0.0.6.tgz" - }, - "follow-redirects": { - "version": "0.0.7", - "from": "follow-redirects@0.0.7", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.7.tgz", - "dependencies": { - "debug": { - "version": "2.4.5", - "from": "debug@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.4.5.tgz" - }, - "ms": { - "version": "0.7.2", - "from": "ms@0.7.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" - } - } - }, - "forever-agent": { - "version": "0.6.1", - "from": "forever-agent@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" - }, - "form-data": { - "version": "2.1.2", - "from": "form-data@>=2.1.1 <2.2.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.2.tgz" - }, - "formatio": { - "version": "1.1.1", - "from": "formatio@1.1.1", - "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.1.1.tgz" - }, - "forwarded": { - "version": "0.1.0", - "from": "forwarded@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz" - }, - "fresh": { - "version": "0.3.0", - "from": "fresh@0.3.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz" - }, - "fs-extra": { - "version": "0.11.1", - "from": "fs-extra@>=0.11.1 <0.12.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.11.1.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.5 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "ncp": { - "version": "0.6.0", - "from": "ncp@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.6.0.tgz" - }, - "rimraf": { - "version": "2.5.4", - "from": "rimraf@>=2.2.8 <3.0.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz" - } - } - }, - "fs.realpath": { - "version": "1.0.0", - "from": "fs.realpath@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" - }, - "fstream": { - "version": "1.0.10", - "from": "fstream@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.10.tgz" - }, - "fstream-ignore": { - "version": "1.0.5", - "from": "fstream-ignore@>=1.0.5 <1.1.0", - "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz", - "dependencies": { - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - } - } - }, - "gauge": { - "version": "2.7.2", - "from": "gauge@>=2.7.1 <2.8.0", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.2.tgz" - }, - "generate-function": { - "version": "2.0.0", - "from": "generate-function@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" - }, - "generate-object-property": { - "version": "1.2.0", - "from": "generate-object-property@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz" - }, - "generic-pool": { - "version": "2.4.2", - "from": "generic-pool@2.4.2", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.2.tgz" - }, - "getobject": { - "version": "0.1.0", - "from": "getobject@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/getobject/-/getobject-0.1.0.tgz" - }, - "getpass": { - "version": "0.1.6", - "from": "getpass@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.6.tgz", - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "from": "assert-plus@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" - } - } - }, - "glob": { - "version": "3.2.11", - "from": "glob@>=3.2.6 <3.3.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", - "dependencies": { - "minimatch": { - "version": "0.3.0", - "from": "minimatch@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz" - } - } - }, - "graceful-fs": { - "version": "4.1.11", - "from": "graceful-fs@>=4.1.2 <5.0.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz" - }, - "graceful-readlink": { - "version": "1.0.1", - "from": "graceful-readlink@>=1.0.0", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" - }, - "growl": { - "version": "1.7.0", - "from": "growl@>=1.7.0 <1.8.0", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.7.0.tgz" - }, - "grunt": { - "version": "0.4.5", - "from": "grunt@>=0.4.5 <0.5.0", - "resolved": "https://registry.npmjs.org/grunt/-/grunt-0.4.5.tgz", - "dependencies": { - "async": { - "version": "0.1.22", - "from": "async@>=0.1.22 <0.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.1.22.tgz" - }, - "dateformat": { - "version": "1.0.2-1.2.3", - "from": "dateformat@1.0.2-1.2.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.2-1.2.3.tgz" - }, - "glob": { - "version": "3.1.21", - "from": "glob@>=3.1.21 <3.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz" - }, - "graceful-fs": { - "version": "1.2.3", - "from": "graceful-fs@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" - }, - "inherits": { - "version": "1.0.2", - "from": "inherits@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz" - }, - "lodash": { - "version": "0.9.2", - "from": "lodash@>=0.9.2 <0.10.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-0.9.2.tgz" - }, - "nopt": { - "version": "1.0.10", - "from": "nopt@>=1.0.10 <1.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz" - }, - "rimraf": { - "version": "2.2.8", - "from": "rimraf@>=2.2.8 <2.3.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz" - } - } - }, - "grunt-legacy-log": { - "version": "0.1.3", - "from": "grunt-legacy-log@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-0.1.3.tgz", - "dependencies": { - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - }, - "underscore.string": { - "version": "2.3.3", - "from": "underscore.string@>=2.3.3 <2.4.0", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.3.3.tgz" - } - } - }, - "grunt-legacy-log-utils": { - "version": "0.1.1", - "from": "grunt-legacy-log-utils@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-0.1.1.tgz", - "dependencies": { - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - }, - "underscore.string": { - "version": "2.3.3", - "from": "underscore.string@>=2.3.3 <2.4.0", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.3.3.tgz" - } - } - }, - "grunt-legacy-util": { - "version": "0.2.0", - "from": "grunt-legacy-util@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-0.2.0.tgz", - "dependencies": { - "async": { - "version": "0.1.22", - "from": "async@>=0.1.22 <0.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.1.22.tgz" - }, - "lodash": { - "version": "0.9.2", - "from": "lodash@>=0.9.2 <0.10.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-0.9.2.tgz" - } - } - }, - "hang": { - "version": "1.0.0", - "from": "hang@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/hang/-/hang-1.0.0.tgz" - }, - "har-validator": { - "version": "2.0.6", - "from": "har-validator@>=2.0.6 <2.1.0", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz" - }, - "has-ansi": { - "version": "2.0.0", - "from": "has-ansi@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" - }, - "has-unicode": { - "version": "2.0.1", - "from": "has-unicode@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz" - }, - "hawk": { - "version": "3.1.3", - "from": "hawk@>=3.1.3 <3.2.0", - "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz" - }, - "heapdump": { - "version": "0.3.7", - "from": "heapdump@>=0.3.7 <0.4.0", - "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.7.tgz" - }, - "hoek": { - "version": "2.16.3", - "from": "hoek@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz" - }, - "hooker": { - "version": "0.2.3", - "from": "hooker@>=0.2.3 <0.3.0", - "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz" - }, - "hooks-fixed": { - "version": "1.1.0", - "from": "hooks-fixed@1.1.0", - "resolved": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-1.1.0.tgz" - }, - "http-errors": { - "version": "1.5.1", - "from": "http-errors@>=1.5.0 <1.6.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz" - }, - "http-proxy": { - "version": "1.16.2", - "from": "http-proxy@>=1.8.1 <2.0.0", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.16.2.tgz" - }, - "http-signature": { - "version": "1.1.1", - "from": "http-signature@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz" - }, - "iconv-lite": { - "version": "0.2.11", - "from": "iconv-lite@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz" - }, - "ieee754": { - "version": "1.1.8", - "from": "ieee754@>=1.1.4 <2.0.0", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz" - }, - "inflection": { - "version": "1.10.0", - "from": "inflection@>=1.6.0 <2.0.0", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.10.0.tgz" - }, - "inflight": { - "version": "1.0.6", - "from": "inflight@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" - }, - "inherits": { - "version": "2.0.3", - "from": "inherits@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" - }, - "ini": { - "version": "1.3.4", - "from": "ini@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz" - }, - "ioredis": { - "version": "2.4.3", - "from": "ioredis@>=2.4.0 <3.0.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-2.4.3.tgz", - "dependencies": { - "debug": { - "version": "2.4.5", - "from": "debug@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.4.5.tgz" - }, - "ms": { - "version": "0.7.2", - "from": "ms@0.7.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" - }, - "redis-parser": { - "version": "1.3.0", - "from": "redis-parser@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-1.3.0.tgz" - } - } - }, - "ipaddr.js": { - "version": "1.0.5", - "from": "ipaddr.js@1.0.5", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.0.5.tgz" - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "from": "is-fullwidth-code-point@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz" - }, - "is-my-json-valid": { - "version": "2.15.0", - "from": "is-my-json-valid@>=2.12.4 <3.0.0", - "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz" - }, - "is-promise": { - "version": "1.0.1", - "from": "is-promise@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz" - }, - "is-property": { - "version": "1.0.2", - "from": "is-property@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" - }, - "is-typedarray": { - "version": "1.0.0", - "from": "is-typedarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "isbinaryfile": { - "version": "0.1.9", - "from": "isbinaryfile@>=0.1.9 <0.2.0", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-0.1.9.tgz" - }, - "isexe": { - "version": "1.1.2", - "from": "isexe@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz" - }, - "isstream": { - "version": "0.1.2", - "from": "isstream@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" - }, - "jade": { - "version": "1.3.1", - "from": "jade@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/jade/-/jade-1.3.1.tgz", - "dependencies": { - "commander": { - "version": "2.1.0", - "from": "commander@2.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz" - }, - "mkdirp": { - "version": "0.3.5", - "from": "mkdirp@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz" - } - } - }, - "jmespath": { - "version": "0.15.0", - "from": "jmespath@0.15.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz" - }, - "jodid25519": { - "version": "1.0.2", - "from": "jodid25519@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz", - "optional": true - }, - "js-yaml": { - "version": "2.0.5", - "from": "js-yaml@>=2.0.5 <2.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-2.0.5.tgz" - }, - "jsbn": { - "version": "0.1.0", - "from": "jsbn@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.0.tgz", - "optional": true - }, - "json-schema": { - "version": "0.2.3", - "from": "json-schema@0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz" - }, - "json-stringify-safe": { - "version": "5.0.1", - "from": "json-stringify-safe@>=5.0.1 <5.1.0", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" - }, - "jsonfile": { - "version": "2.4.0", - "from": "jsonfile@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz" - }, - "jsonpointer": { - "version": "4.0.0", - "from": "jsonpointer@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.0.tgz" - }, - "jsprim": { - "version": "1.3.1", - "from": "jsprim@>=1.2.2 <2.0.0", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.3.1.tgz" - }, - "kareem": { - "version": "1.0.1", - "from": "kareem@1.0.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-1.0.1.tgz" - }, - "kerberos": { - "version": "0.0.22", - "from": "kerberos@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.22.tgz", - "optional": true, - "dependencies": { - "nan": { - "version": "2.4.0", - "from": "nan@>=2.4.0 <2.5.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.4.0.tgz", - "optional": true - } - } - }, - "lazystream": { - "version": "0.1.0", - "from": "lazystream@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-0.1.0.tgz" - }, - "ldap-filter": { - "version": "0.2.2", - "from": "ldap-filter@0.2.2", - "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz", - "dependencies": { - "assert-plus": { - "version": "0.1.5", - "from": "assert-plus@0.1.5", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" - } - } - }, - "ldapauth-fork": { - "version": "2.5.4", - "from": "ldapauth-fork@>=2.5.0 <2.6.0", - "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-2.5.4.tgz", - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "from": "assert-plus@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" - }, - "bunyan": { - "version": "1.8.5", - "from": "bunyan@>=1.8.3 <2.0.0", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.5.tgz", - "dependencies": { - "dtrace-provider": { - "version": "0.8.0", - "from": "dtrace-provider@>=0.8.0 <0.9.0", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.0.tgz", - "optional": true - } - } - }, - "dtrace-provider": { - "version": "0.7.1", - "from": "dtrace-provider@>=0.7.0 <0.8.0", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.7.1.tgz", - "optional": true - }, - "extsprintf": { - "version": "1.2.0", - "from": "extsprintf@1.2.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz" - }, - "glob": { - "version": "6.0.4", - "from": "glob@>=6.0.1 <7.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "optional": true - }, - "ldapjs": { - "version": "1.0.1", - "from": "ldapjs@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.1.tgz" - }, - "lru-cache": { - "version": "3.2.0", - "from": "lru-cache@3.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", - "optional": true - }, - "mv": { - "version": "2.1.1", - "from": "mv@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "optional": true - }, - "once": { - "version": "1.4.0", - "from": "once@>=1.4.0 <2.0.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz" - }, - "rimraf": { - "version": "2.4.5", - "from": "rimraf@>=2.4.0 <2.5.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "optional": true - }, - "vasync": { - "version": "1.6.4", - "from": "vasync@>=1.6.4 <2.0.0", - "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", - "dependencies": { - "verror": { - "version": "1.6.0", - "from": "verror@1.6.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz" - } - } - }, - "verror": { - "version": "1.9.0", - "from": "verror@>=1.8.1 <2.0.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.9.0.tgz" - } - } - }, - "ldapjs": { - "version": "0.7.1", - "from": "ldapjs@>=0.7.1 <0.8.0", - "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-0.7.1.tgz", - "dependencies": { - "asn1": { - "version": "0.2.1", - "from": "asn1@0.2.1", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.1.tgz" - }, - "assert-plus": { - "version": "0.1.5", - "from": "assert-plus@0.1.5", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" - }, - "nopt": { - "version": "2.1.1", - "from": "nopt@2.1.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-2.1.1.tgz" - } - } - }, - "libbase64": { - "version": "0.1.0", - "from": "libbase64@0.1.0", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz" - }, - "libmime": { - "version": "2.0.0", - "from": "libmime@2.0.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.0.0.tgz", - "dependencies": { - "iconv-lite": { - "version": "0.4.13", - "from": "iconv-lite@0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" - } - } - }, - "libqp": { - "version": "1.1.0", - "from": "libqp@1.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz" - }, - "loads": { - "version": "0.0.4", - "from": "loads@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/loads/-/loads-0.0.4.tgz" - }, - "lodash": { - "version": "4.17.2", - "from": "lodash@>=4.13.1 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.2.tgz" - }, - "logger-sharelatex": { - "version": "1.5.1", - "from": "git+https://github.com/sharelatex/logger-sharelatex.git#master", - "resolved": "git+https://github.com/sharelatex/logger-sharelatex.git#405cf1350ca5ae5f7bb1e7091e28d5aa3aaaa72c", - "dependencies": { - "bunyan": { - "version": "1.5.1", - "from": "bunyan@1.5.1", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.5.1.tgz" - }, - "coffee-script": { - "version": "1.4.0", - "from": "coffee-script@1.4.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.4.0.tgz" - }, - "dtrace-provider": { - "version": "0.6.0", - "from": "dtrace-provider@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.6.0.tgz", - "optional": true - }, - "glob": { - "version": "6.0.4", - "from": "glob@>=6.0.1 <7.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "optional": true - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", - "optional": true - }, - "mv": { - "version": "2.1.1", - "from": "mv@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "optional": true - }, - "rimraf": { - "version": "2.4.5", - "from": "rimraf@>=2.4.0 <2.5.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "optional": true - } - } - }, - "lolex": { - "version": "1.3.2", - "from": "lolex@1.3.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.3.2.tgz" - }, - "lru-cache": { - "version": "2.7.3", - "from": "lru-cache@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz" - }, - "lsmod": { - "version": "0.0.3", - "from": "lsmod@>=0.0.3 <0.1.0", - "resolved": "https://registry.npmjs.org/lsmod/-/lsmod-0.0.3.tgz" - }, - "lynx": { - "version": "0.1.1", - "from": "lynx@0.1.1", - "resolved": "https://registry.npmjs.org/lynx/-/lynx-0.1.1.tgz" - }, - "mailcomposer": { - "version": "3.3.2", - "from": "mailcomposer@3.3.2", - "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.3.2.tgz" - }, - "marked": { - "version": "0.3.6", - "from": "marked@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.6.tgz" - }, - "media-typer": { - "version": "0.3.0", - "from": "media-typer@0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" - }, - "merge-descriptors": { - "version": "1.0.0", - "from": "merge-descriptors@1.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.0.tgz" - }, - "mersenne": { - "version": "0.0.3", - "from": "mersenne@>=0.0.3 <0.1.0", - "resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.3.tgz" - }, - "method-override": { - "version": "2.3.7", - "from": "method-override@>=2.3.3 <3.0.0", - "resolved": "https://registry.npmjs.org/method-override/-/method-override-2.3.7.tgz", - "dependencies": { - "debug": { - "version": "2.3.3", - "from": "debug@2.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz" - }, - "ms": { - "version": "0.7.2", - "from": "ms@0.7.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" - }, - "vary": { - "version": "1.1.0", - "from": "vary@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.0.tgz" - } - } - }, - "methods": { - "version": "1.1.2", - "from": "methods@>=1.1.1 <1.2.0", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" - }, - "metrics-sharelatex": { - "version": "1.6.0", - "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.6.0", - "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#718f1144407ab2c867b869ebb38e07de2be1933b", - "dependencies": { - "coffee-script": { - "version": "1.6.0", - "from": "coffee-script@1.6.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz" - } - } - }, - "mime": { - "version": "1.3.4", - "from": "mime@1.3.4", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" - }, - "mime-db": { - "version": "1.25.0", - "from": "mime-db@>=1.25.0 <1.26.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz" - }, - "mime-types": { - "version": "2.1.13", - "from": "mime-types@>=2.1.7 <2.2.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz" - }, - "mimelib": { - "version": "0.2.14", - "from": "mimelib@0.2.14", - "resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.2.14.tgz" - }, - "minimatch": { - "version": "0.2.14", - "from": "minimatch@>=0.2.12 <0.3.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz" - }, - "minimist": { - "version": "0.0.8", - "from": "minimist@0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" - }, - "mkdirp": { - "version": "0.5.1", - "from": "mkdirp@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" - }, - "mocha": { - "version": "1.17.1", - "from": "mocha@1.17.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.17.1.tgz", - "dependencies": { - "commander": { - "version": "2.0.0", - "from": "commander@2.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz" - }, - "glob": { - "version": "3.2.3", - "from": "glob@3.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz" - }, - "graceful-fs": { - "version": "2.0.3", - "from": "graceful-fs@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz" - }, - "jade": { - "version": "0.26.3", - "from": "jade@0.26.3", - "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", - "dependencies": { - "commander": { - "version": "0.6.1", - "from": "commander@0.6.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz" - }, - "mkdirp": { - "version": "0.3.0", - "from": "mkdirp@0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" - } - } - }, - "mkdirp": { - "version": "0.3.5", - "from": "mkdirp@0.3.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz" - } - } - }, - "moment": { - "version": "2.17.1", - "from": "moment@>=2.10.6 <3.0.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.17.1.tgz" - }, - "moment-timezone": { - "version": "0.5.10", - "from": "moment-timezone@>=0.5.4 <0.6.0", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.10.tgz" - }, - "mongodb": { - "version": "2.2.16", - "from": "mongodb@>=2.0.45 <3.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.16.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "readable-stream": { - "version": "2.1.5", - "from": "readable-stream@2.1.5", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz" - } - } - }, - "mongodb-core": { - "version": "2.1.2", - "from": "mongodb-core@2.1.2", - "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.2.tgz" - }, - "mongojs": { - "version": "2.4.0", - "from": "mongojs@2.4.0", - "resolved": "https://registry.npmjs.org/mongojs/-/mongojs-2.4.0.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "readable-stream": { - "version": "2.2.2", - "from": "readable-stream@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.2.tgz" - } - } - }, - "mongoose": { - "version": "4.1.0", - "from": "mongoose@4.1.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-4.1.0.tgz", - "dependencies": { - "async": { - "version": "0.9.0", - "from": "async@0.9.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz" - }, - "bson": { - "version": "0.3.2", - "from": "bson@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-0.3.2.tgz" - }, - "mongodb": { - "version": "2.0.34", - "from": "mongodb@2.0.34", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.0.34.tgz" - }, - "mongodb-core": { - "version": "1.2.0", - "from": "mongodb-core@1.2.0", - "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-1.2.0.tgz", - "dependencies": { - "bson": { - "version": "0.4.23", - "from": "bson@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-0.4.23.tgz" - } - } - }, - "ms": { - "version": "0.1.0", - "from": "ms@0.1.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.1.0.tgz" - }, - "readable-stream": { - "version": "1.0.31", - "from": "readable-stream@1.0.31", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.31.tgz" - } - } - }, - "monocle": { - "version": "1.1.51", - "from": "monocle@1.1.51", - "resolved": "https://registry.npmjs.org/monocle/-/monocle-1.1.51.tgz" - }, - "mpath": { - "version": "0.1.1", - "from": "mpath@0.1.1", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.1.1.tgz" - }, - "mpromise": { - "version": "0.5.4", - "from": "mpromise@0.5.4", - "resolved": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.4.tgz" - }, - "mquery": { - "version": "1.6.1", - "from": "mquery@1.6.1", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-1.6.1.tgz", - "dependencies": { - "bluebird": { - "version": "2.9.26", - "from": "bluebird@2.9.26", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.9.26.tgz" - }, - "debug": { - "version": "2.2.0", - "from": "debug@2.2.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - } - } - }, - "ms": { - "version": "0.6.2", - "from": "ms@0.6.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz" - }, - "multer": { - "version": "0.1.8", - "from": "multer@>=0.1.8 <0.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-0.1.8.tgz", - "dependencies": { - "mime-db": { - "version": "1.12.0", - "from": "mime-db@>=1.12.0 <1.13.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.12.0.tgz" - }, - "mime-types": { - "version": "2.0.14", - "from": "mime-types@>=2.0.9 <2.1.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.0.14.tgz" - }, - "mkdirp": { - "version": "0.3.5", - "from": "mkdirp@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz" - }, - "qs": { - "version": "1.2.2", - "from": "qs@>=1.2.2 <1.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-1.2.2.tgz" - }, - "type-is": { - "version": "1.5.7", - "from": "type-is@>=1.5.2 <1.6.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.5.7.tgz" - } - } - }, - "muri": { - "version": "1.0.0", - "from": "muri@1.0.0", - "resolved": "https://registry.npmjs.org/muri/-/muri-1.0.0.tgz" - }, - "mv": { - "version": "0.0.5", - "from": "mv@0.0.5", - "resolved": "https://registry.npmjs.org/mv/-/mv-0.0.5.tgz", - "optional": true - }, - "nan": { - "version": "2.3.5", - "from": "nan@2.3.5", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz" - }, - "ncp": { - "version": "2.0.0", - "from": "ncp@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "optional": true - }, - "negotiator": { - "version": "0.5.3", - "from": "negotiator@0.5.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.5.3.tgz" - }, - "node-forge": { - "version": "0.2.24", - "from": "node-forge@0.2.24", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.2.24.tgz" - }, - "node-pre-gyp": { - "version": "0.6.30", - "from": "node-pre-gyp@0.6.30", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.30.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.5 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "rimraf": { - "version": "2.5.4", - "from": "rimraf@>=2.5.0 <2.6.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz" - } - } - }, - "node-uuid": { - "version": "1.4.1", - "from": "node-uuid@1.4.1", - "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.1.tgz" - }, - "nodemailer": { - "version": "2.1.0", - "from": "nodemailer@2.1.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-2.1.0.tgz" - }, - "nodemailer-direct-transport": { - "version": "2.0.1", - "from": "nodemailer-direct-transport@2.0.1", - "resolved": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-2.0.1.tgz" - }, - "nodemailer-fetch": { - "version": "1.2.1", - "from": "nodemailer-fetch@1.2.1", - "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.2.1.tgz" - }, - "nodemailer-sendgrid-transport": { - "version": "0.2.0", - "from": "nodemailer-sendgrid-transport@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/nodemailer-sendgrid-transport/-/nodemailer-sendgrid-transport-0.2.0.tgz" - }, - "nodemailer-ses-transport": { - "version": "1.5.0", - "from": "nodemailer-ses-transport@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/nodemailer-ses-transport/-/nodemailer-ses-transport-1.5.0.tgz" - }, - "nodemailer-shared": { - "version": "1.0.3", - "from": "nodemailer-shared@1.0.3", - "resolved": "https://registry.npmjs.org/nodemailer-shared/-/nodemailer-shared-1.0.3.tgz" - }, - "nodemailer-smtp-pool": { - "version": "2.1.0", - "from": "nodemailer-smtp-pool@2.1.0", - "resolved": "https://registry.npmjs.org/nodemailer-smtp-pool/-/nodemailer-smtp-pool-2.1.0.tgz" - }, - "nodemailer-smtp-transport": { - "version": "2.0.1", - "from": "nodemailer-smtp-transport@2.0.1", - "resolved": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-2.0.1.tgz" - }, - "nodemailer-wellknown": { - "version": "0.1.7", - "from": "nodemailer-wellknown@0.1.7", - "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.7.tgz" - }, - "nopt": { - "version": "3.0.6", - "from": "nopt@>=3.0.1 <3.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz" - }, - "npmlog": { - "version": "4.0.2", - "from": "npmlog@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.0.2.tgz" - }, - "number-is-nan": { - "version": "1.0.1", - "from": "number-is-nan@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz" - }, - "oauth-sign": { - "version": "0.8.2", - "from": "oauth-sign@>=0.8.1 <0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz" - }, - "object-assign": { - "version": "4.1.0", - "from": "object-assign@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz" - }, - "on-finished": { - "version": "2.3.0", - "from": "on-finished@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" - }, - "on-headers": { - "version": "1.0.1", - "from": "on-headers@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz" - }, - "once": { - "version": "1.3.3", - "from": "once@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz" - }, - "one-time": { - "version": "0.0.4", - "from": "one-time@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz" - }, - "optimist": { - "version": "0.6.1", - "from": "optimist@0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz" - }, - "os-tmpdir": { - "version": "1.0.2", - "from": "os-tmpdir@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz" - }, - "parse-mongo-url": { - "version": "1.1.1", - "from": "parse-mongo-url@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/parse-mongo-url/-/parse-mongo-url-1.1.1.tgz" - }, - "parseurl": { - "version": "1.3.1", - "from": "parseurl@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz" - }, - "passport": { - "version": "0.3.2", - "from": "passport@>=0.3.2 <0.4.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz" - }, - "passport-ldapauth": { - "version": "0.6.0", - "from": "passport-ldapauth@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-0.6.0.tgz" - }, - "passport-local": { - "version": "1.0.0", - "from": "passport-local@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz" - }, - "passport-saml": { - "version": "0.15.0", - "from": "passport-saml@>=0.15.0 <0.16.0", - "resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-0.15.0.tgz", - "dependencies": { - "xml2js": { - "version": "0.4.17", - "from": "xml2js@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz", - "dependencies": { - "xmlbuilder": { - "version": "4.2.1", - "from": "xmlbuilder@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz" - } - } - }, - "xmlbuilder": { - "version": "2.5.2", - "from": "xmlbuilder@>=2.5.0 <2.6.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.5.2.tgz", - "dependencies": { - "lodash": { - "version": "3.2.0", - "from": "lodash@>=3.2.0 <3.3.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.2.0.tgz" - } - } - } - } - }, - "passport-strategy": { - "version": "1.0.0", - "from": "passport-strategy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" - }, - "path-is-absolute": { - "version": "1.0.1", - "from": "path-is-absolute@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" - }, - "path-to-regexp": { - "version": "0.1.6", - "from": "path-to-regexp@0.1.6", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.6.tgz" - }, - "pause": { - "version": "0.0.1", - "from": "pause@0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz" - }, - "pinkie": { - "version": "2.0.4", - "from": "pinkie@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" - }, - "pinkie-promise": { - "version": "2.0.1", - "from": "pinkie-promise@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" - }, - "pooling": { - "version": "0.4.6", - "from": "pooling@0.4.6", - "resolved": "https://registry.npmjs.org/pooling/-/pooling-0.4.6.tgz", - "dependencies": { - "assert-plus": { - "version": "0.1.5", - "from": "assert-plus@0.1.5", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" - }, - "once": { - "version": "1.3.0", - "from": "once@1.3.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.0.tgz" - } - } - }, - "precond": { - "version": "0.2.3", - "from": "precond@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz" - }, - "process-nextick-args": { - "version": "1.0.7", - "from": "process-nextick-args@>=1.0.6 <1.1.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" - }, - "promise": { - "version": "2.0.0", - "from": "promise@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-2.0.0.tgz" - }, - "proxy-addr": { - "version": "1.0.10", - "from": "proxy-addr@>=1.0.8 <1.1.0", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.10.tgz" - }, - "pseudomap": { - "version": "1.0.2", - "from": "pseudomap@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz" - }, - "punycode": { - "version": "1.4.1", - "from": "punycode@>=1.4.1 <2.0.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" - }, - "q": { - "version": "1.1.2", - "from": "q@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/q/-/q-1.1.2.tgz" - }, - "qs": { - "version": "6.3.0", - "from": "qs@>=6.3.0 <6.4.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.0.tgz" - }, - "querystring": { - "version": "0.2.0", - "from": "querystring@0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz" - }, - "random-bytes": { - "version": "1.0.0", - "from": "random-bytes@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz" - }, - "range-parser": { - "version": "1.0.3", - "from": "range-parser@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.0.3.tgz" - }, - "raven": { - "version": "0.8.1", - "from": "raven@>=0.8.0 <0.9.0", - "resolved": "https://registry.npmjs.org/raven/-/raven-0.8.1.tgz", - "dependencies": { - "cookie": { - "version": "0.1.0", - "from": "cookie@0.1.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.0.tgz" - } - } - }, - "raw-body": { - "version": "2.1.7", - "from": "raw-body@>=2.1.7 <2.2.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.7.tgz", - "dependencies": { - "iconv-lite": { - "version": "0.4.13", - "from": "iconv-lite@0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" - } - } - }, - "rc": { - "version": "1.1.6", - "from": "rc@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.1.6.tgz", - "dependencies": { - "minimist": { - "version": "1.2.0", - "from": "minimist@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" - } - } - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.24 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "readdirp": { - "version": "0.2.5", - "from": "readdirp@>=0.2.3 <0.3.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-0.2.5.tgz" - }, - "redback": { - "version": "0.4.0", - "from": "redback@0.4.0", - "resolved": "https://registry.npmjs.org/redback/-/redback-0.4.0.tgz" - }, - "redis": { - "version": "0.10.1", - "from": "redis@0.10.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-0.10.1.tgz" - }, - "redis-commands": { - "version": "1.3.0", - "from": "redis-commands@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz" - }, - "redis-parser": { - "version": "2.3.0", - "from": "redis-parser@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.3.0.tgz" - }, - "redis-sentinel": { - "version": "0.1.1", - "from": "redis-sentinel@0.1.1", - "resolved": "https://registry.npmjs.org/redis-sentinel/-/redis-sentinel-0.1.1.tgz", - "dependencies": { - "q": { - "version": "0.9.2", - "from": "q@0.9.2", - "resolved": "https://registry.npmjs.org/q/-/q-0.9.2.tgz" - }, - "redis": { - "version": "0.11.0", - "from": "redis@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/redis/-/redis-0.11.0.tgz" - } - } - }, - "redis-sharelatex": { - "version": "0.0.9", - "from": "redis-sharelatex@0.0.9", - "resolved": "https://registry.npmjs.org/redis-sharelatex/-/redis-sharelatex-0.0.9.tgz", - "dependencies": { - "ansi-regex": { - "version": "0.2.1", - "from": "ansi-regex@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" - }, - "ansi-styles": { - "version": "1.1.0", - "from": "ansi-styles@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz" - }, - "assertion-error": { - "version": "1.0.0", - "from": "assertion-error@1.0.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.0.tgz" - }, - "chai": { - "version": "1.9.1", - "from": "chai@1.9.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-1.9.1.tgz" - }, - "chalk": { - "version": "0.5.1", - "from": "chalk@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz" - }, - "coffee-script": { - "version": "1.8.0", - "from": "coffee-script@1.8.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.8.0.tgz" - }, - "commander": { - "version": "2.0.0", - "from": "commander@2.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz" - }, - "formatio": { - "version": "1.0.2", - "from": "formatio@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.0.2.tgz" - }, - "glob": { - "version": "3.2.3", - "from": "glob@3.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz" - }, - "graceful-fs": { - "version": "2.0.3", - "from": "graceful-fs@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz" - }, - "growl": { - "version": "1.8.1", - "from": "growl@>=1.8.0 <1.9.0", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz" - }, - "grunt-contrib-coffee": { - "version": "0.11.1", - "from": "grunt-contrib-coffee@0.11.1", - "resolved": "https://registry.npmjs.org/grunt-contrib-coffee/-/grunt-contrib-coffee-0.11.1.tgz", - "dependencies": { - "coffee-script": { - "version": "1.7.1", - "from": "coffee-script@>=1.7.0 <1.8.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.7.1.tgz" - } - } - }, - "grunt-mocha-test": { - "version": "0.12.0", - "from": "grunt-mocha-test@0.12.0", - "resolved": "https://registry.npmjs.org/grunt-mocha-test/-/grunt-mocha-test-0.12.0.tgz" - }, - "has-ansi": { - "version": "0.1.0", - "from": "has-ansi@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz" - }, - "jade": { - "version": "0.26.3", - "from": "jade@0.26.3", - "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", - "dependencies": { - "commander": { - "version": "0.6.1", - "from": "commander@0.6.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz" - }, - "mkdirp": { - "version": "0.3.0", - "from": "mkdirp@0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" - } - } - }, - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - }, - "mkdirp": { - "version": "0.3.5", - "from": "mkdirp@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz" - }, - "mocha": { - "version": "1.21.4", - "from": "mocha@1.21.4", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.21.4.tgz" - }, - "redis": { - "version": "0.12.1", - "from": "redis@0.12.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz" - }, - "sandboxed-module": { - "version": "1.0.1", - "from": "sandboxed-module@1.0.1", - "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-1.0.1.tgz" - }, - "sinon": { - "version": "1.10.3", - "from": "sinon@1.10.3", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.10.3.tgz" - }, - "stack-trace": { - "version": "0.0.9", - "from": "stack-trace@0.0.9", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz" - }, - "strip-ansi": { - "version": "0.3.0", - "from": "strip-ansi@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz" - }, - "underscore": { - "version": "1.7.0", - "from": "underscore@1.7.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz" - } - } - }, - "regexp-clone": { - "version": "0.0.1", - "from": "regexp-clone@0.0.1", - "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz" - }, - "request": { - "version": "2.79.0", - "from": "request@>=2.69.0 <3.0.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz" - }, - "requests": { - "version": "0.1.7", - "from": "requests@>=0.1.7 <0.2.0", - "resolved": "https://registry.npmjs.org/requests/-/requests-0.1.7.tgz", - "dependencies": { - "eventemitter3": { - "version": "1.1.1", - "from": "eventemitter3@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.1.1.tgz" - } - } - }, - "require_optional": { - "version": "1.0.0", - "from": "require_optional@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.0.tgz" - }, - "require-like": { - "version": "0.1.2", - "from": "require-like@0.1.2", - "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz" - }, - "requires-port": { - "version": "1.0.0", - "from": "requires-port@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" - }, - "resolve-from": { - "version": "2.0.0", - "from": "resolve-from@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz" - }, - "retry-as-promised": { - "version": "2.2.0", - "from": "retry-as-promised@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-2.2.0.tgz", - "dependencies": { - "debug": { - "version": "2.4.5", - "from": "debug@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.4.5.tgz" - }, - "ms": { - "version": "0.7.2", - "from": "ms@0.7.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" - } - } - }, - "rimraf": { - "version": "2.2.6", - "from": "rimraf@2.2.6", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.6.tgz" - }, - "rndm": { - "version": "1.2.0", - "from": "rndm@1.2.0", - "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz" - }, - "safe-json-stringify": { - "version": "1.0.3", - "from": "safe-json-stringify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.3.tgz", - "optional": true - }, - "samsam": { - "version": "1.1.2", - "from": "samsam@1.1.2", - "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.2.tgz" - }, - "sanitizer": { - "version": "0.1.1", - "from": "sanitizer@0.1.1", - "resolved": "https://registry.npmjs.org/sanitizer/-/sanitizer-0.1.1.tgz" - }, - "sax": { - "version": "1.1.5", - "from": "sax@1.1.5", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.5.tgz" - }, - "semver": { - "version": "5.3.0", - "from": "semver@>=5.3.0 <5.4.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz" - }, - "send": { - "version": "0.13.0", - "from": "send@0.13.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.13.0.tgz", - "dependencies": { - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "depd": { - "version": "1.0.1", - "from": "depd@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz" - }, - "http-errors": { - "version": "1.3.1", - "from": "http-errors@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "statuses": { - "version": "1.2.1", - "from": "statuses@>=1.2.1 <1.3.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz" - } - } - }, - "sendgrid": { - "version": "1.9.2", - "from": "sendgrid@>=1.8.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sendgrid/-/sendgrid-1.9.2.tgz", - "dependencies": { - "lodash": { - "version": "3.10.1", - "from": "lodash@>=3.0.1 <4.0.0||>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" - } - } - }, - "sequelize": { - "version": "3.28.0", - "from": "sequelize@>=3.2.0 <4.0.0", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-3.28.0.tgz", - "dependencies": { - "lodash": { - "version": "4.12.0", - "from": "lodash@4.12.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.12.0.tgz" - }, - "node-uuid": { - "version": "1.4.7", - "from": "node-uuid@>=1.4.4 <1.5.0", - "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.7.tgz" - } - } - }, - "serve-static": { - "version": "1.10.3", - "from": "serve-static@>=1.10.0 <1.11.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.10.3.tgz", - "dependencies": { - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "destroy": { - "version": "1.0.4", - "from": "destroy@>=1.0.4 <1.1.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz" - }, - "escape-html": { - "version": "1.0.3", - "from": "escape-html@>=1.0.3 <1.1.0", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" - }, - "http-errors": { - "version": "1.3.1", - "from": "http-errors@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "send": { - "version": "0.13.2", - "from": "send@0.13.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.13.2.tgz" - }, - "statuses": { - "version": "1.2.1", - "from": "statuses@>=1.2.1 <1.3.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz" - } - } - }, - "set-blocking": { - "version": "2.0.0", - "from": "set-blocking@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" - }, - "setprototypeof": { - "version": "1.0.2", - "from": "setprototypeof@1.0.2", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.2.tgz" - }, - "settings-sharelatex": { - "version": "1.0.0", - "from": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0", - "resolved": "git+https://github.com/sharelatex/settings-sharelatex.git#cbc5e41c1dbe6789721a14b3fdae05bf22546559", - "dependencies": { - "coffee-script": { - "version": "1.6.0", - "from": "coffee-script@1.6.0", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz" - } - } - }, - "shimmer": { - "version": "1.1.0", - "from": "shimmer@1.1.0", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.1.0.tgz" - }, - "sigmund": { - "version": "1.0.1", - "from": "sigmund@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" - }, - "signal-exit": { - "version": "3.0.2", - "from": "signal-exit@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz" - }, - "sinon": { - "version": "1.17.6", - "from": "sinon@latest", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.17.6.tgz" - }, - "sixpack-client": { - "version": "1.0.0", - "from": "sixpack-client@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sixpack-client/-/sixpack-client-1.0.0.tgz" - }, - "sliced": { - "version": "0.0.5", - "from": "sliced@0.0.5", - "resolved": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz" - }, - "smtp-connection": { - "version": "2.0.1", - "from": "smtp-connection@2.0.1", - "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz" - }, - "smtpapi": { - "version": "1.2.0", - "from": "smtpapi@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/smtpapi/-/smtpapi-1.2.0.tgz" - }, - "sntp": { - "version": "1.0.9", - "from": "sntp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz" - }, - "source-map": { - "version": "0.1.34", - "from": "source-map@0.1.34", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.34.tgz" - }, - "sshpk": { - "version": "1.10.1", - "from": "sshpk@>=1.7.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.10.1.tgz", - "dependencies": { - "assert-plus": { - "version": "1.0.0", - "from": "assert-plus@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" - } - } - }, - "stack-trace": { - "version": "0.0.7", - "from": "stack-trace@0.0.7", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.7.tgz" - }, - "statsd-parser": { - "version": "0.0.4", - "from": "statsd-parser@>=0.0.4 <0.1.0", - "resolved": "https://registry.npmjs.org/statsd-parser/-/statsd-parser-0.0.4.tgz" - }, - "statuses": { - "version": "1.3.1", - "from": "statuses@>=1.3.1 <2.0.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" - }, - "stream-consume": { - "version": "0.1.0", - "from": "stream-consume@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz" - }, - "streamsearch": { - "version": "0.1.2", - "from": "streamsearch@0.1.2", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz" - }, - "string_decoder": { - "version": "0.10.31", - "from": "string_decoder@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" - }, - "string-width": { - "version": "1.0.2", - "from": "string-width@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz" - }, - "stringstream": { - "version": "0.0.5", - "from": "stringstream@>=0.0.4 <0.1.0", - "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz" - }, - "strip-ansi": { - "version": "3.0.1", - "from": "strip-ansi@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" - }, - "strip-json-comments": { - "version": "1.0.4", - "from": "strip-json-comments@>=1.0.4 <1.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz" - }, - "supports-color": { - "version": "0.2.0", - "from": "supports-color@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz" - }, - "tar": { - "version": "2.2.1", - "from": "tar@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz" - }, - "tar-pack": { - "version": "3.1.4", - "from": "tar-pack@>=3.1.0 <3.2.0", - "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.1.4.tgz", - "dependencies": { - "debug": { - "version": "2.2.0", - "from": "debug@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.5 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "readable-stream": { - "version": "2.1.5", - "from": "readable-stream@>=2.1.4 <2.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz" - }, - "rimraf": { - "version": "2.5.4", - "from": "rimraf@>=2.5.1 <2.6.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz" - } - } - }, - "tar-stream": { - "version": "0.3.3", - "from": "tar-stream@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-0.3.3.tgz" - }, - "temp": { - "version": "0.8.3", - "from": "temp@>=0.8.3 <0.9.0", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz" - }, - "terraformer": { - "version": "1.0.7", - "from": "terraformer@>=1.0.5 <1.1.0", - "resolved": "https://registry.npmjs.org/terraformer/-/terraformer-1.0.7.tgz" - }, - "terraformer-wkt-parser": { - "version": "1.1.2", - "from": "terraformer-wkt-parser@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/terraformer-wkt-parser/-/terraformer-wkt-parser-1.1.2.tgz" - }, - "thunky": { - "version": "0.1.0", - "from": "thunky@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz" - }, - "timekeeper": { - "version": "1.0.0", - "from": "timekeeper@latest", - "resolved": "https://registry.npmjs.org/timekeeper/-/timekeeper-1.0.0.tgz" - }, - "to-mongodb-core": { - "version": "2.0.0", - "from": "to-mongodb-core@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/to-mongodb-core/-/to-mongodb-core-2.0.0.tgz" - }, - "toposort-class": { - "version": "1.0.1", - "from": "toposort-class@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz" - }, - "tough-cookie": { - "version": "2.3.2", - "from": "tough-cookie@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz" - }, - "transformers": { - "version": "2.1.0", - "from": "transformers@2.1.0", - "resolved": "https://registry.npmjs.org/transformers/-/transformers-2.1.0.tgz", - "dependencies": { - "optimist": { - "version": "0.3.7", - "from": "optimist@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz" - }, - "uglify-js": { - "version": "2.2.5", - "from": "uglify-js@>=2.2.5 <2.3.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.2.5.tgz" - } - } - }, - "tsscmp": { - "version": "1.0.5", - "from": "tsscmp@1.0.5", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz" - }, - "tunnel-agent": { - "version": "0.4.3", - "from": "tunnel-agent@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz" - }, - "tweetnacl": { - "version": "0.14.5", - "from": "tweetnacl@>=0.14.0 <0.15.0", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "optional": true - }, - "type-detect": { - "version": "1.0.0", - "from": "type-detect@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz" - }, - "type-is": { - "version": "1.6.14", - "from": "type-is@>=1.6.13 <1.7.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.14.tgz" - }, - "uglify-js": { - "version": "2.4.24", - "from": "uglify-js@>=2.4.0 <2.5.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.4.24.tgz", - "dependencies": { - "async": { - "version": "0.2.10", - "from": "async@>=0.2.6 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - } - } - }, - "uglify-to-browserify": { - "version": "1.0.2", - "from": "uglify-to-browserify@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz" - }, - "uid-number": { - "version": "0.0.6", - "from": "uid-number@>=0.0.6 <0.1.0", - "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz" - }, - "uid-safe": { - "version": "2.1.3", - "from": "uid-safe@2.1.3", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.3.tgz" - }, - "underscore": { - "version": "1.6.0", - "from": "underscore@1.6.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz" - }, - "underscore.string": { - "version": "2.2.1", - "from": "underscore.string@>=2.2.1 <2.3.0", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.2.1.tgz" - }, - "unpipe": { - "version": "1.0.0", - "from": "unpipe@1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" - }, - "url": { - "version": "0.10.3", - "from": "url@0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "dependencies": { - "punycode": { - "version": "1.3.2", - "from": "punycode@1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz" - } - } - }, - "util": { - "version": "0.10.3", - "from": "util@>=0.10.3 <1", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "dependencies": { - "inherits": { - "version": "2.0.1", - "from": "inherits@2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" - } - } - }, - "util-deprecate": { - "version": "1.0.2", - "from": "util-deprecate@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" - }, - "utils-merge": { - "version": "1.0.0", - "from": "utils-merge@1.0.0", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" - }, - "uuid": { - "version": "3.0.1", - "from": "uuid@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz" - }, - "v8-profiler": { - "version": "5.6.5", - "from": "v8-profiler@>=5.2.3 <6.0.0", - "resolved": "https://registry.npmjs.org/v8-profiler/-/v8-profiler-5.6.5.tgz" - }, - "validator": { - "version": "5.7.0", - "from": "validator@>=5.2.0 <6.0.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-5.7.0.tgz" - }, - "vary": { - "version": "1.0.1", - "from": "vary@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.0.1.tgz" - }, - "vasync": { - "version": "1.4.0", - "from": "vasync@1.4.0", - "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.4.0.tgz", - "dependencies": { - "extsprintf": { - "version": "1.0.0", - "from": "extsprintf@1.0.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.0.tgz" - }, - "json-schema": { - "version": "0.2.2", - "from": "json-schema@0.2.2", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.2.tgz" - }, - "jsprim": { - "version": "0.3.0", - "from": "jsprim@0.3.0", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-0.3.0.tgz", - "dependencies": { - "verror": { - "version": "1.3.3", - "from": "verror@1.3.3", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.3.tgz" - } - } - }, - "verror": { - "version": "1.1.0", - "from": "verror@1.1.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.1.0.tgz" - } - } - }, - "verror": { - "version": "1.3.6", - "from": "verror@1.3.6", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz" - }, - "which": { - "version": "1.0.9", - "from": "which@>=1.0.5 <1.1.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.0.9.tgz" - }, - "wide-align": { - "version": "1.1.0", - "from": "wide-align@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.0.tgz" - }, - "window-size": { - "version": "0.1.0", - "from": "window-size@0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" - }, - "with": { - "version": "3.0.1", - "from": "with@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/with/-/with-3.0.1.tgz" - }, - "wkx": { - "version": "0.2.0", - "from": "wkx@0.2.0", - "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.2.0.tgz" - }, - "wordwrap": { - "version": "0.0.2", - "from": "wordwrap@0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" - }, - "wrappy": { - "version": "1.0.2", - "from": "wrappy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" - }, - "xhr-response": { - "version": "1.0.1", - "from": "xhr-response@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/xhr-response/-/xhr-response-1.0.1.tgz" - }, - "xhr-send": { - "version": "1.0.0", - "from": "xhr-send@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/xhr-send/-/xhr-send-1.0.0.tgz" - }, - "xhr-status": { - "version": "1.0.0", - "from": "xhr-status@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/xhr-status/-/xhr-status-1.0.0.tgz" - }, - "xml-crypto": { - "version": "0.8.5", - "from": "xml-crypto@>=0.8.0 <0.9.0", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.8.5.tgz", - "dependencies": { - "xmldom": { - "version": "0.1.19", - "from": "xmldom@0.1.19", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.19.tgz" - } - } - }, - "xml-encryption": { - "version": "0.7.4", - "from": "xml-encryption@>=0.7.0 <0.8.0", - "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-0.7.4.tgz", - "dependencies": { - "async": { - "version": "0.2.10", - "from": "async@>=0.2.7 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - } - } - }, - "xml2js": { - "version": "0.2.0", - "from": "xml2js@0.2.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.0.tgz" - }, - "xmlbuilder": { - "version": "2.6.2", - "from": "xmlbuilder@2.6.2", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.6.2.tgz", - "dependencies": { - "lodash": { - "version": "3.5.0", - "from": "lodash@>=3.5.0 <3.6.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.5.0.tgz" - } - } - }, - "xmldom": { - "version": "0.1.27", - "from": "xmldom@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz" - }, - "xpath": { - "version": "0.0.5", - "from": "xpath@0.0.5", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.5.tgz" - }, - "xpath.js": { - "version": "1.0.7", - "from": "xpath.js@>=0.0.3", - "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.0.7.tgz" - }, - "xtend": { - "version": "4.0.1", - "from": "xtend@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" - }, - "yallist": { - "version": "2.0.0", - "from": "yallist@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.0.0.tgz" - }, - "yargs": { - "version": "3.5.4", - "from": "yargs@>=3.5.4 <3.6.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz" - }, - "zip-stream": { - "version": "0.3.7", - "from": "zip-stream@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-0.3.7.tgz", - "dependencies": { - "lodash": { - "version": "2.4.2", - "from": "lodash@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz" - } - } - } - } + "version": "0.1.4" } diff --git a/services/web/package.json b/services/web/package.json index 7fff540f1e..de95b0a15a 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -42,7 +42,6 @@ "mongojs": "2.4.0", "mongoose": "4.1.0", "multer": "^0.1.8", - "node-uuid": "1.4.1", "nodemailer": "2.1.0", "nodemailer-sendgrid-transport": "^0.2.0", "nodemailer-ses-transport": "^1.3.0", @@ -50,7 +49,6 @@ "passport": "^0.3.2", "passport-ldapauth": "^0.6.0", "passport-local": "^1.0.0", - "redback": "0.4.0", "redis": "0.10.1", "redis-sharelatex": "0.0.9", "request": "^2.69.0", @@ -64,14 +62,19 @@ "underscore": "1.6.0", "v8-profiler": "^5.2.3", "xml2js": "0.2.0", - "passport-saml": "^0.15.0" + "passport-saml": "^0.15.0", + "pug": "^2.0.0-beta6", + "uuid": "^3.0.1", + "rolling-rate-limiter": "git+https://github.com/ShaneKilkelly/rolling-rate-limiter.git#master" }, "devDependencies": { + "autoprefixer": "^6.6.1", "bunyan": "0.22.1", - "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master", "chai": "", "chai-spies": "", "grunt": "0.4.1", + "clean-css": "^3.4.18", + "es6-promise": "^4.0.5", "grunt-available-tasks": "0.4.1", "grunt-bunyan": "0.5.0", "grunt-contrib-clean": "0.5.0", @@ -80,7 +83,6 @@ "grunt-contrib-requirejs": "0.4.1", "grunt-contrib-watch": "^1.0.0", "grunt-env": "0.4.4", - "clean-css": "^3.4.18", "grunt-exec": "^0.4.7", "grunt-execute": "^0.2.2", "grunt-file-append": "0.0.6", @@ -88,9 +90,11 @@ "grunt-mocha-test": "0.9.0", "grunt-newer": "^1.2.0", "grunt-parallel": "^0.5.1", + "grunt-postcss": "^0.8.0", "grunt-sed": "^0.1.1", "sandboxed-module": "0.2.0", "sinon": "", - "timekeeper": "" + "timekeeper": "", + "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master" } } diff --git a/services/web/public/coffee/directives/asyncForm.coffee b/services/web/public/coffee/directives/asyncForm.coffee index 2c6345d878..d9ca11231b 100644 --- a/services/web/public/coffee/directives/asyncForm.coffee +++ b/services/web/public/coffee/directives/asyncForm.coffee @@ -33,6 +33,11 @@ define [ response.success = true response.error = false + onSuccessHandler = scope[attrs.onSuccess] + if onSuccessHandler + onSuccessHandler(data, status, headers, config) + return + if data.redir? ga('send', 'event', formName, 'success') window.location = data.redir @@ -50,6 +55,12 @@ define [ scope[attrs.name].inflight = false response.success = false response.error = true + + onErrorHandler = scope[attrs.onError] + if onErrorHandler + onErrorHandler(data, status, headers, config) + return + if status == 403 # Forbidden response.message = text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies." diff --git a/services/web/public/coffee/directives/expandableTextArea.coffee b/services/web/public/coffee/directives/expandableTextArea.coffee new file mode 100644 index 0000000000..d0bfa9cb99 --- /dev/null +++ b/services/web/public/coffee/directives/expandableTextArea.coffee @@ -0,0 +1,17 @@ +define [ + "base" +], (App) -> + App.directive "expandableTextArea", () -> + restrict: "A" + link: (scope, el) -> + resetHeight = () -> + curHeight = el.outerHeight() + fitHeight = el.prop("scrollHeight") + + if fitHeight > curHeight and el.val() != "" + scope.$emit "expandable-text-area:resize" + el.css("height", fitHeight) + + scope.$watch (() -> el.val()), resetHeight + + \ No newline at end of file diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 370f144ee1..08531f993a 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -28,6 +28,7 @@ define [ "directives/onEnter" "directives/stopPropagation" "directives/rightClick" + "directives/expandableTextArea" "services/queued-http" "filters/formatDate" "main/event" @@ -57,9 +58,6 @@ define [ else this.$originalApply(fn); - if window.location.search.match /tcon=true/ # track changes on - $scope.trackChangesFeatureFlag = true - $scope.state = { loading: true load_progress: 40 @@ -70,7 +68,7 @@ define [ view: "editor" chatOpen: false pdfLayout: 'sideBySide' - reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}") and $scope.trackChangesFeatureFlag + reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}") showCodeCheckerOnboarding: !window.userSettings.syntaxValidation? } $scope.user = window.user @@ -86,6 +84,7 @@ define [ ide.toggleReviewPanel = $scope.toggleReviewPanel = () -> $scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen + event_tracking.sendMB "rp-toggle-panel", { value : $scope.ui.reviewPanelOpen } $scope.$watch "ui.reviewPanelOpen", (value) -> if value? diff --git a/services/web/public/coffee/ide/chat/services/chatMessages.coffee b/services/web/public/coffee/ide/chat/services/chatMessages.coffee index 1205a51267..6c33f95ea7 100644 --- a/services/web/public/coffee/ide/chat/services/chatMessages.coffee +++ b/services/web/public/coffee/ide/chat/services/chatMessages.coffee @@ -1,5 +1,6 @@ define [ "base" + "libs/md5" ], (App) -> App.factory "chatMessages", ($http, ide) -> MESSAGES_URL = "/project/#{ide.project_id}/messages" @@ -72,7 +73,7 @@ define [ firstMessage.contents.unshift message.content else chat.state.messages.unshift({ - user: message.user + user: formatUser(message.user) timestamp: message.timestamp contents: [message.content] }) @@ -93,9 +94,14 @@ define [ lastMessage.contents.push message.content else chat.state.messages.push({ - user: message.user + user: formatUser(message.user) timestamp: message.timestamp contents: [message.content] }) + + formatUser = (user) -> + hash = CryptoJS.MD5(user.email.toLowerCase()) + user.gravatar_url = "//www.gravatar.com/avatar/#{hash}" + return user return chat \ No newline at end of file diff --git a/services/web/public/coffee/ide/connection/ConnectionManager.coffee b/services/web/public/coffee/ide/connection/ConnectionManager.coffee index afbc656a02..a8696fc999 100644 --- a/services/web/public/coffee/ide/connection/ConnectionManager.coffee +++ b/services/web/public/coffee/ide/connection/ConnectionManager.coffee @@ -1,3 +1,4 @@ + define [], () -> ONEHOUR = 1000 * 60 * 60 class ConnectionManager diff --git a/services/web/public/coffee/ide/directives/layout.coffee b/services/web/public/coffee/ide/directives/layout.coffee index 7fc459a539..f20a37b342 100644 --- a/services/web/public/coffee/ide/directives/layout.coffee +++ b/services/web/public/coffee/ide/directives/layout.coffee @@ -117,5 +117,10 @@ define [ element.layout().hide("east") else element.layout().show("east") + + post: (scope, element, attrs) -> + name = attrs.layout + state = element.layout().readState() + scope.$broadcast "layout:#{name}:linked", state } ] diff --git a/services/web/public/coffee/ide/editor/Document.coffee b/services/web/public/coffee/ide/editor/Document.coffee index 17b1d9e28f..1de01b5467 100644 --- a/services/web/public/coffee/ide/editor/Document.coffee +++ b/services/web/public/coffee/ide/editor/Document.coffee @@ -1,7 +1,8 @@ define [ "utils/EventEmitter" "ide/editor/ShareJsDoc" -], (EventEmitter, ShareJsDoc) -> + "ide/review-panel/RangesTracker" +], (EventEmitter, ShareJsDoc, RangesTracker) -> class Document extends EventEmitter @getDocument: (ide, doc_id) -> @openDocs ||= {} @@ -40,6 +41,8 @@ define [ editorDoc = @ace?.getSession().getDocument() editorDoc?.off "change", @_checkConsistency @ide.$scope.$emit 'document:closed', @doc + + submitOp: (args...) -> @doc?.submitOp(args...) _checkConsistency: () -> # We've been seeing a lot of errors when I think there shouldn't be @@ -77,6 +80,15 @@ define [ hasBufferedOps: () -> @doc?.hasBufferedOps() + + setTrackingChanges: (track_changes) -> + @doc.track_changes = track_changes + + getTrackingChanges: () -> + !!@doc.track_changes + + setTrackChangesIdSeeds: (id_seeds) -> + @doc.track_changes_id_seeds = id_seeds _bindToSocketEvents: () -> @_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update) @@ -239,16 +251,18 @@ define [ _joinDoc: (callback = (error) ->) -> if @doc? - @ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) => + @ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates, ranges) => return callback(error) if error? @joined = true @doc.catchUp( updates ) + @_catchUpRanges( ranges?.changes, ranges?.comments ) callback() else - @ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) => + @ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version, updates, ranges) => return callback(error) if error? @joined = true @doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket + @ranges = new RangesTracker(ranges?.changes, ranges?.comments) @_bindToShareJsDocEvents() callback() @@ -307,6 +321,10 @@ define [ inflightOp: inflightOp, pendingOp: pendingOp v: version + @doc.on "change", (ops, oldSnapshot, msg) => + @_applyOpsToRanges(ops, oldSnapshot, msg) + @doc.on "flipped_pending_to_inflight", () => + @trigger "flipped_pending_to_inflight" _onError: (error, meta = {}) -> meta.doc_id = @doc_id @@ -319,3 +337,34 @@ define [ # the disconnect event, which means we try to leaveDoc when the connection comes back. # This could intefere with the new connection of a new instance of this document. @_cleanUp() + + _applyOpsToRanges: (ops = [], oldSnapshot, msg) -> + track_changes_as = null + remote_op = msg? + if msg?.meta?.tc? + old_id_seed = @ranges.getIdSeed() + @ranges.setIdSeed(msg.meta.tc) + if remote_op and msg.meta?.tc + track_changes_as = msg.meta.user_id + else if !remote_op and @track_changes_as? + track_changes_as = @track_changes_as + @ranges.track_changes = track_changes_as? + for op in ops + @ranges.applyOp op, { user_id: track_changes_as } + if old_id_seed? + @ranges.setIdSeed(old_id_seed) + + _catchUpRanges: (changes = [], comments = []) -> + # We've just been given the current server's ranges, but need to apply any local ops we have. + # Reset to the server state then apply our local ops again. + @ranges.emit "clear" + @ranges.changes = changes + @ranges.comments = comments + @ranges.track_changes = @doc.track_changes + for op in @doc.getInflightOp() or [] + @ranges.setIdSeed(@doc.track_changes_id_seeds.inflight) + @ranges.applyOp(op, { user_id: @track_changes_as }) + for op in @doc.getPendingOp() or [] + @ranges.setIdSeed(@doc.track_changes_id_seeds.pending) + @ranges.applyOp(op, { user_id: @track_changes_as }) + @ranges.emit "redraw" diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index eb063c9c6a..e01a12cb8b 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -10,6 +10,8 @@ define [ open_doc_id: null open_doc_name: null opening: true + trackChanges: false + wantTrackChanges: false } @$scope.$on "entity:selected", (event, entity) => @@ -31,6 +33,14 @@ define [ @$scope.$on "flush-changes", () => Document.flushAll() + + @$scope.$watch "editor.wantTrackChanges", (value) => + return if !value? + @_syncTrackChangesState(@$scope.editor.sharejs_doc) + + @$scope.$watch "project.features.trackChanges", (trackChangesFeature) => + return if !trackChangesFeature? + @$scope.editor.wantTrackChanges = window.trackChangesEnabled and trackChangesFeature autoOpenDoc: () -> open_doc_id = @@ -83,6 +93,8 @@ define [ "Sorry, something went wrong opening this document. Please try again." ) return + + @_syncTrackChangesState(sharejs_doc) @$scope.$broadcast "doc:opened" @@ -144,3 +156,26 @@ define [ stopIgnoringExternalUpdates: () -> @_ignoreExternalUpdates = false + + _syncTimeout: null + _syncTrackChangesState: (doc) -> + return if !doc? + + if @_syncTimeout? + clearTimeout @_syncTimeout + @_syncTimeout = null + + want = @$scope.editor.wantTrackChanges + have = doc.getTrackingChanges() + if want == have + @$scope.editor.trackChanges = want + return + + do tryToggle = () => + saved = !doc.getInflightOp()? and !doc.getPendingOp()? + if saved + doc.setTrackingChanges(want) + @$scope.$apply () => + @$scope.editor.trackChanges = want + else + @_syncTimeout = setTimeout tryToggle, 100 diff --git a/services/web/public/coffee/ide/editor/ShareJsDoc.coffee b/services/web/public/coffee/ide/editor/ShareJsDoc.coffee index 5d8b4ef11a..f580c56f77 100644 --- a/services/web/public/coffee/ide/editor/ShareJsDoc.coffee +++ b/services/web/public/coffee/ide/editor/ShareJsDoc.coffee @@ -9,21 +9,9 @@ define [ # Dencode any binary bits of data # See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html @type = "text" - docLines = for line in docLines - if line.text? - @type = "json" - line.text = decodeURIComponent(escape(line.text)) - else - @type = "text" - line = decodeURIComponent(escape(line)) - line - - if @type == "text" - snapshot = docLines.join("\n") - else if @type == "json" - snapshot = { lines: docLines } - else - throw new Error("Unknown type: #{@type}") + docLines = (decodeURIComponent(escape(line)) for line in docLines) + snapshot = docLines.join("\n") + @track_changes = false @connection = { send: (update) => @@ -34,6 +22,9 @@ define [ if window.dropUpdates? and Math.random() < window.dropUpdates sl_console.log "Simulating a lost update", update return + if @track_changes + update.meta ?= {} + update.meta.tc = @track_changes_id_seeds.inflight @socket.emit "applyOtUpdate", @doc_id, update, (error) => return @_handleError(error) if error? state: "ok" @@ -43,8 +34,8 @@ define [ @_doc = new ShareJs.Doc @connection, @doc_id, type: @type @_doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY) - @_doc.on "change", () => - @trigger "change" + @_doc.on "change", (args...) => + @trigger "change", args... @_doc.on "acknowledge", () => @lastAcked = new Date() # note time of last ack from server for an op we sent @trigger "acknowledge" @@ -53,6 +44,8 @@ define [ # ops as quickly as possible for low latency. @_doc.setFlushDelay(0) @trigger "remoteop", args... + @_doc.on "flipped_pending_to_inflight", () => + @trigger "flipped_pending_to_inflight" @_doc.on "error", (e) => @_handleError(e) @@ -70,6 +63,7 @@ define [ @_doc._onMessage message catch error # Version mismatches are thrown as errors + console.log error @_handleError(error) if message?.meta?.type == "external" @@ -125,7 +119,7 @@ define [ attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength) detachFromAce: () -> @_doc.detach_ace?() - + INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack WAIT_FOR_CONNECTION_TIMEOUT: 500 # If we're waiting for the project to join, try again in 0.5 seconds _startInflightOpTimeout: (update) -> diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index b17f3a1268..9205cb7b87 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -13,7 +13,7 @@ define [ EditSession = ace.require('ace/edit_session').EditSession ModeList = ace.require('ace/ext/modelist') - # set the path for ace workers if using a CDN (from editor.jade) + # set the path for ace workers if using a CDN (from editor.pug) if window.aceWorkerPath != "" syntaxValidationEnabled = true ace.config.set('workerPath', "#{window.aceWorkerPath}") @@ -54,10 +54,10 @@ define [ syntaxValidation: "=" reviewPanel: "=" eventsBridge: "=" - trackNewChanges: "=" + trackChanges: "=" trackChangesEnabled: "=" - changesTracker: "=" docId: "=" + rendererData: "=" } link: (scope, element, attrs) -> # Don't freak out if we're already in an apply callback @@ -167,10 +167,22 @@ define [ if arg == "/" ace.require("ace/ext/searchbox").Search(editor, true) + getCursorScreenPosition = () -> + session = editor.getSession() + cursorPosition = session.selection.getCursor() + sessionPos = session.documentToScreenPosition(cursorPosition.row, cursorPosition.column) + screenPos = editor.renderer.textToScreenCoordinates(sessionPos.row, sessionPos.column) + return sessionPos.row * editor.renderer.lineHeight - session.getScrollTop() + if attrs.resizeOn? for event in attrs.resizeOn.split(",") scope.$on event, () -> + previousScreenPosition = getCursorScreenPosition() editor.resize() + # Put cursor back to same vertical position on screen + newScreenPosition = getCursorScreenPosition() + session = editor.getSession() + session.setScrollTop(session.getScrollTop() + newScreenPosition - previousScreenPosition) scope.$watch "theme", (value) -> editor.setTheme("ace/theme/#{value}") @@ -279,7 +291,7 @@ define [ session.setUseWrapMode(true) # use syntax validation only when explicitly set - if scope.syntaxValidation? and syntaxValidationEnabled + if scope.syntaxValidation? and syntaxValidationEnabled and !scope.fileName.match(/\.bib$/) session.setOption("useWorker", scope.syntaxValidation); # now attach session to editor @@ -318,6 +330,14 @@ define [ doc = session.getDocument() doc.off "change", onChange + + editor.renderer.on "changeCharacterSize", () -> + scope.$apply () -> + scope.rendererData.lineHeight = editor.renderer.lineHeight + + scope.$watch "rendererData", (rendererData) -> + if rendererData? + rendererData.lineHeight = editor.renderer.lineHeight template: """
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee index b8a3d43819..119aa471e1 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee @@ -37,6 +37,9 @@ define [ @gotoOffset(offset) , 10 # Hack: Must happen after @gotoStoredPosition + @$scope.$on "#{@$scope.name}:clearSelection", (e) => + @editor.selection.clearSelection() + storeScrollTopPosition: (session) -> if @doc_id? docPosition = @localStorage("doc.position.#{@doc_id}") || {} diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee index 470909a9ed..5014559562 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee @@ -12,6 +12,9 @@ define [ class HighlightedWordManager constructor: (@editor) -> + @reset() + + reset: () -> @highlights = rows: [] addHighlight: (highlight) -> @@ -21,7 +24,7 @@ define [ highlight.row, highlight.column, highlight.row, highlight.column + highlight.word.length ) - highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", null, true + highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", 'text', false @highlights.rows[highlight.row] ||= [] @highlights.rows[highlight.row].push highlight diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee index e84ce1d785..759b1d2b70 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee @@ -22,6 +22,10 @@ define [ @closeContextMenu() @editor.on "changeSession", (e) => + @highlightedWordManager.reset() + if @inProgressRequest? + @inProgressRequest.abort() + if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" @runSpellCheckSoon(200) @@ -183,7 +187,8 @@ define [ if not words.length displayResult highlights else - @apiRequest "/check", {language: language, words: words}, (error, result) => + @inProgressRequest = @apiRequest "/check", {language: language, words: words}, (error, result) => + delete @inProgressRequest if error? or !result? or !result.misspellings? return null mispelled = [] @@ -240,4 +245,4 @@ define [ callback null, data error: (xhr, status, error) -> callback error - $.ajax options + return $.ajax options diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee index af9815b2cb..ed15da2958 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee @@ -10,18 +10,18 @@ define [ constructor: (@$scope, @editor, @element) -> window.trackChangesManager ?= @ - @$scope.$watch "changesTracker", (changesTracker) => - return if !changesTracker? - @disconnectFromChangesTracker() - @changesTracker = changesTracker - @connectToChangesTracker() - - @$scope.$watch "trackNewChanges", (track_new_changes) => - return if !track_new_changes? - @changesTracker?.track_changes = track_new_changes + @$scope.$watch "trackChanges", (track_changes) => + return if !track_changes? + @setTrackChanges(track_changes) - @$scope.$on "comment:add", (e, comment) => - @addCommentToSelection(comment) + @$scope.$watch "sharejsDoc", (doc) => + return if !doc? + @disconnectFromRangesTracker() + @rangesTracker = doc.ranges + @connectToRangesTracker() + + @$scope.$on "comment:add", (e, thread_id) => + @addCommentToSelection(thread_id) @$scope.$on "comment:select_line", (e) => @selectLineIfNoSelection() @@ -35,11 +35,11 @@ define [ @$scope.$on "comment:remove", (e, comment_id) => @removeCommentId(comment_id) - @$scope.$on "comment:resolve", (e, comment_id, user_id) => - @resolveCommentId(comment_id, user_id) + @$scope.$on "comment:resolve_threads", (e, thread_ids) => + @resolveCommentByThreadIds(thread_ids) - @$scope.$on "comment:unresolve", (e, comment_id) => - @unresolveCommentId(comment_id) + @$scope.$on "comment:unresolve_thread", (e, thread_id) => + @unresolveCommentByThreadId(thread_id) @$scope.$on "review-panel:recalculate-screen-positions", () => @recalculateReviewEntriesScreenPositions() @@ -58,48 +58,16 @@ define [ onResize = () => @recalculateReviewEntriesScreenPositions() - onChange = (e) => - if !@editor.initing - # This change is trigger by a sharejs 'change' event, which is before the - # sharejs 'remoteop' event. So wait until the next event loop when the 'remoteop' - # will have fired, before we decide if it was a remote op. - setTimeout () => - if @nextUpdateMetaData? - user_id = @nextUpdateMetaData.user_id - # The remote op may have contained multiple atomic ops, each of which is an Ace - # 'change' event (i.e. bulk commenting out of lines is a single remote op - # but gives us one event for each % inserted). These all come in a single event loop - # though, so wait until the next one before clearing the metadata. - setTimeout () => - @nextUpdateMetaData = null - else - user_id = window.user.id - - was_tracking = @changesTracker.track_changes - if @dont_track_next_update - @changesTracker.track_changes = false - @dont_track_next_update = false - @applyChange(e, { user_id }) - @changesTracker.track_changes = was_tracking - - # TODO: Just for debugging, remove before going live. - setTimeout () => - @checkMapping() - , 100 - onChangeSession = (e) => - e.oldSession?.getDocument().off "change", onChange - e.session.getDocument().on "change", onChange + @clearAnnotations() @redrawAnnotations() bindToAce = () => - @editor.getSession().getDocument().on "change", onChange @editor.on "changeSelection", onChangeSelection @editor.on "changeSession", onChangeSession @editor.renderer.on "resize", onResize unbindFromAce = () => - @editor.getSession().getDocument().off "change", onChange @editor.off "changeSelection", onChangeSelection @editor.off "changeSession", onChangeSession @editor.renderer.off "resize", onResize @@ -111,94 +79,117 @@ define [ else unbindFromAce() - disconnectFromChangesTracker: () -> + disconnectFromRangesTracker: () -> @changeIdToMarkerIdMap = {} - if @changesTracker? - @changesTracker.off "insert:added" - @changesTracker.off "insert:removed" - @changesTracker.off "delete:added" - @changesTracker.off "delete:removed" - @changesTracker.off "changes:moved" - @changesTracker.off "comment:added" - @changesTracker.off "comment:moved" - @changesTracker.off "comment:removed" - @changesTracker.off "comment:resolved" - @changesTracker.off "comment:unresolved" - - connectToChangesTracker: () -> - @changesTracker.track_changes = @$scope.trackNewChanges - - @changesTracker.on "insert:added", (change) => - sl_console.log "[insert:added]", change - @_onInsertAdded(change) - @changesTracker.on "insert:removed", (change) => - sl_console.log "[insert:removed]", change - @_onInsertRemoved(change) - @changesTracker.on "delete:added", (change) => - sl_console.log "[delete:added]", change - @_onDeleteAdded(change) - @changesTracker.on "delete:removed", (change) => - sl_console.log "[delete:removed]", change - @_onDeleteRemoved(change) - @changesTracker.on "changes:moved", (changes) => - sl_console.log "[changes:moved]", changes - @_onChangesMoved(changes) + if @rangesTracker? + @rangesTracker.off "insert:added" + @rangesTracker.off "insert:removed" + @rangesTracker.off "delete:added" + @rangesTracker.off "delete:removed" + @rangesTracker.off "changes:moved" + @rangesTracker.off "comment:added" + @rangesTracker.off "comment:moved" + @rangesTracker.off "comment:removed" - @changesTracker.on "comment:added", (comment) => - sl_console.log "[comment:added]", comment - @_onCommentAdded(comment) - @changesTracker.on "comment:moved", (comment) => - sl_console.log "[comment:moved]", comment - @_onCommentMoved(comment) - @changesTracker.on "comment:removed", (comment) => - sl_console.log "[comment:removed]", comment - @_onCommentRemoved(comment) - @changesTracker.on "comment:resolved", (comment) => - sl_console.log "[comment:resolved]", comment - @_onCommentRemoved(comment) - @changesTracker.on "comment:unresolved", (comment) => - sl_console.log "[comment:unresolved]", comment - @_onCommentAdded(comment) + setTrackChanges: (value) -> + if value + @$scope.sharejsDoc?.track_changes_as = window.user.id or "anonymous" + else + @$scope.sharejsDoc?.track_changes_as = null + + connectToRangesTracker: () -> + @setTrackChanges(@$scope.trackChanges) + # Add a timeout because on remote ops, we get these notifications before + # ace has updated + @rangesTracker.on "insert:added", (change) => + sl_console.log "[insert:added]", change + setTimeout () => + @_onInsertAdded(change) + @broadcastChange() + @rangesTracker.on "insert:removed", (change) => + sl_console.log "[insert:removed]", change + setTimeout () => + @_onInsertRemoved(change) + @broadcastChange() + @rangesTracker.on "delete:added", (change) => + sl_console.log "[delete:added]", change + setTimeout () => + @_onDeleteAdded(change) + @broadcastChange() + @rangesTracker.on "delete:removed", (change) => + sl_console.log "[delete:removed]", change + setTimeout () => + @_onDeleteRemoved(change) + @broadcastChange() + @rangesTracker.on "changes:moved", (changes) => + sl_console.log "[changes:moved]", changes + setTimeout () => + @_onChangesMoved(changes) + @broadcastChange() + + @rangesTracker.on "comment:added", (comment) => + sl_console.log "[comment:added]", comment + setTimeout () => + @_onCommentAdded(comment) + @broadcastChange() + @rangesTracker.on "comment:moved", (comment) => + sl_console.log "[comment:moved]", comment + setTimeout () => + @_onCommentMoved(comment) + @broadcastChange() + @rangesTracker.on "comment:removed", (comment) => + sl_console.log "[comment:removed]", comment + setTimeout () => + @_onCommentRemoved(comment) + @broadcastChange() + + @rangesTracker.on "clear", () => + @clearAnnotations() + @rangesTracker.on "redraw", () => + @redrawAnnotations() + + clearAnnotations: () -> + session = @editor.getSession() + for change_id, markers of @changeIdToMarkerIdMap + for marker_name, marker_id of markers + session.removeMarker marker_id + @changeIdToMarkerIdMap = {} + redrawAnnotations: () -> - for change in @changesTracker.changes + for change in @rangesTracker.changes if change.op.i? @_onInsertAdded(change) else if change.op.d? @_onDeleteAdded(change) - for comment in @changesTracker.comments + for comment in @rangesTracker.comments @_onCommentAdded(comment) + + @broadcastChange() - addComment: (offset, length, content) -> - @changesTracker.addComment offset, length, { - thread: [{ - content: content - user_id: window.user_id - ts: new Date() - }] - } + addComment: (offset, content, thread_id) -> + op = { c: content, p: offset, t: thread_id } + # @rangesTracker.applyOp op # Will apply via sharejs + @$scope.sharejsDoc.submitOp op - addCommentToSelection: (content) -> + addCommentToSelection: (thread_id) -> range = @editor.getSelectionRange() + content = @editor.getSelectedText() offset = @_aceRangeToShareJs(range.start) - end = @_aceRangeToShareJs(range.end) - length = end - offset - @addComment(offset, length, content) + @addComment(offset, content, thread_id) selectLineIfNoSelection: () -> if @editor.selection.isEmpty() @editor.selection.selectLine() acceptChangeId: (change_id) -> - @changesTracker.removeChangeId(change_id) + @rangesTracker.removeChangeId(change_id) rejectChangeId: (change_id) -> - change = @changesTracker.getChange(change_id) + change = @rangesTracker.getChange(change_id) return if !change? - @changesTracker.removeChangeId(change_id) - @dont_track_next_update = true session = @editor.getSession() if change.op.d? content = change.op.d @@ -215,17 +206,25 @@ define [ throw new Error("unknown change: #{JSON.stringify(change)}") removeCommentId: (comment_id) -> - @changesTracker.removeCommentId(comment_id) + @rangesTracker.removeCommentId(comment_id) - resolveCommentId: (comment_id, user_id) -> - @changesTracker.resolveCommentId(comment_id, { - user_id, ts: new Date() - }) + resolveCommentByThreadIds: (thread_ids) -> + resolve_ids = {} + for id in thread_ids + resolve_ids[id] = true + for comment in @rangesTracker?.comments or [] + if resolve_ids[comment.op.t] + @_onCommentRemoved(comment) + @broadcastChange() - unresolveCommentId: (comment_id) -> - @changesTracker.unresolveCommentId(comment_id) + unresolveCommentByThreadId: (thread_id) -> + for comment in @rangesTracker?.comments or [] + if comment.op.t == thread_id + @_onCommentAdded(comment) + @broadcastChange() checkMapping: () -> + # TODO: reintroduce this check session = @editor.getSession() # Make a copy of session.getMarkers() so we can modify it @@ -234,7 +233,7 @@ define [ markers[marker_id] = marker expected_markers = [] - for change in @changesTracker.changes + for change in @rangesTracker.changes if @changeIdToMarkerIdMap[change.id]? op = change.op {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id] @@ -246,11 +245,11 @@ define [ expected_markers.push { marker_id: background_marker_id, start, end } expected_markers.push { marker_id: callout_marker_id, start, end: start } - for comment in @changesTracker.comments + for comment in @rangesTracker.comments if @changeIdToMarkerIdMap[comment.id]? {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id] - start = @_shareJsOffsetToAcePosition(comment.offset) - end = @_shareJsOffsetToAcePosition(comment.offset + comment.length) + start = @_shareJsOffsetToAcePosition(comment.op.p) + end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length) expected_markers.push { marker_id: background_marker_id, start, end } expected_markers.push { marker_id: callout_marker_id, start, end: start } @@ -267,16 +266,13 @@ define [ if marker.clazz.match("track-changes") console.error "Orphaned ace marker", marker - applyChange: (delta, metadata) -> - op = @_aceChangeToShareJs(delta) - @changesTracker.applyOp(op, metadata) - updateFocus: () -> selection = @editor.getSelectionRange() - cursor_offset = @_aceRangeToShareJs(selection.start) + selection_start = @_aceRangeToShareJs(selection.start) + selection_end = @_aceRangeToShareJs(selection.end) entries = @_getCurrentDocEntries() - selection = !(selection.start.column == selection.end.column and selection.start.row == selection.end.row) - @$scope.$emit "editor:focus:changed", cursor_offset, selection + is_selection = (selection_start != selection_end) + @$scope.$emit "editor:focus:changed", selection_start, selection_end, is_selection broadcastChange: () -> @$scope.$emit "editor:track-changes:changed", @$scope.docId @@ -330,7 +326,6 @@ define [ background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text" callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout") @changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id } - @broadcastChange() _onDeleteAdded: (change) -> position = @_shareJsOffsetToAcePosition(change.op.p) @@ -345,7 +340,6 @@ define [ callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout") @changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id } - @broadcastChange() _onInsertRemoved: (change) -> {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id] @@ -353,7 +347,6 @@ define [ session = @editor.getSession() session.removeMarker background_marker_id session.removeMarker callout_marker_id - @broadcastChange() _onDeleteRemoved: (change) -> {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id] @@ -361,20 +354,21 @@ define [ session = @editor.getSession() session.removeMarker background_marker_id session.removeMarker callout_marker_id - @broadcastChange() _onCommentAdded: (comment) -> + if @rangesTracker.resolvedThreadIds[comment.op.t] + # Comment is resolved so shouldn't be displayed. + return if !@changeIdToMarkerIdMap[comment.id]? # Only create new markers if they don't already exist - start = @_shareJsOffsetToAcePosition(comment.offset) - end = @_shareJsOffsetToAcePosition(comment.offset + comment.length) + start = @_shareJsOffsetToAcePosition(comment.op.p) + end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length) session = @editor.getSession() doc = session.getDocument() background_range = new Range(start.row, start.column, end.row, end.column) background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text" callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout") @changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id } - @broadcastChange() _onCommentRemoved: (comment) -> if @changeIdToMarkerIdMap[comment.id]? @@ -384,7 +378,6 @@ define [ session = @editor.getSession() session.removeMarker background_marker_id session.removeMarker callout_marker_id - @broadcastChange() _aceRangeToShareJs: (range) -> lines = @editor.getSession().getDocument().getLines 0, range.row @@ -409,25 +402,23 @@ define [ end = start @_updateMarker(change.id, start, end) @editor.renderer.updateBackMarkers() - @broadcastChange() _onCommentMoved: (comment) -> - start = @_shareJsOffsetToAcePosition(comment.offset) - end = @_shareJsOffsetToAcePosition(comment.offset + comment.length) + start = @_shareJsOffsetToAcePosition(comment.op.p) + end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length) @_updateMarker(comment.id, start, end) @editor.renderer.updateBackMarkers() - @broadcastChange() _updateMarker: (change_id, start, end) -> return if !@changeIdToMarkerIdMap[change_id]? session = @editor.getSession() markers = session.getMarkers() {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id] - if background_marker_id? + if background_marker_id? and markers[background_marker_id]? background_marker = markers[background_marker_id] background_marker.range.start = start background_marker.range.end = end - if callout_marker_id? + if callout_marker_id? and markers[callout_marker_id]? callout_marker = markers[callout_marker_id] callout_marker.range.start = start callout_marker.range.end = start diff --git a/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee b/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee index d25baf89d5..aca3560b49 100644 --- a/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee +++ b/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee @@ -71,7 +71,7 @@ class Doc # Its important that these event handlers are called with oldSnapshot. # The reason is that the OT type APIs might need to access the snapshots to # determine information about the received op. - @emit 'change', docOp, oldSnapshot + @emit 'change', docOp, oldSnapshot, msg @emit 'remoteop', docOp, oldSnapshot, msg if isRemote _connectionStateChanged: (state, data) -> @@ -266,6 +266,8 @@ class Doc @pendingOp = null @pendingCallbacks = [] + @emit "flipped_pending_to_inflight" + #console.log "SENDING OP TO SERVER", @inflightOp, @version @connection.send {doc:@name, op:@inflightOp, v:@version} @@ -274,6 +276,7 @@ class Doc submitOp: (op, callback) -> op = @type.normalize(op) if @type.normalize? + oldSnapshot = @snapshot # If this throws an exception, no changes should have been made to the doc @snapshot = @type.apply @snapshot, op @@ -284,7 +287,7 @@ class Doc @pendingCallbacks.push callback if callback - @emit 'change', op + @emit 'change', op, oldSnapshot @delayedFlush() diff --git a/services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee b/services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee index 96243ceffb..274b6019c5 100644 --- a/services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee +++ b/services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee @@ -28,5 +28,5 @@ text.api = for component in op if component.i != undefined @emit 'insert', component.p, component.i - else + else if component.d != undefined @emit 'delete', component.p, component.d diff --git a/services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee b/services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee index c64b4dfa68..2a3b79997d 100644 --- a/services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee +++ b/services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee @@ -31,7 +31,8 @@ checkValidComponent = (c) -> i_type = typeof c.i d_type = typeof c.d - throw new Error 'component needs an i or d field' unless (i_type == 'string') ^ (d_type == 'string') + c_type = typeof c.c + throw new Error 'component needs an i, d or c field' unless (i_type == 'string') ^ (d_type == 'string') ^ (c_type == 'string') throw new Error 'position cannot be negative' unless c.p >= 0 @@ -44,11 +45,15 @@ text.apply = (snapshot, op) -> for component in op if component.i? snapshot = strInject snapshot, component.p, component.i - else + else if component.d? deleted = snapshot[component.p...(component.p + component.d.length)] throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..] - + else if component.c? + comment = snapshot[component.p...(component.p + component.c.length)] + throw new Error "Comment component '#{component.c}' does not match commented text '#{comment}'" unless component.c == comment + else + throw new Error "Unknown op type" snapshot @@ -112,7 +117,7 @@ transformPosition = (pos, c, insertAfter) -> pos + c.i.length else pos - else + else if c.d? # I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length)) # but I think its harder to read that way, and it compiles using ternary operators anyway # so its no slower written like this. @@ -122,6 +127,10 @@ transformPosition = (pos, c, insertAfter) -> c.p else pos - c.d.length + else if c.c? + pos + else + throw new Error("unknown op type") # Helper method to transform a cursor position as a result of an op. # @@ -143,7 +152,7 @@ text._tc = transformComponent = (dest, c, otherC, side) -> if c.i? append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')} - else # Delete + else if c.d? # Delete if otherC.i? # delete vs insert s = c.d if c.p < otherC.p @@ -152,7 +161,7 @@ text._tc = transformComponent = (dest, c, otherC, side) -> if s != '' append dest, {d:s, p:c.p + otherC.i.length} - else # Delete vs delete + else if otherC.d? # Delete vs delete if c.p >= otherC.p + otherC.d.length append dest, {d:c.d, p:c.p - otherC.d.length} else if c.p + c.d.length <= otherC.p @@ -177,6 +186,51 @@ text._tc = transformComponent = (dest, c, otherC, side) -> # This could be rewritten similarly to insert v delete, above. newC.p = transformPosition newC.p, otherC append dest, newC + + else if otherC.c? + append dest, c + + else + throw new Error("unknown op type") + + else if c.c? # Comment + if otherC.i? + if c.p < otherC.p < c.p + c.c.length + offset = otherC.p - c.p + new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...]) + append dest, {c:new_c, p:c.p, t: c.t} + else + append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t} + + else if otherC.d? + if c.p >= otherC.p + otherC.d.length + append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t} + else if c.p + c.c.length <= otherC.p + append dest, c + else # Delete overlaps comment + # They overlap somewhere. + newC = {c:'', p:c.p, t: c.t} + if c.p < otherC.p + newC.c = c.c[...(otherC.p - c.p)] + if c.p + c.c.length > otherC.p + otherC.d.length + newC.c += c.c[(otherC.p + otherC.d.length - c.p)..] + + # This is entirely optional - just for a check that the deleted + # text in the two ops matches + intersectStart = Math.max c.p, otherC.p + intersectEnd = Math.min c.p + c.c.length, otherC.p + otherC.d.length + cIntersect = c.c[intersectStart - c.p...intersectEnd - c.p] + otherIntersect = otherC.d[intersectStart - otherC.p...intersectEnd - otherC.p] + throw new Error 'Delete ops delete different text in the same region of the document' unless cIntersect == otherIntersect + + newC.p = transformPosition newC.p, otherC + append dest, newC + + else if otherC.c? + append dest, c + + else + throw new Error("unknown op type") dest diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 8c49d54c23..c4ad4b30a4 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -275,6 +275,16 @@ define [ doc: entity path: path } + # Keep list ordered by folders, then name + @$scope.docs.sort (a,b) -> + aDepth = (a.path.match(/\//g) || []).length + bDepth = (b.path.match(/\//g) || []).length + if aDepth - bDepth != 0 + return -(aDepth - bDepth) # Deeper path == folder first + else if a.path < b.path + return -1 + else + return 1 getEntityPath: (entity) -> @_getEntityPathInFolder @$scope.rootFolder, entity diff --git a/services/web/public/coffee/ide/permissions/PermissionsManager.coffee b/services/web/public/coffee/ide/permissions/PermissionsManager.coffee index 096f15babe..88dea13084 100644 --- a/services/web/public/coffee/ide/permissions/PermissionsManager.coffee +++ b/services/web/public/coffee/ide/permissions/PermissionsManager.coffee @@ -5,15 +5,22 @@ define [], () -> read: false write: false admin: false + comment: false @$scope.$watch "permissionsLevel", (permissionsLevel) => if permissionsLevel? if permissionsLevel == "readOnly" @$scope.permissions.read = true + @$scope.permissions.comment = true else if permissionsLevel == "readAndWrite" @$scope.permissions.read = true @$scope.permissions.write = true + @$scope.permissions.comment = true else if permissionsLevel == "owner" @$scope.permissions.read = true @$scope.permissions.write = true @$scope.permissions.admin = true + @$scope.permissions.comment = true + + if @$scope.anonymous + @$scope.permissions.comment = false diff --git a/services/web/public/coffee/ide/review-panel/ChangesTracker.coffee b/services/web/public/coffee/ide/review-panel/RangesTracker.coffee similarity index 85% rename from services/web/public/coffee/ide/review-panel/ChangesTracker.coffee rename to services/web/public/coffee/ide/review-panel/RangesTracker.coffee index 0b668c90dd..e31b84f051 100644 --- a/services/web/public/coffee/ide/review-panel/ChangesTracker.coffee +++ b/services/web/public/coffee/ide/review-panel/RangesTracker.coffee @@ -1,7 +1,5 @@ -define [ - "utils/EventEmitter" -], (EventEmitter) -> - class ChangesTracker extends EventEmitter +load = (EventEmitter) -> + class RangesTracker extends EventEmitter # The purpose of this class is to track a set of inserts and deletes to a document, like # track changes in Word. We store these as a set of ShareJs style ranges: # {i: "foo", p: 42} # Insert 'foo' at offset 42 @@ -36,30 +34,34 @@ define [ # * Deletes by another user will consume deletes by the first user # * Inserts by another user will not combine with inserts by the first user. If they are in the # middle of a previous insert by the first user, the original insert will be split into two. - constructor: () -> - # Change objects have the following structure: - # { - # id: ... # Uniquely generated by us - # op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d) - # i: "..." - # p: 42 - # } - # } - # - # Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in - # sync with Ace ranges. - @changes = [] - @comments = [] - @id = 0 + constructor: (@changes = [], @comments = []) -> + @setIdSeed(RangesTracker.generateIdSeed()) + + getIdSeed: () -> + return @id_seed + + setIdSeed: (seed) -> + @id_seed = seed + @id_increment = 0 - addComment: (offset, length, metadata) -> - # TODO: Don't allow overlapping comments? - @comments.push comment = { - id: @_newId() - offset, length, metadata - } - @emit "comment:added", comment - return comment + @generateIdSeed: () -> + # Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part + # Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js + pid = Math.floor(Math.random() * (32767)).toString(16) + machine = Math.floor(Math.random() * (16777216)).toString(16) + timestamp = Math.floor(new Date().valueOf() / 1000).toString(16) + return '00000000'.substr(0, 8 - timestamp.length) + timestamp + + '000000'.substr(0, 6 - machine.length) + machine + + '0000'.substr(0, 4 - pid.length) + pid + + @generateId: () -> + @generateIdSeed() + "000001" + + newId: () -> + @id_increment++ + increment = @id_increment.toString(16) + id = @id_seed + '000000'.substr(0, 6 - increment.length) + increment; + return id getComment: (comment_id) -> comment = null @@ -69,19 +71,6 @@ define [ break return comment - resolveCommentId: (comment_id, resolved_data) -> - comment = @getComment(comment_id) - return if !comment? - comment.metadata.resolved = true - comment.metadata.resolved_data = resolved_data - @emit "comment:resolved", comment - - unresolveCommentId: (comment_id) -> - comment = @getComment(comment_id) - return if !comment? - comment.metadata.resolved = false - @emit "comment:unresolved", comment - removeCommentId: (comment_id) -> comment = @getComment(comment_id) return if !comment? @@ -101,7 +90,7 @@ define [ return if !change? @_removeChange(change) - applyOp: (op, metadata) -> + applyOp: (op, metadata = {}) -> metadata.ts ?= new Date() # Apply an op that has been applied to the document to our changes to keep them up to date if op.i? @@ -110,14 +99,31 @@ define [ else if op.d? @applyDeleteToChanges(op, metadata) @applyDeleteToComments(op) + else if op.c? + @addComment(op, metadata) + else + throw new Error("unknown op type") + + addComment: (op, metadata) -> + @comments.push comment = { + id: op.t or @newId() + op: # Copy because we'll modify in place + c: op.c + p: op.p + t: op.t + metadata + } + @emit "comment:added", comment + return comment applyInsertToComments: (op) -> for comment in @comments - if op.p <= comment.offset - comment.offset += op.i.length + if op.p <= comment.op.p + comment.op.p += op.i.length @emit "comment:moved", comment - else if op.p < comment.offset + comment.length - comment.length += op.i.length + else if op.p < comment.op.p + comment.op.c.length + offset = op.p - comment.op.p + comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...] @emit "comment:moved", comment applyDeleteToComments: (op) -> @@ -125,20 +131,35 @@ define [ op_length = op.d.length op_end = op.p + op_length for comment in @comments - comment_end = comment.offset + comment.length - if op_end <= comment.offset + comment_start = comment.op.p + comment_end = comment.op.p + comment.op.c.length + comment_length = comment_end - comment_start + if op_end <= comment_start # delete is fully before comment - comment.offset -= op_length + comment.op.p -= op_length @emit "comment:moved", comment else if op_start >= comment_end # delete is fully after comment, nothing to do else # delete and comment overlap - delete_length_before = Math.max(0, comment.offset - op_start) - delete_length_after = Math.max(0, op_end - comment_end) - delete_length_overlapping = op_length - delete_length_before - delete_length_after - comment.offset = Math.min(comment.offset, op_start) - comment.length -= delete_length_overlapping + if op_start <= comment_start + remaining_before = "" + else + remaining_before = comment.op.c.slice(0, op_start - comment_start) + if op_end >= comment_end + remaining_after = "" + else + remaining_after = comment.op.c.slice(op_end - comment_start) + + # Check deleted content matches delete op + deleted_comment = comment.op.c.slice(remaining_before.length, comment_length - remaining_after.length) + offset = Math.max(0, comment_start - op_start) + deleted_op_content = op.d.slice(offset).slice(0, deleted_comment.length) + if deleted_comment != deleted_op_content + throw new Error("deleted content does not match comment content") + + comment.op.p = Math.min(comment_start, op_start) + comment.op.c = remaining_before + remaining_after @emit "comment:moved", comment applyInsertToChanges: (op, metadata) -> @@ -374,12 +395,9 @@ define [ if moved_changes.length > 0 @emit "changes:moved", moved_changes - _newId: () -> - (@id++).toString() - _addOp: (op, metadata) -> change = { - id: @_newId() + id: @newId() op: op metadata: metadata } @@ -453,3 +471,9 @@ define [ else # Only update to the current change if we haven't removed it. previous_change = change return { moved_changes, remove_changes } + +if define? + define ["utils/EventEmitter"], load +else + EventEmitter = require("events").EventEmitter + module.exports = load(EventEmitter) \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee b/services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee index 6a23d15016..1565d6db73 100644 --- a/services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee +++ b/services/web/public/coffee/ide/review-panel/ReviewPanelManager.coffee @@ -1,9 +1,13 @@ define [ "ide/review-panel/controllers/ReviewPanelController" + "ide/review-panel/controllers/TrackChangesUpgradeModalController" "ide/review-panel/directives/reviewPanelSorted" "ide/review-panel/directives/reviewPanelToggle" "ide/review-panel/directives/changeEntry" "ide/review-panel/directives/commentEntry" "ide/review-panel/directives/addCommentEntry" + "ide/review-panel/directives/resolvedCommentEntry" + "ide/review-panel/directives/resolvedCommentsDropdown" + "ide/review-panel/filters/notEmpty" "ide/review-panel/filters/orderOverviewEntries" ], () -> \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee b/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee index 9623e2af9a..5cdf7c672c 100644 --- a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee +++ b/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee @@ -2,9 +2,9 @@ define [ "base", "utils/EventEmitter" "ide/colors/ColorManager" - "ide/review-panel/ChangesTracker" -], (App, EventEmitter, ColorManager, ChangesTracker) -> - App.controller "ReviewPanelController", ($scope, $element, ide, $timeout) -> + "ide/review-panel/RangesTracker" +], (App, EventEmitter, ColorManager, RangesTracker) -> + App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, $modal, event_tracking) -> $reviewPanelEl = $element.find "#review-panel" $scope.SubViews = @@ -13,131 +13,113 @@ define [ $scope.reviewPanel = entries: {} - trackNewChanges: false + resolvedComments: {} hasEntries: false subView: $scope.SubViews.CUR_FILE openSubView: $scope.SubViews.CUR_FILE + overview: + loading: false + dropdown: + loading: false + commentThreads: {} + resolvedThreadIds: {} + layoutToLeft: false + rendererData: {} + loadingThreads: false + + $scope.$on "layout:pdf:linked", (event, state) -> + $scope.reviewPanel.layoutToLeft = (state.east?.size < 220 || state.east?.initClosed) + $scope.$broadcast "review-panel:layout" + + $scope.$on "layout:pdf:resize", (event, state) -> + $scope.reviewPanel.layoutToLeft = (state.east?.size < 220 || state.east?.initClosed) + $scope.$broadcast "review-panel:layout", false + + $scope.$on "expandable-text-area:resize", (event) -> + $timeout () -> + $scope.$broadcast "review-panel:layout" + + $scope.$watch "ui.pdfLayout", (layout) -> + $scope.reviewPanel.layoutToLeft = (layout == "flat") + + $scope.$watch "project.features.trackChangesVisible", (visible) -> + return if !visible? + if !visible + $scope.ui.reviewPanelOpen = false $scope.commentState = adding: false content: "" - $scope.reviewPanelEventsBridge = new EventEmitter() + $scope.users = {} - changesTrackers = {} + $scope.reviewPanelEventsBridge = new EventEmitter() + + ide.socket.on "new-comment", (thread_id, comment) -> + thread = getThread(thread_id) + delete thread.submitting + thread.messages.push(formatComment(comment)) + $scope.$apply() + $timeout () -> + $scope.$broadcast "review-panel:layout" + + ide.socket.on "accept-change", (doc_id, change_id) -> + if doc_id != $scope.editor.open_doc_id + getChangeTracker(doc_id).removeChangeId(change_id) + else + $scope.$broadcast "change:accept", change_id + updateEntries(doc_id) + $scope.$apply () -> + + ide.socket.on "resolve-thread", (thread_id, user) -> + _onCommentResolved(thread_id, user) + + ide.socket.on "reopen-thread", (thread_id) -> + _onCommentReopened(thread_id) + + ide.socket.on "delete-thread", (thread_id) -> + _onThreadDeleted(thread_id) + $scope.$apply () -> + + ide.socket.on "edit-message", (thread_id, message_id, content) -> + _onCommentEdited(thread_id, message_id, content) + $scope.$apply () -> + + ide.socket.on "delete-message", (thread_id, message_id) -> + _onCommentDeleted(thread_id, message_id) + $scope.$apply () -> + + rangesTrackers = {} getDocEntries = (doc_id) -> $scope.reviewPanel.entries[doc_id] ?= {} return $scope.reviewPanel.entries[doc_id] + getDocResolvedComments = (doc_id) -> + $scope.reviewPanel.resolvedComments[doc_id] ?= {} + return $scope.reviewPanel.resolvedComments[doc_id] + + getThread = (thread_id) -> + $scope.reviewPanel.commentThreads[thread_id] ?= { messages: [] } + return $scope.reviewPanel.commentThreads[thread_id] + getChangeTracker = (doc_id) -> - changesTrackers[doc_id] ?= new ChangesTracker() - return changesTrackers[doc_id] - - # TODO Just for prototyping purposes; remove afterwards. - mockedUserId = 'mock_user_id_1' - mockedUserId2 = 'mock_user_id_2' - - if window.location.search.match /mocktc=true/ - mock_changes = { - "main.tex": - changes: [{ - op: { i: "Habitat loss and conflicts with humans are the greatest causes of concern.", p: 925 - 38 } - metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 30 * 60 * 1000) } - }, { - op: { d: "The lion is now a vulnerable species. ", p: 778 } - metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 31 * 60 * 1000) } - }] - comments: [{ - offset: 1375 - 38 - length: 79 - metadata: - thread: [{ - content: "Do we have a source for this?" - user_id: mockedUserId - ts: new Date(Date.now() - 45 * 60 * 1000) - }] - }] - "chapter_1.tex": - changes: [{ - "op":{"p":740,"d":", to take down large animals"}, - "metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 15 * 60 * 1000)} - }, { - "op":{"i":", to keep hold of the prey","p":920}, - "metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 130 * 60 * 1000)} - }, { - "op":{"i":" being","p":1057}, - "metadata":{"user_id":mockedUserId2, ts: new Date(Date.now() - 72 * 60 * 1000)} - }] - comments:[{ - "offset":111,"length":5, - "metadata":{ - "thread": [ - {"content":"Have we used 'pride' too much here?","user_id":mockedUserId, ts: new Date(Date.now() - 12 * 60 * 1000)}, - {"content":"No, I think this is OK","user_id":mockedUserId2, ts: new Date(Date.now() - 9 * 60 * 1000)} - ] - } - },{ - "offset":452,"length":21, - "metadata":{ - "thread":[ - {"content":"TODO: Don't use as many parentheses!","user_id":mockedUserId2, ts: new Date(Date.now() - 99 * 60 * 1000)} - ] - } - }] - "chapter_2.tex": - changes: [{ - "op":{"p":458,"d":"other"}, - "metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 133 * 60 * 1000)} - },{ - "op":{"i":"usually 2-3, ","p":928}, - "metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 27 * 60 * 1000)} - },{ - "op":{"i":"If the parents are a male lion and a female tiger, it is called a liger. A tigon comes from a male tiger and a female lion.","p":1126}, - "metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 152 * 60 * 1000)} - }] - comments: [{ - "offset":299,"length":10, - "metadata":{ - "thread":[{ - "content":"Should we use a different word here if 'den' needs clarifying?","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000) - }] - } - },{ - "offset":843,"length":66, - "metadata":{ - "thread":[{ - "content":"This sentence is a little ambiguous","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000) - }] - } - }] - } - ide.$scope.$on "file-tree:initialized", () -> - ide.fileTreeManager.forEachEntity (entity) -> - if mock_changes[entity.name]? - changesTracker = getChangeTracker(entity.id) - for change in mock_changes[entity.name].changes - changesTracker._addOp change.op, change.metadata - for comment in mock_changes[entity.name].comments - changesTracker.addComment comment.offset, comment.length, comment.metadata - for doc_id, changesTracker of changesTrackers - updateEntries(doc_id) + if !rangesTrackers[doc_id]? + rangesTrackers[doc_id] = new RangesTracker() + rangesTrackers[doc_id].resolvedThreadIds = $scope.reviewPanel.resolvedThreadIds + return rangesTrackers[doc_id] scrollbar = {} $scope.reviewPanelEventsBridge.on "aceScrollbarVisibilityChanged", (isVisible, scrollbarWidth) -> scrollbar = {isVisible, scrollbarWidth} updateScrollbar() - + updateScrollbar = () -> if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE $reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px" else $reviewPanelEl.css "right", "0" - - $scope.$watch "reviewPanel.subView", (subView) -> - return if !subView? - updateScrollbar() - + $scope.$watch "ui.reviewPanelOpen", (open) -> return if !open? if !open @@ -147,33 +129,88 @@ define [ else # Reset back to what we had when previously open $scope.reviewPanel.subView = $scope.reviewPanel.openSubView + + $scope.$watch "reviewPanel.subView", (view) -> + return if !view? + updateScrollbar() + if view == $scope.SubViews.OVERVIEW + refreshOverviewPanel() - $scope.$watch "editor.open_doc_id", (open_doc_id) -> - return if !open_doc_id? - changesTrackers[open_doc_id] ?= new ChangesTracker() - $scope.reviewPanel.changesTracker = changesTrackers[open_doc_id] + $scope.$watch "editor.sharejs_doc", (doc, old_doc) -> + return if !doc? + # The open doc range tracker is kept up to date in real-time so + # replace any outdated info with this + rangesTrackers[doc.doc_id] = doc.ranges + rangesTrackers[doc.doc_id].resolvedThreadIds = $scope.reviewPanel.resolvedThreadIds + $scope.reviewPanel.rangesTracker = rangesTrackers[doc.doc_id] + if old_doc? + old_doc.off "flipped_pending_to_inflight" + doc.on "flipped_pending_to_inflight", () -> + regenerateTrackChangesId(doc) + regenerateTrackChangesId(doc) $scope.$watch (() -> entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {} Object.keys(entries).length ), (nEntries) -> - $scope.reviewPanel.hasEntries = nEntries > 0 and $scope.trackChangesFeatureFlag + $scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible $scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) -> return if !reviewPanelOpen? $timeout () -> $scope.$broadcast "review-panel:toggle" - $scope.$broadcast "review-panel:layout" + $scope.$broadcast "review-panel:layout", false + + regenerateTrackChangesId = (doc) -> + old_id = getChangeTracker(doc.doc_id).getIdSeed() + new_id = RangesTracker.generateIdSeed() + getChangeTracker(doc.doc_id).setIdSeed(new_id) + doc.setTrackChangesIdSeeds({pending: new_id, inflight: old_id}) + refreshRanges = () -> + $http.get "/project/#{$scope.project_id}/ranges" + .success (docs) -> + for doc in docs + if doc.id != $scope.editor.open_doc_id # this is kept up to date in real-time, don't overwrite + rangesTracker = getChangeTracker(doc.id) + rangesTracker.comments = doc.ranges?.comments or [] + rangesTracker.changes = doc.ranges?.changes or [] + updateEntries(doc.id) + + refreshOverviewPanel = () -> + $scope.reviewPanel.overview.loading = true + refreshRanges() + .then () -> + $scope.reviewPanel.overview.loading = false + .catch () -> + $scope.reviewPanel.overview.loading = false + + $scope.refreshResolvedCommentsDropdown = () -> + $scope.reviewPanel.dropdown.loading = true + q = refreshRanges() + q.then () -> + $scope.reviewPanel.dropdown.loading = false + q.catch () -> + $scope.reviewPanel.dropdown.loading = false + return q + updateEntries = (doc_id) -> - changesTracker = getChangeTracker(doc_id) + rangesTracker = getChangeTracker(doc_id) entries = getDocEntries(doc_id) + resolvedComments = getDocResolvedComments(doc_id) + changed = false + # Assume we'll delete everything until we see it, then we'll remove it from this object delete_changes = {} - delete_changes[change_id] = true for change_id, change of entries + for change_id, change of entries + if change_id != "add-comment" + delete_changes[change_id] = true + for change_id, change of resolvedComments + delete_changes[change_id] = true - for change in changesTracker.changes + for change in rangesTracker.changes + changed = true delete delete_changes[change.id] entries[change.id] ?= {} @@ -189,22 +226,37 @@ define [ for key, value of new_entry entries[change.id][key] = value - for comment in changesTracker.comments + if !$scope.users[change.metadata.user_id]? + refreshChangeUsers(change.metadata.user_id) + + if rangesTracker.comments.length > 0 + ensureThreadsAreLoaded() + + for comment in rangesTracker.comments + changed = true delete delete_changes[comment.id] - entries[comment.id] ?= {} + if $scope.reviewPanel.resolvedThreadIds[comment.op.t] + new_comment = resolvedComments[comment.id] ?= {} + delete entries[comment.id] + else + new_comment = entries[comment.id] ?= {} + delete resolvedComments[comment.id] new_entry = { type: "comment" - thread: comment.metadata.thread - resolved: comment.metadata.resolved - resolved_data: comment.metadata.resolved_data - offset: comment.offset - length: comment.length + thread_id: comment.op.t + content: comment.op.c + offset: comment.op.p } for key, value of new_entry - entries[comment.id][key] = value + new_comment[key] = value for change_id, _ of delete_changes + changed = true delete entries[change_id] + delete resolvedComments[change_id] + + if changed + $scope.$broadcast "entries:changed" $scope.$on "editor:track-changes:changed", () -> doc_id = $scope.editor.open_doc_id @@ -212,53 +264,65 @@ define [ $scope.$broadcast "review-panel:recalculate-screen-positions" $scope.$broadcast "review-panel:layout" - $scope.$on "editor:focus:changed", (e, cursor_offset, selection) -> + $scope.$on "editor:focus:changed", (e, selection_offset_start, selection_offset_end, selection) -> doc_id = $scope.editor.open_doc_id entries = getDocEntries(doc_id) - if !selection - delete entries["add-comment"] - else - entries["add-comment"] = { - type: "add-comment" - offset: cursor_offset - } + delete entries["add-comment"] + if selection + # Only show add comment if we're not already overlapping one + overlapping_comment = false + for id, entry of entries + if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id] + unless entry.offset >= selection_offset_end or entry.offset + entry.content.length <= selection_offset_start + overlapping_comment = true + if !overlapping_comment + entries["add-comment"] = { + type: "add-comment" + offset: selection_offset_start + } for id, entry of entries - if entry.type == "comment" and not entry.resolved - entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.length) + if entry.type == "comment" and not $scope.reviewPanel.resolvedThreadIds[entry.thread_id] + entry.focused = (entry.offset <= selection_offset_start <= entry.offset + entry.content.length) else if entry.type == "insert" - entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.content.length) + entry.focused = (entry.offset <= selection_offset_start <= entry.offset + entry.content.length) else if entry.type == "delete" - entry.focused = (entry.offset == cursor_offset) + entry.focused = (entry.offset == selection_offset_start) else if entry.type == "add-comment" and selection entry.focused = true $scope.$broadcast "review-panel:recalculate-screen-positions" $scope.$broadcast "review-panel:layout" - + $scope.acceptChange = (entry_id) -> + $http.post "/project/#{$scope.project_id}/doc/#{$scope.editor.open_doc_id}/changes/#{entry_id}/accept", {_csrf: window.csrfToken} $scope.$broadcast "change:accept", entry_id + event_tracking.sendMB "rp-change-accepted", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' } $scope.rejectChange = (entry_id) -> $scope.$broadcast "change:reject", entry_id + event_tracking.sendMB "rp-change-rejected", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' } $scope.startNewComment = () -> - # $scope.commentState.adding = true $scope.$broadcast "comment:select_line" $timeout () -> $scope.$broadcast "review-panel:layout" $scope.submitNewComment = (content) -> - # $scope.commentState.adding = false - $scope.$broadcast "comment:add", content - # $scope.commentState.content = "" + thread_id = RangesTracker.generateId() + thread = getThread(thread_id) + thread.submitting = true + $scope.$broadcast "comment:add", thread_id + $http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages", {content, _csrf: window.csrfToken}) + .error (error) -> + ide.showGenericMessageModal("Error submitting comment", "Sorry, there was a problem submitting your comment") + $scope.$broadcast "editor:clearSelection" $timeout () -> $scope.$broadcast "review-panel:layout" - + event_tracking.sendMB "rp-new-comment", { size: content.length } + $scope.cancelNewComment = (entry) -> - # $scope.commentState.adding = false - # $scope.commentState.content = "" $timeout () -> $scope.$broadcast "review-panel:layout" @@ -267,117 +331,216 @@ define [ $timeout () -> $scope.$broadcast "review-panel:layout" - # $scope.handleCommentReplyKeyPress = (ev, entry) -> - # if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey - # ev.preventDefault() - # ev.target.blur() - # $scope.submitReply(entry) + $scope.submitReply = (entry, entry_id) -> + thread_id = entry.thread_id + content = entry.replyContent + $http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages", {content, _csrf: window.csrfToken}) + .error (error) -> + ide.showGenericMessageModal("Error submitting comment", "Sorry, there was a problem submitting your comment") + + trackingMetadata = + view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' + size: entry.replyContent.length + thread: thread_id - $scope.submitReply = (entry, entry_id) -> - $scope.unresolveComment(entry_id) - entry.thread.push { - content: entry.replyContent - ts: new Date() - user_id: window.user_id - } + thread = getThread(thread_id) + thread.submitting = true entry.replyContent = "" entry.replying = false $timeout () -> $scope.$broadcast "review-panel:layout" - # TODO Just for prototyping purposes; remove afterwards - window.setTimeout((() -> - $scope.$applyAsync(() -> submitMockedReply(entry)) - ), 1000 * 2) + event_tracking.sendMB "rp-comment-reply", trackingMetadata - # TODO Just for prototyping purposes; remove afterwards. - submitMockedReply = (entry) -> - entry.thread.push { - content: 'Sounds good!' - ts: new Date() - user_id: mockedUserId - } - entry.replyContent = "" - entry.replying = false - $timeout () -> - $scope.$broadcast "review-panel:layout" - $scope.cancelReply = (entry) -> entry.replying = false entry.replyContent = "" $scope.$broadcast "review-panel:layout" $scope.resolveComment = (entry, entry_id) -> - entry.showWhenResolved = false entry.focused = false - $scope.$broadcast "comment:resolve", entry_id, window.user_id - - $scope.unresolveComment = (entry_id) -> - $scope.$broadcast "comment:unresolve", entry_id - - $scope.deleteComment = (entry_id) -> - $scope.$broadcast "comment:remove", entry_id + $http.post "/project/#{$scope.project_id}/thread/#{entry.thread_id}/resolve", {_csrf: window.csrfToken} + _onCommentResolved(entry.thread_id, ide.$scope.user) + event_tracking.sendMB "rp-comment-resolve", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' } - $scope.showThread = (entry) -> - entry.showWhenResolved = true + $scope.unresolveComment = (thread_id) -> + _onCommentReopened(thread_id) + $http.post "/project/#{$scope.project_id}/thread/#{thread_id}/reopen", {_csrf: window.csrfToken} + event_tracking.sendMB "rp-comment-reopen" + + _onCommentResolved = (thread_id, user) -> + thread = getThread(thread_id) + return if !thread? + thread.resolved = true + thread.resolved_by_user = formatUser(user) + thread.resolved_at = new Date() + $scope.reviewPanel.resolvedThreadIds[thread_id] = true + $scope.$broadcast "comment:resolve_threads", [thread_id] + + _onCommentReopened = (thread_id) -> + thread = getThread(thread_id) + return if !thread? + delete thread.resolved + delete thread.resolved_by_user + delete thread.resolved_at + delete $scope.reviewPanel.resolvedThreadIds[thread_id] + $scope.$broadcast "comment:unresolve_thread", thread_id + + _onThreadDeleted = (thread_id) -> + delete $scope.reviewPanel.resolvedThreadIds[thread_id] + delete $scope.reviewPanel.commentThreads[thread_id] + $scope.$broadcast "comment:remove", thread_id + + _onCommentEdited = (thread_id, comment_id, content) -> + thread = getThread(thread_id) + return if !thread? + for message in thread.messages + if message.id == comment_id + message.content = content + updateEntries() + + _onCommentDeleted = (thread_id, comment_id) -> + thread = getThread(thread_id) + return if !thread? + thread.messages = thread.messages.filter (m) -> m.id != comment_id + updateEntries() + + $scope.deleteThread = (entry_id, doc_id, thread_id) -> + _onThreadDeleted(thread_id) + $http({ + method: "DELETE" + url: "/project/#{$scope.project_id}/doc/#{doc_id}/thread/#{thread_id}", + headers: { + 'X-CSRF-Token': window.csrfToken + } + }) + event_tracking.sendMB "rp-comment-delete" + + $scope.saveEdit = (thread_id, comment) -> + $http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}/edit", { + content: comment.content + _csrf: window.csrfToken + }) $timeout () -> $scope.$broadcast "review-panel:layout" - $scope.hideThread = (entry) -> - entry.showWhenResolved = false + $scope.deleteComment = (thread_id, comment) -> + _onCommentDeleted(thread_id, comment.id) + $http({ + method: "DELETE" + url: "/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}", + headers: { + 'X-CSRF-Token': window.csrfToken + } + }) $timeout () -> $scope.$broadcast "review-panel:layout" $scope.setSubView = (subView) -> $scope.reviewPanel.subView = subView + event_tracking.sendMB "rp-subview-change", { subView } $scope.gotoEntry = (doc_id, entry) -> ide.editorManager.openDocId(doc_id, { gotoOffset: entry.offset }) - - DOC_ID_NAMES = {} - $scope.getFileName = (doc_id) -> - # This is called a lot and is relatively expensive, so cache the result - if !DOC_ID_NAMES[doc_id]? - entity = ide.fileTreeManager.findEntityById(doc_id) - return if !entity? - DOC_ID_NAMES[doc_id] = ide.fileTreeManager.getEntityPath(entity) - return DOC_ID_NAMES[doc_id] - - # TODO: Eventually we need to get this from the server, and update it - # when we get an id we don't know. This'll do for client side testing - refreshUsers = () -> - $scope.users = {} - # TODO Just for prototyping purposes; remove afterwards. - $scope.users[mockedUserId] = { - email: "paulo@sharelatex.com" - name: "Paulo Reis" - isSelf: false - hue: 70 - avatar_text: "PR" - } - $scope.users[mockedUserId2] = { - email: "james@sharelatex.com" - name: "James Allen" - isSelf: false - hue: 320 - avatar_text: "JA" - } - - for member in $scope.project.members.concat($scope.project.owner) - if member._id == window.user_id - name = "You" - isSelf = true - else - name = "#{member.first_name} #{member.last_name}" - isSelf = false - - $scope.users[member._id] = { - email: member.email - name: name - isSelf: isSelf - hue: ColorManager.getHueForUserId(member._id) - avatar_text: [member.first_name, member.last_name].filter((n) -> n?).map((n) -> n[0]).join "" - } - $scope.$watch "project.members", (members) -> - return if !members? - refreshUsers() + $scope.toggleTrackChanges = (value) -> + if $scope.project.features.trackChanges + $scope.editor.wantTrackChanges = value + $http.post "/project/#{$scope.project_id}/track_changes", {_csrf: window.csrfToken, on: value} + event_tracking.sendMB "rp-trackchanges-toggle", { value } + else + $scope.openTrackChangesUpgradeModal() + + ide.socket.on "toggle-track-changes", (value) -> + $scope.$apply () -> + $scope.editor.wantTrackChanges = value + + _refreshingRangeUsers = false + _refreshedForUserIds = {} + refreshChangeUsers = (refresh_for_user_id) -> + if refresh_for_user_id? + if _refreshedForUserIds[refresh_for_user_id]? + # We've already tried to refresh to get this user id, so stop it looping + return + _refreshedForUserIds[refresh_for_user_id] = true + + # Only do one refresh at once + if _refreshingRangeUsers + return + _refreshingRangeUsers = true + + $http.get "/project/#{$scope.project_id}/changes/users" + .success (users) -> + _refreshingRangeUsers = false + $scope.users = {} + # Always include ourself, since if we submit an op, we might need to display info + # about it locally before it has been flushed through the server + if ide.$scope.user?.id? + $scope.users[ide.$scope.user.id] = formatUser(ide.$scope.user) + for user in users + if user.id? + $scope.users[user.id] = formatUser(user) + .error () -> + _refreshingRangeUsers = false + + _threadsLoaded = false + ensureThreadsAreLoaded = () -> + if _threadsLoaded + # We get any updates in real time so only need to load them once. + return + _threadsLoaded = true + $scope.reviewPanel.loadingThreads = true + $http.get "/project/#{$scope.project_id}/threads" + .success (threads) -> + $scope.reviewPanel.loadingThreads = false + for thread_id, _ of $scope.reviewPanel.resolvedThreadIds + delete $scope.reviewPanel.resolvedThreadIds[thread_id] + for thread_id, thread of threads + for comment in thread.messages + formatComment(comment) + if thread.resolved_by_user? + thread.resolved_by_user = formatUser(thread.resolved_by_user) + $scope.reviewPanel.resolvedThreadIds[thread_id] = true + $scope.$broadcast "comment:resolve_threads", [thread_id] + $scope.reviewPanel.commentThreads = threads + $timeout () -> + $scope.$broadcast "review-panel:layout" + + formatComment = (comment) -> + comment.user = formatUser(comment.user) + comment.timestamp = new Date(comment.timestamp) + return comment + + formatUser = (user) -> + id = user?._id or user?.id + + if !id? + return { + email: null + name: "Anonymous" + isSelf: false + hue: ColorManager.ANONYMOUS_HUE + avatar_text: "A" + } + if id == window.user_id + name = "You" + isSelf = true + else + name = [user.first_name, user.last_name].filter((n) -> n? and n != "").join(" ") + if name == "" + name = user.email?.split("@")[0] or "Unknown" + isSelf = false + return { + id: id + email: user.email + name: name + isSelf: isSelf + hue: ColorManager.getHueForUserId(id) + avatar_text: [user.first_name, user.last_name].filter((n) -> n?).map((n) -> n[0]).join "" + } + + $scope.openTrackChangesUpgradeModal = () -> + $modal.open { + templateUrl: "trackChangesUpgradeModalTemplate" + controller: "TrackChangesUpgradeModalController" + scope: $scope.$new() + } diff --git a/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee new file mode 100644 index 0000000000..ae8c049f69 --- /dev/null +++ b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee @@ -0,0 +1,11 @@ +define [ + "base" +], (App) -> + App.controller "TrackChangesUpgradeModalController", ($scope, $modalInstance) -> + $scope.cancel = () -> + $modalInstance.dismiss() + + $scope.startFreeTrial = (source) -> + ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source) + window.open("/user/subscription/new?planCode=student_free_trial_7_days") + $scope.startedFreeTrial = true \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee b/services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee index fd3edd09ca..124794e7b8 100644 --- a/services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee +++ b/services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee @@ -17,6 +17,8 @@ define [ scope.startNewComment = () -> scope.state.isAdding = true scope.onStartNew() + setTimeout () -> + scope.$broadcast "comment:new:open" scope.cancelNewComment = () -> scope.state.isAdding = false @@ -25,11 +27,11 @@ define [ scope.handleCommentKeyPress = (ev) -> if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey ev.preventDefault() - ev.target.blur() - scope.submitNewComment() + if scope.state.content.length > 0 + ev.target.blur() + scope.submitNewComment() scope.submitNewComment = () -> - console.log scope.state.content scope.onSubmit { content: scope.state.content } scope.state.isAdding = false scope.state.content = "" \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee b/services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee index d436a34b2c..9dc1ef2a37 100644 --- a/services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee +++ b/services/web/public/coffee/ide/review-panel/directives/changeEntry.coffee @@ -1,13 +1,30 @@ define [ "base" ], (App) -> - App.directive "changeEntry", () -> + App.directive "changeEntry", ($timeout) -> restrict: "E" templateUrl: "changeEntryTemplate" scope: entry: "=" user: "=" + permissions: "=" onAccept: "&" onReject: "&" onIndicatorClick: "&" - \ No newline at end of file + onBodyClick: "&" + link: (scope, element, attrs) -> + scope.contentLimit = 40 + scope.isCollapsed = true + scope.needsCollapsing = false + + element.on "click", (e) -> + if $(e.target).is('.rp-entry, .rp-entry-description, .rp-entry-body, .rp-entry-action-icon i') + scope.onBodyClick() + + scope.toggleCollapse = () -> + scope.isCollapsed = !scope.isCollapsed + $timeout () -> + scope.$emit "review-panel:layout" + + scope.$watch "entry.content.length", (contentLength) -> + scope.needsCollapsing = contentLength > scope.contentLimit \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee b/services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee index 6938062e2b..b2f09c96af 100644 --- a/services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee +++ b/services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee @@ -1,23 +1,65 @@ define [ "base" ], (App) -> - App.directive "commentEntry", () -> + App.directive "commentEntry", ($timeout) -> restrict: "E" templateUrl: "commentEntryTemplate" scope: entry: "=" - users: "=" + threads: "=" + permissions: "=" onResolve: "&" onReply: "&" onIndicatorClick: "&" + onSaveEdit: "&" onDelete: "&" - onUnresolve: "&" - onShowThread: "&" - onHideThread: "&" + onBodyClick: "&" link: (scope, element, attrs) -> + scope.state = + animating: false + + element.on "click", (e) -> + if $(e.target).is('.rp-entry, .rp-comment-loaded, .rp-comment-content, .rp-comment-reply, .rp-entry-metadata') + scope.onBodyClick() + scope.handleCommentReplyKeyPress = (ev) -> if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey ev.preventDefault() - ev.target.blur() - scope.onReply() - \ No newline at end of file + if scope.entry.replyContent.length > 0 + ev.target.blur() + scope.onReply() + + scope.animateAndCallOnResolve = () -> + scope.state.animating = true + element.find(".rp-entry").css("top", 0) + $timeout((() -> scope.onResolve()), 350) + return true + + scope.startEditing = (comment) -> + comment.editing = true + setTimeout () -> + scope.$emit "review-panel:layout" + + scope.saveEdit = (comment) -> + comment.editing = false + scope.onSaveEdit({comment:comment}) + + scope.confirmDelete = (comment) -> + comment.deleting = true + setTimeout () -> + scope.$emit "review-panel:layout" + + scope.cancelDelete = (comment) -> + comment.deleting = false + setTimeout () -> + scope.$emit "review-panel:layout" + + scope.doDelete = (comment) -> + comment.deleting = false + scope.onDelete({comment: comment}) + + scope.saveEditOnEnter = (ev, comment) -> + if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey + ev.preventDefault() + scope.saveEdit(comment) + \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/directives/resolvedCommentEntry.coffee b/services/web/public/coffee/ide/review-panel/directives/resolvedCommentEntry.coffee new file mode 100644 index 0000000000..8a1d42990b --- /dev/null +++ b/services/web/public/coffee/ide/review-panel/directives/resolvedCommentEntry.coffee @@ -0,0 +1,21 @@ +define [ + "base" +], (App) -> + App.directive "resolvedCommentEntry", () -> + restrict: "E" + templateUrl: "resolvedCommentEntryTemplate" + scope: + thread: "=" + permissions: "=" + onUnresolve: "&" + onDelete: "&" + link: (scope, element, attrs) -> + scope.contentLimit = 40 + scope.needsCollapsing = false + scope.isCollapsed = true + + scope.toggleCollapse = () -> + scope.isCollapsed = !scope.isCollapsed + + scope.$watch "thread.content.length", (contentLength) -> + scope.needsCollapsing = contentLength > scope.contentLimit \ No newline at end of file diff --git a/services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee b/services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee new file mode 100644 index 0000000000..d500d24db8 --- /dev/null +++ b/services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee @@ -0,0 +1,59 @@ +define [ + "base" +], (App) -> + App.directive "resolvedCommentsDropdown", (_) -> + restrict: "E" + templateUrl: "resolvedCommentsDropdownTemplate" + scope: + entries : "=" + threads : "=" + resolvedIds : "=" + docs : "=" + permissions: "=" + onOpen : "&" + onUnresolve : "&" + onDelete : "&" + isLoading : "=" + + link: (scope, element, attrs) -> + scope.state = + isOpen: false + + scope.toggleOpenState = () -> + scope.state.isOpen = !scope.state.isOpen + if (scope.state.isOpen) + scope.onOpen() + .then () -> filterResolvedComments() + + scope.resolvedComments = [] + + scope.handleUnresolve = (threadId) -> + scope.onUnresolve({ threadId }) + scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId + + scope.handleDelete = (entryId, docId, threadId) -> + scope.onDelete({ entryId, docId, threadId }) + scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId + + getDocNameById = (docId) -> + doc = _.find(scope.docs, (doc) -> doc.doc.id == docId) + if doc? + return doc.path + else + return null + + filterResolvedComments = () -> + scope.resolvedComments = [] + + for docId, docEntries of scope.entries + for entryId, entry of docEntries + if entry.type == "comment" and scope.threads[entry.thread_id]?.resolved? + resolvedComment = angular.copy scope.threads[entry.thread_id] + + resolvedComment.content = entry.content + resolvedComment.threadId = entry.thread_id + resolvedComment.entryId = entryId + resolvedComment.docId = docId + resolvedComment.docName = getDocNameById(docId) + + scope.resolvedComments.push(resolvedComment) diff --git a/services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee b/services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee index 82435faf44..4028406713 100644 --- a/services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee +++ b/services/web/public/coffee/ide/review-panel/directives/reviewPanelSorted.coffee @@ -6,7 +6,11 @@ define [ link: (scope, element, attrs) -> previous_focused_entry_index = 0 - layout = () -> + layout = (animate = true) -> + if animate + element.removeClass("no-animate") + else + element.addClass("no-animate") sl_console.log "LAYOUT" if scope.ui.reviewPanelOpen PADDING = 8 @@ -32,6 +36,8 @@ define [ return if entries.length == 0 + line_height = scope.reviewPanel.rendererData.lineHeight + focused_entry_index = Math.min(previous_focused_entry_index, entries.length - 1) for entry, i in entries if entry.scope.entry.focused @@ -43,15 +49,34 @@ define [ previous_focused_entry_index = focused_entry_index sl_console.log "focused_entry_index", focused_entry_index - - line_height = 15 - - # Put the focused entry exactly where it wants to be - focused_entry_top = Math.max(TOOLBAR_HEIGHT, focused_entry.scope.entry.screenPos.y) + + # As we go backwards, we run the risk of pushing things off the top of the editor. + # If we go through the entries before and assume they are as pushed together as they + # could be, we can work out the 'ceiling' that each one can't go through. I.e. the first + # on can't go beyond the toolbar height, the next one can't go beyond the bottom of the first + # one at this minimum height, etc. + heights = (entry.$layout_el.height() for entry in entries_before) + previousMinTop = TOOLBAR_HEIGHT + min_tops = [] + for height in heights + min_tops.push previousMinTop + previousMinTop += PADDING + height + min_tops.reverse() + + positionLayoutEl = ($callout_el, original_top, top) -> + if original_top <= top + $callout_el.removeClass("rp-entry-callout-inverted") + $callout_el.css(top: original_top + line_height - 1, height: top - original_top) + else + $callout_el.addClass("rp-entry-callout-inverted") + $callout_el.css(top: top + line_height, height: original_top - top) + + # Put the focused entry as close to where it wants to be as possible + focused_entry_top = Math.max(previousMinTop, focused_entry.scope.entry.screenPos.y) focused_entry.$box_el.css(top: focused_entry_top) focused_entry.$indicator_el.css(top: focused_entry_top) - focused_entry.$callout_el.css(top: focused_entry_top + line_height, height: 0) - + positionLayoutEl(focused_entry.$callout_el, focused_entry.scope.entry.screenPos.y, focused_entry_top) + previousBottom = focused_entry_top + focused_entry.$layout_el.height() for entry in entries_after original_top = entry.scope.entry.screenPos.y @@ -60,31 +85,32 @@ define [ previousBottom = top + height entry.$box_el.css(top: top) entry.$indicator_el.css(top: top) - entry.$callout_el.removeClass("rp-entry-callout-inverted") - entry.$callout_el.css(top: original_top + line_height, height: top - original_top) + positionLayoutEl(entry.$callout_el, original_top, top) sl_console.log "ENTRY", {entry: entry.scope.entry, top} - + previousTop = focused_entry_top entries_before.reverse() # Work through backwards, starting with the one just above - for entry in entries_before + for entry, i in entries_before original_top = entry.scope.entry.screenPos.y height = entry.$layout_el.height() original_bottom = original_top + height bottom = Math.min(original_bottom, previousTop - PADDING) - top = bottom - height + top = Math.max(bottom - height, min_tops[i]) previousTop = top entry.$box_el.css(top: top) entry.$indicator_el.css(top: top) - entry.$callout_el.addClass("rp-entry-callout-inverted") - entry.$callout_el.css(top: top + line_height + 1, height: original_top - top) + positionLayoutEl(entry.$callout_el, original_top, top) sl_console.log "ENTRY", {entry: entry.scope.entry, top} scope.$applyAsync () -> layout() - scope.$on "review-panel:layout", () -> + scope.$on "review-panel:layout", (e, animate = true) -> scope.$applyAsync () -> - layout() + layout(animate) + + scope.$watch "reviewPanel.rendererData.lineHeight", () -> + layout() ## Scroll lock with Ace scroller = element diff --git a/services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee b/services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee index e3844d1b12..2b5180dce6 100644 --- a/services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee +++ b/services/web/public/coffee/ide/review-panel/directives/reviewPanelToggle.coffee @@ -4,10 +4,25 @@ define [ App.directive "reviewPanelToggle", () -> restrict: "E" scope: - innerModel: '=ngModel' + onToggle: '=' + ngModel: '=' + disabled: '=?' + onDisabledClick: '=?' + link: (scope) -> + if !scope.disabled? + scope.disabled = false + scope.onChange = (args...) -> + scope.onToggle(scope.localModel) + scope.handleClick = () -> + if scope.disabled + scope.onDisabledClick() + scope.localModel = scope.ngModel + scope.$watch "ngModel", (value) -> + scope.localModel = value + template: """ -
- +
+
""" diff --git a/services/web/public/coffee/ide/review-panel/filters/notEmpty.coffee b/services/web/public/coffee/ide/review-panel/filters/notEmpty.coffee new file mode 100644 index 0000000000..52100c7ff1 --- /dev/null +++ b/services/web/public/coffee/ide/review-panel/filters/notEmpty.coffee @@ -0,0 +1,5 @@ +define [ + "base" +], (App) -> + app.filter 'notEmpty', () -> + (object) -> !angular.equals({}, object) diff --git a/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee b/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee index 5a96bde5c1..9fdb3e9ca2 100644 --- a/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee +++ b/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee @@ -6,4 +6,4 @@ define [ $scope.hasProjects = window.data.projects.length > 0 $scope.userHasNoSubscription = window.userHasNoSubscription - $scope.randomView = _.shuffle(["default", "dropbox", "github"])[0] + diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 431c24ada7..161598d32d 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -14,10 +14,9 @@ define [ $scope.searchText = value : "" - if $scope.projects.length == 0 - $timeout () -> - recalculateProjectListHeight() - , 10 + $timeout () -> + recalculateProjectListHeight() + , 10 recalculateProjectListHeight = () -> topOffset = $(".project-list-card")?.offset()?.top diff --git a/services/web/public/img/about/chris.jpg b/services/web/public/img/about/chris.jpg new file mode 100644 index 0000000000..d317be783a Binary files /dev/null and b/services/web/public/img/about/chris.jpg differ diff --git a/services/web/public/img/about/geri.jpg b/services/web/public/img/about/geri.jpg deleted file mode 100644 index 0de2f9a20e..0000000000 Binary files a/services/web/public/img/about/geri.jpg and /dev/null differ diff --git a/services/web/public/img/about/joe_green.jpg b/services/web/public/img/about/joe_green.jpg new file mode 100644 index 0000000000..0b730673d0 Binary files /dev/null and b/services/web/public/img/about/joe_green.jpg differ diff --git a/services/web/public/img/teasers/track-changes/teaser-track-changes.gif b/services/web/public/img/teasers/track-changes/teaser-track-changes.gif new file mode 100644 index 0000000000..00e02a29b0 Binary files /dev/null and b/services/web/public/img/teasers/track-changes/teaser-track-changes.gif differ diff --git a/services/web/public/img/teasers/track-changes/teaser-track-changes.mp4 b/services/web/public/img/teasers/track-changes/teaser-track-changes.mp4 new file mode 100644 index 0000000000..1f75a461ad Binary files /dev/null and b/services/web/public/img/teasers/track-changes/teaser-track-changes.mp4 differ diff --git a/services/web/public/js/ace-1.2.5/mode-latex.js b/services/web/public/js/ace-1.2.5/mode-latex.js index f183d7c263..8e7bbe4802 100644 --- a/services/web/public/js/ace-1.2.5/mode-latex.js +++ b/services/web/public/js/ace-1.2.5/mode-latex.js @@ -242,6 +242,10 @@ var createLatexWorker = function (session) { var annotations = []; var newRange = {}; var cursor = selection.getCursor(); + var maxRow = session.getLength() - 1; + var maxCol = (maxRow > 0) ? session.getLine(maxRow).length : 0; + var cursorAtEndOfDocument = (cursor.row == maxRow) && (cursor.column === maxCol); + suppressions = []; for (var i = 0, len = hints.length; i 0) { + return j; // advance past these tokens + } else { + return null; + } +}; + var readOptionalParams = function(TokeniseResult, k) { var Tokens = TokeniseResult.tokens; var text = TokeniseResult.text; + var params = Tokens[k+1]; + if(params && params[1] === "Text") { + var paramNum = text.substring(params[2], params[3]); + if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) { + return k + 1; // got it + }; + }; + var count = 0; + var nextToken = Tokens[k+1]; + var pos = nextToken[2]; + + for (var i = pos, end = text.length; i < end; i++) { + var char = text[i]; + if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];}; + if (char === "[") { count++; } + if (char === "]") { count--; } + if (count === 0 && char === "{") { return k - 1; } + if (count > 0 && (char === '\r' || char === '\n')) { return null; } + }; + return null; +}; + +var readOptionalGeneric = function(TokeniseResult, k) { + var Tokens = TokeniseResult.tokens; + var text = TokeniseResult.text; + var params = Tokens[k+1]; if(params && params[1] === "Text") { var paramNum = text.substring(params[2], params[3]); - if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) { + if (paramNum.match(/^(\[[^\]]*\])+\s*$/)) { return k + 1; // got it }; }; return null; }; +var readOptionalDef = function (TokeniseResult, k) { + var Tokens = TokeniseResult.tokens; + var text = TokeniseResult.text; + + var defToken = Tokens[k]; + var pos = defToken[3]; + + var openBrace = "{"; + var nextToken = Tokens[k+1]; + for (var i = pos, end = text.length; i < end; i++) { + var char = text[i]; + if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];}; + if (char === openBrace) { return k - 1; }; // move back to the last token of the optional arguments + if (char === '\r' || char === '\n') { return null; } + }; + + return null; + +}; + var readDefinition = function(TokeniseResult, k) { var Tokens = TokeniseResult.tokens; var text = TokeniseResult.text; @@ -1697,7 +1780,6 @@ var readUrl = function(TokeniseResult, k) { return null; }; - var InterpretTokens = function (TokeniseResult, ErrorReporter) { var Tokens = TokeniseResult.tokens; var linePosition = TokeniseResult.linePosition; @@ -1706,11 +1788,30 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { var TokenErrorFromTo = ErrorReporter.TokenErrorFromTo; var TokenError = ErrorReporter.TokenError; - var Environments = []; + var Environments = new EnvHandler(ErrorReporter); + + var nextGroupMathMode = null; // if the next group should have + var nextGroupMathModeStack = [] ; // tracking all nextGroupMathModes + var seenUserDefinedBeginEquation = false; // if we have seen macros like \beq + var seenUserDefinedEndEquation = false; // if we have seen macros like \eeq for (var i = 0, len = Tokens.length; i < len; i++) { var token = Tokens[i]; var line = token[0], type = token[1], start = token[2], end = token[3], seq = token[4]; + + if (type === "{") { + Environments.push({command:"{", token:token, mathMode: nextGroupMathMode}); + nextGroupMathModeStack.push(nextGroupMathMode); + nextGroupMathMode = null; + continue; + } else if (type === "}") { + Environments.push({command:"}", token:token}); + nextGroupMathMode = nextGroupMathModeStack.pop(); + continue; + } else { + nextGroupMathMode = null; + }; + if (type === "\\") { if (seq === "begin" || seq === "end") { var open = Tokens[i+1]; @@ -1759,15 +1860,31 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { } else { TokenError(token, "invalid environment command"); }; - } - } else if (seq === "newcommand" || seq === "renewcommand" || seq === "def" || seq === "DeclareRobustCommand") { - var newPos = read1arg(TokeniseResult, i, {allowStar: (seq != "def")}); + } + } else if (typeof seq === "string" && seq.match(/^(be|beq|beqa|bea)$/i)) { + seenUserDefinedBeginEquation = true; + } else if (typeof seq === "string" && seq.match(/^(ee|eeq|eeqn|eeqa|eeqan|eea)$/i)) { + seenUserDefinedEndEquation = true; + } else if (seq === "newcommand" || seq === "renewcommand" || seq === "DeclareRobustCommand") { + var newPos = read1arg(TokeniseResult, i, {allowStar: true}); if (newPos === null) { continue; } else {i = newPos;}; newPos = readOptionalParams(TokeniseResult, i); if (newPos === null) { /* do nothing */ } else {i = newPos;}; newPos = readDefinition(TokeniseResult, i); if (newPos === null) { /* do nothing */ } else {i = newPos;}; + } else if (seq === "def") { + newPos = read1arg(TokeniseResult, i); + if (newPos === null) { continue; } else {i = newPos;}; + newPos = readOptionalDef(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + + } else if (seq === "let") { + newPos = readLetDefinition(TokeniseResult, i); + if (newPos === null) { continue; } else {i = newPos;}; + } else if (seq === "newcolumntype") { newPos = read1name(TokeniseResult, i); if (newPos === null) { continue; } else {i = newPos;}; @@ -1791,128 +1908,435 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { } else if (seq === "url") { newPos = readUrl(TokeniseResult, i); if (newPos === null) { TokenError(token, "invalid url command"); } else {i = newPos;}; + } else if (seq === "left" || seq === "right") { + var nextToken = Tokens[i+1]; + char = ""; + if (nextToken && nextToken[1] === "Text") { + char = text.substring(nextToken[2], nextToken[2] + 1); + } else if (nextToken && nextToken[1] === "\\" && nextToken[5] == "control-symbol") { + char = nextToken[4]; + } else if (nextToken && nextToken[1] === "\\") { + char = "unknown"; + } + if (char === "" || (char !== "unknown" && "(){}[]<>/|\\.".indexOf(char) === -1)) { + TokenError(token, "invalid bracket command"); + } else { + i = i + 1; + Environments.push({command:seq, token:token}); + }; + } else if (seq === "(" || seq === ")" || seq === "[" || seq === "]") { + Environments.push({command:seq, token:token}); + } else if (seq === "input") { + newPos = read1filename(TokeniseResult, i); + if (newPos === null) { continue; } else {i = newPos;}; + } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") { + nextGroupMathMode = false; + } else if (seq === "rotatebox" || seq === "scalebox") { + newPos = readOptionalGeneric(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + nextGroupMathMode = false; + } else if (seq === "resizebox") { + newPos = readOptionalGeneric(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + + nextGroupMathMode = false; + } else if (seq === "DeclareMathOperator") { + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + } else if (seq === "DeclarePairedDelimiter") { + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + } else if (typeof seq === "string" && seq.match(/^(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)$/)) { + var currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) + if (currentMathMode === null) { + TokenError(token, type + seq + " must be inside math mode", {mathMode:true}); + }; + } else if (typeof seq === "string" && seq.match(/^(chapter|section|subsection|subsubsection)$/)) { + currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) + if (currentMathMode) { + TokenError(token, type + seq + " used inside math mode", {mathMode:true}); + Environments.resetMathMode(); + }; + } else if (typeof seq === "string" && seq.match(/^[a-z]+$/)) { + nextGroupMathMode = undefined; + }; + + } else if (type === "$") { + var lookAhead = Tokens[i+1]; + var nextIsDollar = lookAhead && lookAhead[1] === "$"; + currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) + if (nextIsDollar && (!currentMathMode || currentMathMode.command == "$$")) { + Environments.push({command:"$$", token:token}); + i = i + 1; + } else { + Environments.push({command:"$", token:token}); } - } else if (type === "{") { - Environments.push({command:"{", token:token}); - } else if (type === "}") { - Environments.push({command:"}", token:token}); - }; + } else if (type === "^" || type === "_") { + currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) + var insideGroup = Environments.insideGroup(); // true if inside {....} + if (currentMathMode === null && !insideGroup) { + TokenError(token, type + " must be inside math mode", {mathMode:true}); + }; + } }; + + if (seenUserDefinedBeginEquation && seenUserDefinedEndEquation) { + ErrorReporter.filterMath = true; + }; + return Environments; }; - -var CheckEnvironments = function (Environments, ErrorReporter) { +var EnvHandler = function (ErrorReporter) { var ErrorTo = ErrorReporter.EnvErrorTo; var ErrorFromTo = ErrorReporter.EnvErrorFromTo; var ErrorFrom = ErrorReporter.EnvErrorFrom; + var envs = []; + var state = []; var documentClosed = null; var inVerbatim = false; var verbatimRanges = []; - for (var i = 0, len = Environments.length; i < len; i++) { - var name = Environments[i].name ; - if (name && name.match(/^(verbatim|boxedverbatim|lstlisting|minted)$/)) { - Environments[i].verbatim = true; + + this.Environments = envs; + + this.push = function (newEnv) { + this.setEnvProps(newEnv); + this.checkAndUpdateState(newEnv); + envs.push(newEnv); + }; + + this._endVerbatim = function (thisEnv) { + var lastEnv = state.pop(); + if (lastEnv && lastEnv.name === thisEnv.name) { + inVerbatim = false; + verbatimRanges.push({start: lastEnv.token[2], end: thisEnv.token[2]}); + } else { + if(lastEnv) { state.push(lastEnv); } ; } - } - for (i = 0, len = Environments.length; i < len; i++) { - var thisEnv = Environments[i]; - if(thisEnv.command === "begin" || thisEnv.command === "{") { - if (inVerbatim) { continue; } // ignore anything in verbatim environments - if (thisEnv.verbatim) {inVerbatim = true;}; - state.push(thisEnv); - } else if (thisEnv.command === "end" || thisEnv.command === "}") { + }; + + var invalidEnvs = []; + + this._end = function (thisEnv) { + do { var lastEnv = state.pop(); + var retry = false; + var i; - if (inVerbatim) { - if (lastEnv && lastEnv.name === thisEnv.name) { - inVerbatim = false; - verbatimRanges.push({start: lastEnv.token[2], end: thisEnv.token[2]}); - continue; - } else { - if(lastEnv) { state.push(lastEnv); } ; - continue; // ignore all other commands - } - }; - - if (lastEnv && lastEnv.command === "{" && thisEnv.command === "}") { - continue; - } else if (lastEnv && lastEnv.name === thisEnv.name) { - if (thisEnv.name === "document" && !documentClosed) { + if (closedBy(lastEnv, thisEnv)) { + if (thisEnv.command === "end" && thisEnv.name === "document" && !documentClosed) { documentClosed = thisEnv; }; - continue; + return; } else if (!lastEnv) { - if (thisEnv.command === "}") { - if (documentClosed) { - ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected end group }",{errorAtStart: true, type: "info"}); - } else { - ErrorTo(thisEnv, "unexpected end group }"); - }; - } else if (thisEnv.command === "end") { - if (documentClosed) { - ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"}); - } else { - ErrorTo(thisEnv, "unexpected \\end{" + thisEnv.name + "}"); - } + if (documentClosed) { + ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"}); + } else { + ErrorTo(thisEnv, "unexpected " + getName(thisEnv)); } - } else if (lastEnv.command === "begin" && thisEnv.command === "}") { - ErrorFromTo(lastEnv, thisEnv, "unexpected end group } after \\begin{" + lastEnv.name +"}"); - state.push(lastEnv); - } else if (lastEnv.command === "{" && thisEnv.command === "end") { - ErrorFromTo(lastEnv, thisEnv, - "unclosed group { found at \\end{" + thisEnv.name + "}", - {suppressIfEditing:true, errorAtStart: true, type:"warning"}); - i--; - } else if (lastEnv.command === "begin" && thisEnv.command === "end") { - ErrorFromTo(lastEnv, thisEnv, - "unclosed \\begin{" + lastEnv.name + "} found at \\end{" + thisEnv.name + "} " , - {errorAtStart: true}); - for (var j = i + 1; j < len; j++) { - var futureEnv = Environments[j]; - if (futureEnv.command === "end" && futureEnv.name === lastEnv.name) { - state.push(lastEnv); - continue; - } - } - lastEnv = state.pop(); - if(lastEnv) { - if (thisEnv.name === lastEnv.name) { - continue; - } else { - state.push(lastEnv); + } else if (invalidEnvs.length > 0 && (i = indexOfClosingEnvInArray(invalidEnvs, thisEnv) > -1)) { + invalidEnvs.splice(i, 1); + if (lastEnv) { state.push(lastEnv); } ; + return; + } else { + var status = reportError(lastEnv, thisEnv); + if (envPrecedence(lastEnv) < envPrecedence(thisEnv)) { + invalidEnvs.push(lastEnv); + retry = true; + } else { + var prevLastEnv = state.pop(); + if(prevLastEnv) { + if (thisEnv.name === prevLastEnv.name) { + return; + } else { + state.push(prevLastEnv); + } } + invalidEnvs.push(lastEnv); } } + } while (retry === true); + }; + + var CLOSING_DELIMITER = { + "{" : "}", + "left" : "right", + "[" : "]", + "(" : ")", + "$" : "$", + "$$": "$$" + }; + + var closedBy = function (lastEnv, thisEnv) { + if (!lastEnv) { + return false ; + } else if (thisEnv.command === "end") { + return lastEnv.command === "begin" && lastEnv.name === thisEnv.name; + } else if (thisEnv.command === CLOSING_DELIMITER[lastEnv.command]) { + return true; + } else { + return false; } - } - while (state.length > 0) { - thisEnv = state.pop(); - if (thisEnv.command === "{") { - ErrorFrom(thisEnv, "unclosed group {", {type:"warning"}); - } else if (thisEnv.command === "begin") { - ErrorFrom(thisEnv, "unclosed environment \\begin{" + thisEnv.name + "}"); + }; + + var indexOfClosingEnvInArray = function (envs, thisEnv) { + for (var i = 0, n = envs.length; i < n ; i++) { + if (closedBy(envs[i], thisEnv)) { + return i; + } + } + return -1; + }; + + var envPrecedence = function (env) { + var openScore = { + "{" : 1, + "left" : 2, + "$" : 3, + "$$" : 4, + "begin": 4 }; - } - var vlen = verbatimRanges.length; - len = ErrorReporter.tokenErrors.length; - if (vlen >0 && len > 0) { - for (i = 0; i < len; i++) { - var tokenError = ErrorReporter.tokenErrors[i]; - var startPos = tokenError.startPos; - var endPos = tokenError.endPos; - for (j = 0; j < vlen; j++) { - if (startPos > verbatimRanges[j].start && startPos < verbatimRanges[j].end) { - tokenError.ignore = true; - break; + var closeScore = { + "}" : 1, + "right" : 2, + "$" : 3, + "$$" : 5, + "end": 4 + }; + if (env.command) { + return openScore[env.command] || closeScore[env.command]; + } else { + return 0; + } + }; + + var getName = function(env) { + var description = { + "{" : "open group {", + "}" : "close group }", + "[" : "open display math \\[", + "]" : "close display math \\]", + "(" : "open inline math \\(", + ")" : "close inline math \\)", + "$" : "$", + "$$" : "$$", + "left" : "\\left", + "right" : "\\right" + }; + if (env.command === "begin" || env.command === "end") { + return "\\" + env.command + "{" + env.name + "}"; + } else if (env.command in description) { + return description[env.command]; + } else { + return env.command; + } + }; + + var EXTRA_CLOSE = 1; + var UNCLOSED_GROUP = 2; + var UNCLOSED_ENV = 3; + + var reportError = function(lastEnv, thisEnv) { + if (!lastEnv) { // unexpected close, nothing was open! + if (documentClosed) { + ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected end group }",{errorAtStart: true, type: "info"}); + } else { + ErrorTo(thisEnv, "unexpected " + getName(thisEnv)); + }; + return EXTRA_CLOSE; + } else if (lastEnv.command === "{" && thisEnv.command === "end") { + ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv), + {suppressIfEditing:true, errorAtStart: true, type:"warning"}); + return UNCLOSED_GROUP; + } else { + var pLast = envPrecedence(lastEnv); + var pThis = envPrecedence(thisEnv); + if (pThis > pLast) { + ErrorFromTo(lastEnv, thisEnv, "unclosed " + getName(lastEnv) + " found at " + getName(thisEnv), + {suppressIfEditing:true, errorAtStart: true}); + } else { + ErrorFromTo(lastEnv, thisEnv, "unexpected " + getName(thisEnv) + " after " + getName(lastEnv)); + } + return UNCLOSED_ENV; + }; + }; + + this._beginMathMode = function (thisEnv) { + var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env + if (currentMathMode) { + ErrorFrom(thisEnv, thisEnv.name + " used inside existing math mode " + getName(currentMathMode), + {suppressIfEditing:true, errorAtStart: true, mathMode:true}); + }; + thisEnv.mathMode = thisEnv; + state.push(thisEnv); + }; + + this._toggleMathMode = function (thisEnv) { + var lastEnv = state.pop(); + if (closedBy(lastEnv, thisEnv)) { + return; + } else { + if (lastEnv) {state.push(lastEnv);} + if (lastEnv && lastEnv.mathMode) { + this._end(thisEnv); + } else { + thisEnv.mathMode = thisEnv; + state.push(thisEnv); + } + }; + }; + + this.getMathMode = function () { + var n = state.length; + if (n > 0) { + return state[n-1].mathMode; + } else { + return null; + } + }; + + this.insideGroup = function () { + var n = state.length; + if (n > 0) { + return (state[n-1].command === "{"); + } else { + return null; + } + }; + + var resetMathMode = function () { + var n = state.length; + if (n > 0) { + var lastMathMode = state[n-1].mathMode; + do { + var lastEnv = state.pop(); + } while (lastEnv && lastEnv !== lastMathMode); + } else { + return; + } + }; + + this.resetMathMode = resetMathMode; + + var getNewMathMode = function (currentMathMode, thisEnv) { + var newMathMode = null; + + if (thisEnv.command === "{") { + if (thisEnv.mathMode !== null) { + newMathMode = thisEnv.mathMode; + } else { + newMathMode = currentMathMode; + } + } else if (thisEnv.command === "left") { + if (currentMathMode === null) { + ErrorFrom(thisEnv, "\\left can only be used in math mode", {mathMode: true}); + }; + newMathMode = currentMathMode; + } else if (thisEnv.command === "begin") { + var name = thisEnv.name; + if (name) { + if (name.match(/^(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/)) { + if (currentMathMode) { + ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode), + {suppressIfEditing:true, errorAtStart: true, mathMode: true}); + resetMathMode(); + }; + newMathMode = null; + } else if (name.match(/^(array|gathered|split|aligned|alignedat)\*?$/)) { + if (currentMathMode === null) { + ErrorFrom(thisEnv, thisEnv.name + " not inside math mode", {mathMode: true}); + }; + newMathMode = currentMathMode; + } else if (name.match(/^(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?$/)) { + if (currentMathMode) { + ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode), + {suppressIfEditing:true, errorAtStart: true, mathMode: true}); + resetMathMode(); + }; + newMathMode = thisEnv; + } else { + newMathMode = undefined; // undefined means we don't know if we are in math mode or not + } + } + }; + return newMathMode; + }; + + this.checkAndUpdateState = function (thisEnv) { + if (inVerbatim) { + if (thisEnv.command === "end") { + this._endVerbatim(thisEnv); + } else { + return; // ignore anything in verbatim environments + } + } else if(thisEnv.command === "begin" || thisEnv.command === "{" || thisEnv.command === "left") { + if (thisEnv.verbatim) {inVerbatim = true;}; + var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env + var newMathMode = getNewMathMode(currentMathMode, thisEnv); + thisEnv.mathMode = newMathMode; + state.push(thisEnv); + } else if (thisEnv.command === "end") { + this._end(thisEnv); + } else if (thisEnv.command === "(" || thisEnv.command === "[") { + this._beginMathMode(thisEnv); + } else if (thisEnv.command === ")" || thisEnv.command === "]") { + this._end(thisEnv); + } else if (thisEnv.command === "}") { + this._end(thisEnv); + } else if (thisEnv.command === "right") { + this._end(thisEnv); + } else if (thisEnv.command === "$" || thisEnv.command === "$$") { + this._toggleMathMode(thisEnv); + } + }; + + this.close = function () { + while (state.length > 0) { + var thisEnv = state.pop(); + if (thisEnv.command === "{") { + ErrorFrom(thisEnv, "unclosed group {", {type:"warning"}); + } else { + ErrorFrom(thisEnv, "unclosed " + getName(thisEnv)); + } + } + var vlen = verbatimRanges.length; + var len = ErrorReporter.tokenErrors.length; + if (vlen >0 && len > 0) { + for (var i = 0; i < len; i++) { + var tokenError = ErrorReporter.tokenErrors[i]; + var startPos = tokenError.startPos; + var endPos = tokenError.endPos; + for (var j = 0; j < vlen; j++) { + if (startPos > verbatimRanges[j].start && startPos < verbatimRanges[j].end) { + tokenError.ignore = true; + break; + } } } } - } + }; + this.setEnvProps = function (env) { + var name = env.name ; + if (name && name.match(/^(verbatim|boxedverbatim|lstlisting|minted|Verbatim)$/)) { + env.verbatim = true; + } + }; }; var ErrorReporter = function (TokeniseResult) { var text = TokeniseResult.text; @@ -1922,18 +2346,41 @@ var ErrorReporter = function (TokeniseResult) { var errors = [], tokenErrors = []; this.errors = errors; this.tokenErrors = tokenErrors; + this.filterMath = false; this.getErrors = function () { var returnedErrors = []; for (var i = 0, len = tokenErrors.length; i < len; i++) { if (!tokenErrors[i].ignore) { returnedErrors.push(tokenErrors[i]); } } - return returnedErrors.concat(errors); + var allErrors = returnedErrors.concat(errors); + var result = []; + var mathErrorCount = 0; + for (i = 0, len = allErrors.length; i < len; i++) { + if (allErrors[i].mathMode) { + mathErrorCount++; + } + if (mathErrorCount > 10) { + return []; + } + } + if (this.filterMath && mathErrorCount > 0) { + for (i = 0, len = allErrors.length; i < len; i++) { + if (!allErrors[i].mathMode) { + result.push(allErrors[i]); + } + } + return result; + } else { + return allErrors; + } }; - this.TokenError = function (token, message) { + this.TokenError = function (token, message, options) { + if(!options) { options = { suppressIfEditing:true } ; }; var line = token[0], type = token[1], start = token[2], end = token[3]; var start_col = start - linePosition[line]; + if (!end) { end = start + 1; } ; var end_col = end - linePosition[line]; tokenErrors.push({row: line, column: start_col, @@ -1945,10 +2392,12 @@ var ErrorReporter = function (TokeniseResult) { text:message, startPos: start, endPos: end, - suppressIfEditing:true}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; - this.TokenErrorFromTo = function (fromToken, toToken, message) { + this.TokenErrorFromTo = function (fromToken, toToken, message, options) { + if(!options) { options = {suppressIfEditing:true } ; }; var fromLine = fromToken[0], fromStart = fromToken[2], fromEnd = fromToken[3]; var toLine = toToken[0], toStart = toToken[2], toEnd = toToken[3]; if (!toEnd) { toEnd = toStart + 1;}; @@ -1965,7 +2414,8 @@ var ErrorReporter = function (TokeniseResult) { text:message, startPos: fromStart, endPos: toEnd, - suppressIfEditing:true}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; @@ -1986,7 +2436,8 @@ var ErrorReporter = function (TokeniseResult) { end_col: end_col, type: options.type ? options.type : "error", text:message, - suppressIfEditing:options.suppressIfEditing}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; this.EnvErrorTo = function (toEnv, message, options) { @@ -2002,7 +2453,8 @@ var ErrorReporter = function (TokeniseResult) { end_row: line, end_col: end_col, type: options.type ? options.type : "error", - text:message}; + text:message, + mathMode: options.mathMode}; errors.push(err); }; @@ -2019,7 +2471,8 @@ var ErrorReporter = function (TokeniseResult) { end_row: lineNumber, end_col: end_col, type: options.type ? options.type : "error", - text:message}); + text:message, + mathMode: options.mathMode}); }; }; @@ -2027,7 +2480,7 @@ var Parse = function (text) { var TokeniseResult = Tokenise(text); var Reporter = new ErrorReporter(TokeniseResult); var Environments = InterpretTokens(TokeniseResult, Reporter); - CheckEnvironments(Environments, Reporter); + Environments.close(); return Reporter.getErrors(); }; diff --git a/services/web/public/js/ace-1.2.5/worker-latex_beta.js b/services/web/public/js/ace-1.2.5/worker-latex_beta.js index b47d8f0a46..720c3e5009 100644 --- a/services/web/public/js/ace-1.2.5/worker-latex_beta.js +++ b/services/web/public/js/ace-1.2.5/worker-latex_beta.js @@ -1554,6 +1554,25 @@ var read1arg = function (TokeniseResult, k, options) { } }; +var readLetDefinition = function (TokeniseResult, k) { + + var Tokens = TokeniseResult.tokens; + var text = TokeniseResult.text; + + var first = Tokens[k+1]; + var second = Tokens[k+2]; + var third = Tokens[k+3]; + + if(first && first[1] === "\\" && second && second[1] === "\\") { + return k + 2; + } else if(first && first[1] === "\\" && + second && second[1] === "Text" && text.substring(second[2], second[3]) === "=" && + third && third[1] === "\\") { + return k + 3; + } else { + return null; + } +}; var read1name = function (TokeniseResult, k) { var Tokens = TokeniseResult.tokens; @@ -1624,9 +1643,56 @@ var readOptionalParams = function(TokeniseResult, k) { return k + 1; // got it }; }; + var count = 0; + var nextToken = Tokens[k+1]; + var pos = nextToken[2]; + + for (var i = pos, end = text.length; i < end; i++) { + var char = text[i]; + if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];}; + if (char === "[") { count++; } + if (char === "]") { count--; } + if (count === 0 && char === "{") { return k - 1; } + if (count > 0 && (char === '\r' || char === '\n')) { return null; } + }; return null; }; +var readOptionalGeneric = function(TokeniseResult, k) { + var Tokens = TokeniseResult.tokens; + var text = TokeniseResult.text; + + var params = Tokens[k+1]; + + if(params && params[1] === "Text") { + var paramNum = text.substring(params[2], params[3]); + if (paramNum.match(/^(\[[^\]]*\])+\s*$/)) { + return k + 1; // got it + }; + }; + return null; +}; + +var readOptionalDef = function (TokeniseResult, k) { + var Tokens = TokeniseResult.tokens; + var text = TokeniseResult.text; + + var defToken = Tokens[k]; + var pos = defToken[3]; + + var openBrace = "{"; + var nextToken = Tokens[k+1]; + for (var i = pos, end = text.length; i < end; i++) { + var char = text[i]; + if (nextToken && i >= nextToken[2]) { k++; nextToken = Tokens[k+1];}; + if (char === openBrace) { return k - 1; }; // move back to the last token of the optional arguments + if (char === '\r' || char === '\n') { return null; } + }; + + return null; + +}; + var readDefinition = function(TokeniseResult, k) { var Tokens = TokeniseResult.tokens; var text = TokeniseResult.text; @@ -1726,10 +1792,27 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { var Environments = new EnvHandler(ErrorReporter); var nextGroupMathMode = null; // if the next group should have math mode on or off (for \hbox) + var nextGroupMathModeStack = [] ; // tracking all nextGroupMathModes + var seenUserDefinedBeginEquation = false; // if we have seen macros like \beq + var seenUserDefinedEndEquation = false; // if we have seen macros like \eeq for (var i = 0, len = Tokens.length; i < len; i++) { var token = Tokens[i]; var line = token[0], type = token[1], start = token[2], end = token[3], seq = token[4]; + + if (type === "{") { + Environments.push({command:"{", token:token, mathMode: nextGroupMathMode}); + nextGroupMathModeStack.push(nextGroupMathMode); + nextGroupMathMode = null; + continue; + } else if (type === "}") { + Environments.push({command:"}", token:token}); + nextGroupMathMode = nextGroupMathModeStack.pop(); + continue; + } else { + nextGroupMathMode = null; + }; + if (type === "\\") { if (seq === "begin" || seq === "end") { var open = Tokens[i+1]; @@ -1778,15 +1861,31 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { } else { TokenError(token, "invalid environment command"); }; - } - } else if (seq === "newcommand" || seq === "renewcommand" || seq === "def" || seq === "DeclareRobustCommand") { - var newPos = read1arg(TokeniseResult, i, {allowStar: (seq != "def")}); + } + } else if (typeof seq === "string" && seq.match(/^(be|beq|beqa|bea)$/i)) { + seenUserDefinedBeginEquation = true; + } else if (typeof seq === "string" && seq.match(/^(ee|eeq|eeqn|eeqa|eeqan|eea)$/i)) { + seenUserDefinedEndEquation = true; + } else if (seq === "newcommand" || seq === "renewcommand" || seq === "DeclareRobustCommand") { + var newPos = read1arg(TokeniseResult, i, {allowStar: true}); if (newPos === null) { continue; } else {i = newPos;}; newPos = readOptionalParams(TokeniseResult, i); if (newPos === null) { /* do nothing */ } else {i = newPos;}; newPos = readDefinition(TokeniseResult, i); if (newPos === null) { /* do nothing */ } else {i = newPos;}; + } else if (seq === "def") { + newPos = read1arg(TokeniseResult, i); + if (newPos === null) { continue; } else {i = newPos;}; + newPos = readOptionalDef(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + + } else if (seq === "let") { + newPos = readLetDefinition(TokeniseResult, i); + if (newPos === null) { continue; } else {i = newPos;}; + } else if (seq === "newcolumntype") { newPos = read1name(TokeniseResult, i); if (newPos === null) { continue; } else {i = newPos;}; @@ -1820,7 +1919,7 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { } else if (nextToken && nextToken[1] === "\\") { char = "unknown"; } - if (char === "" || (char !== "unknown" && "(){}[]<>|.".indexOf(char) === -1)) { + if (char === "" || (char !== "unknown" && "(){}[]<>/|\\.".indexOf(char) === -1)) { TokenError(token, "invalid bracket command"); } else { i = i + 1; @@ -1831,25 +1930,50 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { } else if (seq === "input") { newPos = read1filename(TokeniseResult, i); if (newPos === null) { continue; } else {i = newPos;}; - } else if (seq === "hbox" || seq === "text" || seq === "mbox") { + } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") { nextGroupMathMode = false; + } else if (seq === "rotatebox" || seq === "scalebox") { + newPos = readOptionalGeneric(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + nextGroupMathMode = false; + } else if (seq === "resizebox") { + newPos = readOptionalGeneric(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + + nextGroupMathMode = false; + } else if (seq === "DeclareMathOperator") { + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + } else if (seq === "DeclarePairedDelimiter") { + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; + newPos = readDefinition(TokeniseResult, i); + if (newPos === null) { /* do nothing */ } else {i = newPos;}; } else if (typeof seq === "string" && seq.match(/^(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)$/)) { var currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) - if (currentMathMode === null && !insideGroup) { - TokenError(token, type + seq + " must be inside math mode"); + if (currentMathMode === null) { + TokenError(token, type + seq + " must be inside math mode", {mathMode:true}); }; - } else if (typeof seq === "string" && seq.match(/^(chapter|section|subsection|subsubsection|cite|ref)/)) { + } else if (typeof seq === "string" && seq.match(/^(chapter|section|subsection|subsubsection)$/)) { currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) - if (currentMathMode && !insideGroup) { - TokenError(token, type + seq + " used inside math mode"); + if (currentMathMode) { + TokenError(token, type + seq + " used inside math mode", {mathMode:true}); Environments.resetMathMode(); }; + } else if (typeof seq === "string" && seq.match(/^[a-z]+$/)) { + nextGroupMathMode = undefined; }; - } else if (type === "{") { - Environments.push({command:"{", token:token, mathMode: nextGroupMathMode}); - nextGroupMathMode = null; - } else if (type === "}") { - Environments.push({command:"}", token:token}); + } else if (type === "$") { var lookAhead = Tokens[i+1]; var nextIsDollar = lookAhead && lookAhead[1] === "$"; @@ -1864,12 +1988,15 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) { currentMathMode = Environments.getMathMode() ; // returns null / $(inline) / $$(display) var insideGroup = Environments.insideGroup(); // true if inside {....} if (currentMathMode === null && !insideGroup) { - TokenError(token, type + " must be inside math mode"); + TokenError(token, type + " must be inside math mode", {mathMode:true}); }; - } else { - nextGroupMathMode = null; } }; + + if (seenUserDefinedBeginEquation && seenUserDefinedEndEquation) { + ErrorReporter.filterMath = true; + }; + return Environments; }; @@ -1920,7 +2047,7 @@ var EnvHandler = function (ErrorReporter) { if (documentClosed) { ErrorFromTo(documentClosed, thisEnv, "\\end{" + documentClosed.name + "} is followed by unexpected content",{errorAtStart: true, type: "info"}); } else { - ErrorTo(thisEnv, "unexpected \\end{" + thisEnv.name + "}"); + ErrorTo(thisEnv, "unexpected " + getName(thisEnv)); } } else if (invalidEnvs.length > 0 && (i = indexOfClosingEnvInArray(invalidEnvs, thisEnv) > -1)) { invalidEnvs.splice(i, 1); @@ -2054,7 +2181,7 @@ var EnvHandler = function (ErrorReporter) { var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env if (currentMathMode) { ErrorFrom(thisEnv, thisEnv.name + " used inside existing math mode " + getName(currentMathMode), - {suppressIfEditing:true, errorAtStart: true}); + {suppressIfEditing:true, errorAtStart: true, mathMode:true}); }; thisEnv.mathMode = thisEnv; state.push(thisEnv); @@ -2118,28 +2245,28 @@ var EnvHandler = function (ErrorReporter) { } } else if (thisEnv.command === "left") { if (currentMathMode === null) { - ErrorFrom(thisEnv, "\\left can only be used in math mode"); + ErrorFrom(thisEnv, "\\left can only be used in math mode", {mathMode: true}); }; newMathMode = currentMathMode; } else if (thisEnv.command === "begin") { var name = thisEnv.name; if (name) { - if (name.match(/^(document|figure|center|tabular|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/)) { + if (name.match(/^(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/)) { if (currentMathMode) { ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode), - {suppressIfEditing:true, errorAtStart: true}); + {suppressIfEditing:true, errorAtStart: true, mathMode: true}); resetMathMode(); }; newMathMode = null; - } else if (name.match(/^(array|gathered|split|aligned|alignedat)/)) { - if (!currentMathMode) { - ErrorFrom(thisEnv, thisEnv.name + " not inside math mode"); + } else if (name.match(/^(array|gathered|split|aligned|alignedat)\*?$/)) { + if (currentMathMode === null) { + ErrorFrom(thisEnv, thisEnv.name + " not inside math mode", {mathMode: true}); }; newMathMode = currentMathMode; } else if (name.match(/^(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?$/)) { if (currentMathMode) { ErrorFromTo(currentMathMode, thisEnv, thisEnv.name + " used inside " + getName(currentMathMode), - {suppressIfEditing:true, errorAtStart: true}); + {suppressIfEditing:true, errorAtStart: true, mathMode: true}); resetMathMode(); }; newMathMode = thisEnv; @@ -2220,13 +2347,36 @@ var ErrorReporter = function (TokeniseResult) { var errors = [], tokenErrors = []; this.errors = errors; this.tokenErrors = tokenErrors; + this.filterMath = false; this.getErrors = function () { var returnedErrors = []; for (var i = 0, len = tokenErrors.length; i < len; i++) { if (!tokenErrors[i].ignore) { returnedErrors.push(tokenErrors[i]); } } - return returnedErrors.concat(errors); + var allErrors = returnedErrors.concat(errors); + var result = []; + + var mathErrorCount = 0; + for (i = 0, len = allErrors.length; i < len; i++) { + if (allErrors[i].mathMode) { + mathErrorCount++; + } + if (mathErrorCount > 10) { + return []; + } + } + + if (this.filterMath && mathErrorCount > 0) { + for (i = 0, len = allErrors.length; i < len; i++) { + if (!allErrors[i].mathMode) { + result.push(allErrors[i]); + } + } + return result; + } else { + return allErrors; + } }; this.TokenError = function (token, message, options) { @@ -2245,7 +2395,8 @@ var ErrorReporter = function (TokeniseResult) { text:message, startPos: start, endPos: end, - suppressIfEditing:options.suppressIfEditing}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; this.TokenErrorFromTo = function (fromToken, toToken, message, options) { @@ -2266,7 +2417,8 @@ var ErrorReporter = function (TokeniseResult) { text:message, startPos: fromStart, endPos: toEnd, - suppressIfEditing:options.suppressIfEditing}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; @@ -2287,7 +2439,8 @@ var ErrorReporter = function (TokeniseResult) { end_col: end_col, type: options.type ? options.type : "error", text:message, - suppressIfEditing:options.suppressIfEditing}); + suppressIfEditing:options.suppressIfEditing, + mathMode: options.mathMode}); }; this.EnvErrorTo = function (toEnv, message, options) { @@ -2303,7 +2456,8 @@ var ErrorReporter = function (TokeniseResult) { end_row: line, end_col: end_col, type: options.type ? options.type : "error", - text:message}; + text:message, + mathMode: options.mathMode}; errors.push(err); }; @@ -2320,7 +2474,8 @@ var ErrorReporter = function (TokeniseResult) { end_row: lineNumber, end_col: end_col, type: options.type ? options.type : "error", - text:message}); + text:message, + mathMode: options.mathMode}); }; }; diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 5a4d7feed1..7847822bf9 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -35,6 +35,10 @@ aside#file-tree { line-height: 2.6; position: relative; + .entity { + user-select: none; + } + .entity-name { color: @gray-darker; cursor: pointer; diff --git a/services/web/public/stylesheets/app/editor/review-panel.less b/services/web/public/stylesheets/app/editor/review-panel.less index d14e843591..0d4c0032e8 100644 --- a/services/web/public/stylesheets/app/editor/review-panel.less +++ b/services/web/public/stylesheets/app/editor/review-panel.less @@ -1,42 +1,61 @@ -@rp-base-font-size : 12px; -@rp-small-font-size : 10px; -@rp-icon-large-size : 22px; +@rp-base-font-size : 12px; +@rp-small-font-size : 10px; +@rp-icon-large-size : 18px; -@rp-bg-blue : #dadfed; -@rp-bg-dim-blue : #fafafa; -@rp-highlight-blue : #8a96b5; +@rp-bg-blue : #dadfed; +@rp-bg-dim-blue : #fafafa; +@rp-highlight-blue : #8a96b5; -@rp-border-grey : #d9d9d9; +@rp-border-grey : #d9d9d9; -@rp-green : #2c8e30; -@rp-dim-green : #cae3cb; -@rp-red : #c5060b; -@rp-dim-red : #f3cdce; -@rp-yellow : #f3b111; -@rp-dim-yellow : #ffe9b2; -@rp-grey : #aaaaaa; +@rp-green : #2c8e30; +@rp-dim-green : #cae3cb; +@rp-green-on-dark : rgba(37, 107, 41, 0.5); +@rp-red : #c5060b; +@rp-dim-red : #f3cdce; +@rp-yellow : #f3b111; +@rp-yellow-on-dark : rgba(194, 93, 11, 0.5); +@rp-dim-yellow : #ffe9b2; +@rp-grey : #aaaaaa; -@rp-type-blue : #6b7797; -@rp-type-darkgrey : #3f3f3f; +@rp-type-blue : #6b7797; +@rp-type-darkgrey : #3f3f3f; + +@rp-entry-ribbon-width : 4px; +@rp-entry-arrow-width : 6px; +@rp-semibold-weight : 600; +@review-panel-width : 230px; +@review-off-width : 22px; + +@rp-toolbar-height : 32px; -@rp-entry-ribbon-width : 4px; -@rp-entry-arrow-width : 6px; -@rp-semibold-weight : 600; -@review-panel-width : 230px; -@review-off-width : 22px; -@rp-toolbar-height: 32px; .rp-button() { + display: block; // IE doesn't do flex with inline items. background-color: @rp-highlight-blue; color: #FFF; text-align: center; + line-height: 1.3; + user-select: none; + border: 0; + &:hover, &:focus { + outline: 0; background-color: darken(@rp-highlight-blue, 5%); text-decoration: none; color: #FFF; } + + &[disabled] { + opacity: 0.5; + + &:hover, + &:focus { + background-color: @rp-highlight-blue; + } + } } .triangle(@_, @width, @height, @color) { @@ -82,6 +101,7 @@ .rp-size-mini & { display: block; width: @review-off-width; + z-index: 6; } position: absolute; @@ -92,6 +112,7 @@ border-left: solid 1px @rp-border-grey; font-size: @rp-base-font-size; color: @rp-type-blue; + z-index: 6; } .review-panel-toolbar { @@ -102,23 +123,27 @@ justify-content: space-between; padding: 0 5px; } - .rp-state-current-file & { - position: absolute; - top: 0; - left: 0; - right: 0; - } + + position: relative; height: @rp-toolbar-height; border-bottom: 1px solid @rp-border-grey; background-color: @rp-bg-dim-blue; text-align: center; - z-index: 2; + z-index: 3; flex-basis: 32px; flex-shrink: 0; } .review-panel-toolbar-label { cursor: pointer; - margin-right: 5px; + text-align: right; + flex-grow: 1; + } + .review-panel-toolbar-label-disabled { + cursor: auto; + margin-right: 5px; + } + .review-panel-toolbar-spinner { + margin-left: 5px; } .rp-entry-list { @@ -135,7 +160,6 @@ bottom: 0; } - .rp-state-overview & { flex-grow: 2; overflow-y: auto; @@ -150,7 +174,6 @@ display: none; .rp-size-mini & { display: block; - z-index: 12; } position: absolute; left: 2px; @@ -161,6 +184,9 @@ color: #FFF; cursor: pointer; transition: top 0.3s, left 0.1s, right 0.1s; + .no-animate & { + transition: none; + } &-focused { left: 0px; @@ -186,21 +212,39 @@ display: none; left: @review-off-width + @rp-entry-arrow-width; box-shadow: 0 0 10px 5px rgba(0, 0, 0, .2); - z-index: 11; + z-index: 1; &::before { - .triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit); - top: (@review-off-width / 2) - @rp-entry-arrow-width; - left: -(@rp-entry-ribbon-width + @rp-entry-arrow-width); - content: ''; - } - &::after { content: ''; position: absolute; top: -(@review-off-width + @rp-entry-arrow-width); right: -(@review-off-width + @rp-entry-arrow-width); bottom: -(@review-off-width + @rp-entry-arrow-width); + left: -(2 * @rp-entry-arrow-width + 2); + z-index: -1; + } + &::after { + .triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit); + top: (@review-off-width / 2) - @rp-entry-arrow-width; + left: -(@rp-entry-ribbon-width + @rp-entry-arrow-width); + content: ''; + } + } + .rp-state-current-file-mini.rp-layout-left & { + left: auto; + right: @review-off-width + @rp-entry-arrow-width; + border-left-width: 0; + border-right-width: @rp-entry-ribbon-width; + border-right-style: solid; + + &::before { left: -(@review-off-width + @rp-entry-arrow-width); + right: -(2 * @rp-entry-arrow-width + 2); + } + &::after { + .triangle(right, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit); + right: -(@rp-entry-ribbon-width + @rp-entry-arrow-width); + left: auto; } } .rp-state-current-file-expanded & { @@ -224,15 +268,21 @@ } .rp-state-overview & { border-radius: 0; - padding: 2px 5px; border-bottom: solid 1px @rp-border-grey; cursor: pointer; } + .resolved-comments-dropdown & { + position: static; + margin-bottom: 5px; + } border-left: solid @rp-entry-ribbon-width transparent; border-radius: 3px; background-color: #FFF; transition: top 0.3s, left 0.1s, right 0.1s; + .no-animate & { + transition: none; + } &-insert { border-color: @rp-green; @@ -246,6 +296,16 @@ border-color: @rp-yellow; } + &-comment-resolving { + top: 4px; + left: 6px; + opacity: 0; + z-index: 3; + transform: scale(.1); + transform-origin: 0 0; + transition: top .35s ease-out, left .35s ease-out, transform .35s ease-out, opacity .35s ease-out .2s; + } + &-comment-resolved { border-color: @rp-grey; background-color: #efefef; @@ -264,69 +324,59 @@ } } } - - .rp-entry-header { + .rp-entry-body { display: flex; align-items: center; - padding: 5px; - - .rp-state-overview & { - padding: 0px; - } + padding: 4px 5px; } .rp-entry-action-icon { font-size: @rp-icon-large-size; - padding: 0 5px; + padding: 0 3px; line-height: 0; .rp-state-overview & { - font-size: @rp-base-font-size; - padding: 0px; - margin-right: 5px; + display: none; } } - .rp-entry-metadata { - flex-grow: 1; - padding: 0 5px; - line-height: 1.2; + .rp-entry-details { + line-height: 1.4; + margin-left: 5px; + // We need to set any low-enough flex base size (0px), making it growable (1) and non-shrinkable (0). + // This is needed to ensure that IE makes the element fill the available space. + flex: 1 0 1px; + overflow-x: auto; .rp-state-overview & { - display: flex; - line-height: inherit; - padding: 0; + margin-left: 0; } } - .rp-entry-metadata-line { - margin: 0; - .rp-state-overview &:last-of-type { - flex-grow: 1; - text-align: right; + .rp-entry-metadata { + font-size: @rp-small-font-size; + } + .rp-entry-user { + font-weight: @rp-semibold-weight; + font-style: normal; + } + .rp-comment-actions { + a { color: @rp-type-blue; } + } + + .rp-content-highlight { + color: @rp-type-darkgrey; + font-weight: @rp-semibold-weight; + text-decoration: none; + + .rp-entry-delete & { + text-decoration: line-through; } } - .rp-entry-body { - padding: 5px; - - .rp-state-overview & { - padding: 0; - } - } - .rp-content-highlight { - color: @rp-type-darkgrey; - font-weight: @rp-semibold-weight; - text-decoration: none; - - .rp-entry-delete & { - text-decoration: line-through; - } - } - .rp-entry-actions { display: flex; - .rp-state-overview & { + .rp-state-overview .rp-entry-list & { display: none; } } @@ -340,71 +390,54 @@ border-bottom-right-radius: 3px; border-right-width: 0; } + + .rp-layout-left & { + &:first-child { + border-bottom-left-radius: 3px; + } + &:last-child { + border-bottom-right-radius: 0; + } + } } .rp-comment { - display: flex; - align-items: flex-start; - padding: 5px; + margin: 2px 5px; + padding-bottom: 3px; + line-height: 1.4; + border-bottom: solid 1px @rp-border-grey; - .rp-state-overview & { - padding: 3px 0; - line-height: 1.2; + &:last-child { + margin-bottom: 2px; + border-bottom-width: 0; + } + + .rp-state-overview .rp-entry-list & { + margin: 4px 5px; + + &:first-child { + margin-top: 0; + padding-top: 4px; + } } } - .rp-comment-body { - position: relative; - background-color: currentColor; - flex-grow: 1; - padding: 2px 5px; - margin-left: @rp-entry-arrow-width; - border-radius: 3px; - - .rp-comment-self & { - margin-left: 0; - margin-right: @rp-entry-arrow-width; - } - - &::after { - .triangle(left, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit); - top: (@review-off-width / 2) - @rp-entry-arrow-width; - left: -@rp-entry-arrow-width; - content: ''; - - .rp-comment-self & { - .triangle(right, @rp-entry-arrow-width, @rp-entry-arrow-width * 1.5, inherit); - right: -@rp-entry-arrow-width; - left: auto; - } - - } + .rp-comment-content { + margin: 0; + color: @rp-type-darkgrey; + overflow-x: auto; // Long words, like links can overflow without this. } - .rp-comment-content { - margin: 0; - color: @rp-type-darkgrey; - } - - .rp-comment-metadata { - color: @rp-type-blue; - font-size: @rp-small-font-size; - margin: 0; - } - - .rp-comment-reply { - padding: 0 5px; - - .rp-state-overview & { - padding: 3px 0 0; - } + + .rp-comment-resolver { + color: @rp-type-blue; + } + .rp-comment-resolver-content { + font-style: italic; + margin: 0; } - .rp-comment-resolved-description { - padding: 5px; - - .rp-state-overview & { - padding: 0px; - } - } + .rp-comment-reply { + padding: 0 5px; + } .rp-add-comment-btn { .rp-button(); @@ -424,26 +457,12 @@ border-radius: 3px; border: solid 1px @rp-border-grey; resize: vertical; + color: @rp-type-darkgrey; + margin-top: 3px; + overflow-x: hidden; + min-height: 3em; } -.rp-avatar { - border-radius: 3px; - font-weight: @rp-semibold-weight; - font-size: @rp-icon-large-size; - line-height: 1.2; - text-transform: uppercase; - color: #FFF; - width: 1.3em; - height: 1.3em; - text-align: center; - flex-grow: 0; - flex-shrink: 0; - - .rp-state-overview & { - display: none; - } -} - .rp-icon-delete { display: inline-block; line-height: 1; @@ -456,6 +475,26 @@ } } +.rp-resolved-comment { + border-left: solid @rp-entry-ribbon-width @rp-yellow; + border-radius: 3px; + background-color: #FFF; + margin-bottom: 5px; +} + .rp-resolved-comment-context { + background-color: lighten(@rp-yellow, 35%); + padding: 4px 5px; + } + .rp-resolved-comment-context-file { + font-weight: @rp-semibold-weight; + } + + .rp-resolved-comment-context-quote { + color: #000; + font-family: @font-family-monospace; + margin: 0; + } + .rp-entry-callout { .rp-state-current-file & { position: absolute; @@ -524,10 +563,24 @@ padding: 2px 5px; border-top: solid 1px @rp-border-grey; border-bottom: solid 1px @rp-border-grey; - background-color: #FFF; + background-color: @rp-bg-dim-blue; margin-top: 10px; font-weight: @rp-semibold-weight; - border-left: solid @rp-entry-ribbon-width currentColor; + text-align: center; +} + +.rp-comment-wrapper { + transition: .35s opacity ease-out .2s; + + &-resolving { + opacity: 0; + } +} + +.rp-loading, +.rp-empty { + text-align: center; + padding: 5px; } .rp-nav { @@ -548,6 +601,7 @@ z-index: 2; } .rp-nav-item { + display: block; color: lighten(@rp-type-blue, 25%); flex: 0 0 50%; border-top: solid 3px transparent; @@ -599,6 +653,7 @@ .rp-toggle { display: inline-block; vertical-align: middle; + padding-left: 5px; } .rp-toggle-hidden-input { display: none; @@ -647,7 +702,7 @@ .track-changes-marker-callout { border-radius: 0; position: absolute; - .rp-state-overview & { + .rp-state-overview &, .rp-loading-threads & { display: none; } } @@ -664,6 +719,9 @@ .track-changes-marker { border-radius: 0; position: absolute; + .rp-loading-threads & { + display: none; + } } .track-changes-comment-marker { @@ -676,13 +734,25 @@ border-left: 2px dotted @rp-red; margin-left: -1px; } + + .ace_dark { + .track-changes-comment-marker { + background-color: @rp-yellow-on-dark + } + .track-changes-added-marker { + background-color: @rp-green-on-dark; + } + } } .review-icon { - position: absolute; + display: inline-block; background: url('/img/review-icon-sprite.png') top/30px no-repeat; width: 30px; - height: 30px; + + &::before { + content: '\00a0'; // Non-breakable space. A non-breakable character here makes this icon work like font-awesome. + } .toolbar .btn-full-height:hover & { background-position-y: -30px; @@ -692,9 +762,126 @@ .toolbar .btn-full-height:active & { background-position-y: -60px; } +} - & + .toolbar-label { - margin-left: 34px; +.resolved-comments-toggle { + font-size: 14px; + color: lighten(@rp-type-blue, 25%); + border: solid 1px @rp-border-grey; + border-radius: 3px; + padding: 0 4px; + display: block; + height: 22px; + width: 22px; + line-height: 1.4; + + &:hover, + &:focus { + text-decoration: none; + color: @rp-type-blue; + } +} + +.resolved-comments-backdrop { + display: none; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + + &-visible { + display: block; + } +} + +.resolved-comments-dropdown { + display: none; + position: absolute; + width: 300px; + left: -150px; + max-height: ~"calc(100vh - 100px)"; + margin-top: @rp-entry-arrow-width * 1.5; + margin-left: 1em; + background-color: @rp-bg-blue; + text-align: left; + align-items: stretch; + justify-content: center; + border-radius: 3px; + box-shadow: 0 0 20px 10px rgba(0, 0, 0, .3); + + &::before { + content: ''; + .triangle(top, @rp-entry-arrow-width * 3, @rp-entry-arrow-width * 1.5, @rp-bg-blue); + top: -@rp-entry-ribbon-width * 2; + left: 50%; + margin-left: -@rp-entry-arrow-width * .75; } + &-open { + display: flex; + } +} + .resolved-comments-scroller { + flex: 0 0 auto; // Can't use 100% in the flex-basis key here, IE won't account for padding. + width: 100%; // We need to set the width explicitly, as flex-basis won't work. + max-height: ~"calc(100vh - 100px)"; // We also need to explicitly set the max-height, IE won't compute the flex-determined height. + padding: 5px; + overflow-y: auto; + } + +.rp-collapse-toggle { + color: @rp-type-blue; + font-weight: @rp-semibold-weight; + + &:hover, + &:focus { + color: darken(@rp-type-blue, 5%); + text-decoration: none; + } +} + +.rp-track-changes-indicator { + display: none; + position: absolute; + top: 0; + right: @review-off-width; + padding: 5px 10px; + background-color: rgba(240, 240, 240, 0.9); + color: @rp-type-blue; + text-align: center; + border-bottom-left-radius: 3px; + font-size: 10px; + z-index: 2; + white-space: nowrap; + + &.rp-track-changes-indicator-on-dark { + background-color: rgba(88, 88, 88, .8); + color: #FFF; + + &:hover, + &:focus { + background-color: rgba(88, 88, 88, 1); + color: #FFF; + } + } + + &:hover, + &:focus { + outline: 0; + text-decoration: none; + background-color: rgba(240, 240, 240, 1); + color: @rp-type-blue; + } + + .rp-size-mini & { + display: block; + } +} + +// Helper class for elements which aren't treated as flex-items by IE10, e.g: +// * inline items; +// * unknown elements (elements which aren't standard DOM elements, such as custom element directives) +.rp-flex-block { + display: block; } diff --git a/services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee index 49e8292f97..daa0da0531 100644 --- a/services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee @@ -10,18 +10,21 @@ expect = require("chai").expect describe 'AnnouncementsHandler', -> beforeEach -> - @user_id = "some_id" + @user = + _id:"some_id" + email: "someone@gmail.com" @AnalyticsManager = getLastOccurance: sinon.stub() @BlogHandler = getLatestAnnouncements:sinon.stub() + @settings = {} @handler = SandboxedModule.require modulePath, requires: "../Analytics/AnalyticsManager":@AnalyticsManager "../Blog/BlogHandler":@BlogHandler + "settings-sharelatex":@settings "logger-sharelatex": log:-> - describe "getUnreadAnnouncements", -> beforeEach -> @stubbedAnnouncements = [ @@ -44,7 +47,7 @@ describe 'AnnouncementsHandler', -> it "should mark all announcements as read is false", (done)-> @AnalyticsManager.getLastOccurance.callsArgWith(2, null, []) - @handler.getUnreadAnnouncements @user_id, (err, announcements)=> + @handler.getUnreadAnnouncements @user, (err, announcements)=> announcements[0].read.should.equal false announcements[1].read.should.equal false announcements[2].read.should.equal false @@ -53,7 +56,7 @@ describe 'AnnouncementsHandler', -> it "should should be sorted again to ensure correct order", (done)-> @AnalyticsManager.getLastOccurance.callsArgWith(2, null, []) - @handler.getUnreadAnnouncements @user_id, (err, announcements)=> + @handler.getUnreadAnnouncements @user, (err, announcements)=> announcements[3].should.equal @stubbedAnnouncements[2] announcements[2].should.equal @stubbedAnnouncements[3] announcements[1].should.equal @stubbedAnnouncements[1] @@ -62,7 +65,7 @@ describe 'AnnouncementsHandler', -> it "should return older ones marked as read as well", (done)-> @AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2014/04/12/title-date-irrelivant"}}) - @handler.getUnreadAnnouncements @user_id, (err, announcements)=> + @handler.getUnreadAnnouncements @user, (err, announcements)=> announcements[0].id.should.equal @stubbedAnnouncements[0].id announcements[0].read.should.equal false @@ -79,7 +82,7 @@ describe 'AnnouncementsHandler', -> it "should return all of them marked as read", (done)-> @AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2016/11/01/introducting-latex-code-checker"}}) - @handler.getUnreadAnnouncements @user_id, (err, announcements)=> + @handler.getUnreadAnnouncements @user, (err, announcements)=> announcements[0].read.should.equal true announcements[1].read.should.equal true announcements[2].read.should.equal true @@ -87,3 +90,70 @@ describe 'AnnouncementsHandler', -> done() + describe "with custom domain announcements", -> + beforeEach -> + @stubbedDomainSpecificAnn = [ + { + domains: ["gmail.com", 'yahoo.edu'] + title: "some message" + excerpt: "read this" + url:"http://www.sharelatex.com/i/somewhere" + id:"iaaa" + date: new Date(1308369600000).toString() + } + ] + + @handler._domainSpecificAnnouncements = sinon.stub().returns(@stubbedDomainSpecificAnn) + + it "should insert the domain specific in the correct place", (done)-> + @AnalyticsManager.getLastOccurance.callsArgWith(2, null, []) + @handler.getUnreadAnnouncements @user, (err, announcements)=> + announcements[4].should.equal @stubbedAnnouncements[2] + announcements[3].should.equal @stubbedAnnouncements[3] + announcements[2].should.equal @stubbedAnnouncements[1] + announcements[1].should.equal @stubbedDomainSpecificAnn[0] + announcements[0].should.equal @stubbedAnnouncements[0] + done() + + describe "_domainSpecificAnnouncements", -> + beforeEach -> + @settings.domainAnnouncements = [ + { + domains: ["gmail.com", 'yahoo.edu'] + title: "some message" + excerpt: "read this" + url:"http://www.sharelatex.com/i/somewhere" + id:"id1" + date: new Date(1308369600000).toString() + }, { + domains: ["gmail.com", 'yahoo.edu'] + title: "some message" + excerpt: "read this" + url:"http://www.sharelatex.com/i/somewhere" + date: new Date(1308369600000).toString() + }, { + domains: ["gmail.com", 'yahoo.edu'] + title: "some message" + excerpt: "read this" + url:"http://www.sharelatex.com/i/somewhere" + id:"id3" + date: new Date(1308369600000).toString() + } + ] + + it "should filter announcments which don't have an id", (done) -> + result = @handler._domainSpecificAnnouncements "someone@gmail.com" + result.length.should.equal 2 + result[0].id.should.equal "id1" + result[1].id.should.equal "id3" + done() + + + it "should match on domain", (done) -> + @settings.domainAnnouncements[2].domains = ["yahoo.com"] + result = @handler._domainSpecificAnnouncements "someone@gmail.com" + result.length.should.equal 1 + result[0].id.should.equal "id1" + done() + + diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee index 72265eac11..94e930c7b1 100644 --- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee @@ -387,6 +387,10 @@ describe "AuthenticationController", -> beforeEach -> @req.headers = {} @AuthenticationController.httpAuth = sinon.stub() + @_setRedirect = sinon.spy(@AuthenticationController, '_setRedirectInSession') + + afterEach -> + @_setRedirect.restore() describe "with white listed url", -> beforeEach -> @@ -431,6 +435,9 @@ describe "AuthenticationController", -> @req.session = {} @AuthenticationController.requireGlobalLogin @req, @res, @next + it 'should have called setRedirectInSession', -> + @_setRedirect.callCount.should.equal 1 + it "should redirect to the /login page", -> @res.redirectedTo.should.equal "/login" @@ -543,6 +550,15 @@ describe "AuthenticationController", -> @AuthenticationController._setRedirectInSession(@req, '/somewhere/specific') expect(@req.session.postLoginRedirect).to.equal "/somewhere/specific" + describe 'with a js path', -> + + beforeEach -> + @req = {session: {}} + + it 'should not set the redirect', -> + @AuthenticationController._setRedirectInSession(@req, '/js/something.js') + expect(@req.session.postLoginRedirect).to.equal undefined + describe '_getRedirectFromSession', -> beforeEach -> @req = {session: {postLoginRedirect: "/a?b=c"}} diff --git a/services/web/test/UnitTests/coffee/Chat/ChatApiHandlerTests.coffee b/services/web/test/UnitTests/coffee/Chat/ChatApiHandlerTests.coffee new file mode 100644 index 0000000000..ea569b8a53 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Chat/ChatApiHandlerTests.coffee @@ -0,0 +1,92 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = path.join __dirname, "../../../../app/js/Features/Chat/ChatApiHandler" +expect = require("chai").expect + +describe "ChatApiHandler", -> + beforeEach -> + @settings = + apis: + chat: + internal_url:"chat.sharelatex.env" + @request = sinon.stub() + @ChatApiHandler = SandboxedModule.require modulePath, requires: + "settings-sharelatex": @settings + "logger-sharelatex": { log: sinon.stub(), error: sinon.stub() } + "request": @request + @project_id = "3213213kl12j" + @user_id = "2k3jlkjs9" + @content = "my message here" + @callback = sinon.stub() + + describe "sendGlobalMessage", -> + describe "successfully", -> + beforeEach -> + @message = { "mock": "message" } + @request.callsArgWith(1, null, {statusCode: 200}, @message) + @ChatApiHandler.sendGlobalMessage @project_id, @user_id, @content, @callback + + it "should post the data to the chat api", -> + @request.calledWith({ + url: "#{@settings.apis.chat.internal_url}/project/#{@project_id}/messages" + method: "POST" + json: + content: @content + user_id: @user_id + }).should.equal true + + it "should return the message from the post", -> + @callback.calledWith(null, @message).should.equal true + + describe "with a non-success status code", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 500}) + @ChatApiHandler.sendGlobalMessage @project_id, @user_id, @content, @callback + + it "should return an error", -> + error = new Error() + error.statusCode = 500 + @callback.calledWith(error).should.equal true + + describe "getGlobalMessages", -> + beforeEach -> + @messages = [{ "mock": "message" }] + @limit = 30 + @before = "1234" + + describe "successfully", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 200}, @messages) + @ChatApiHandler.getGlobalMessages @project_id, @limit, @before, @callback + + it "should make get request for room to chat api", -> + @request.calledWith({ + method: "GET" + url: "#{@settings.apis.chat.internal_url}/project/#{@project_id}/messages" + qs: + limit: @limit + before: @before + json: true + }).should.equal true + + it "should return the messages from the request", -> + @callback.calledWith(null, @messages).should.equal true + + describe "with failure error code", -> + beforeEach -> + @request.callsArgWith(1, null, {statusCode: 500}, null) + @ChatApiHandler.getGlobalMessages @project_id, @limit, @before, @callback + + it "should return an error", -> + error = new Error() + error.statusCode = 500 + @callback.calledWith(error).should.equal true + + + + + + diff --git a/services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee b/services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee index a491e4b499..851eb47f09 100644 --- a/services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Chat/ChatControllerTests.coffee @@ -7,75 +7,76 @@ modulePath = path.join __dirname, "../../../../app/js/Features/Chat/ChatControll expect = require("chai").expect describe "ChatController", -> - beforeEach -> - - @user_id = 'ier_' + @user_id = 'mock-user-id' @settings = {} - @ChatHandler = - sendMessage:sinon.stub() - getMessages:sinon.stub() - + @ChatApiHandler = {} @EditorRealTimeController = emitToRoom:sinon.stub().callsArgWith(3) - @AuthenticationController = getLoggedInUserId: sinon.stub().returns(@user_id) @ChatController = SandboxedModule.require modulePath, requires: - "settings-sharelatex":@settings - "logger-sharelatex": log:-> - "./ChatHandler":@ChatHandler - "../Editor/EditorRealTimeController":@EditorRealTimeController + "settings-sharelatex": @settings + "logger-sharelatex": log: -> + "./ChatApiHandler": @ChatApiHandler + "../Editor/EditorRealTimeController": @EditorRealTimeController '../Authentication/AuthenticationController': @AuthenticationController - @query = - before:"some time" - + '../User/UserInfoManager': @UserInfoManager = {} + '../User/UserInfoController': @UserInfoController = {} + '../Comments/CommentsController': @CommentsController = {} @req = params: - Project_id:@project_id - session: - user: - _id:@user_id - body: - content:@messageContent + project_id: @project_id @res = - set:sinon.stub() + json: sinon.stub() + send: sinon.stub() describe "sendMessage", -> - - it "should tell the chat handler about the message", (done)-> - @ChatHandler.sendMessage.callsArgWith(3) - @res.send = => - @ChatHandler.sendMessage.calledWith(@project_id, @user_id, @messageContent).should.equal true - done() + beforeEach -> + @req.body = + content: @content = "message-content" + @UserInfoManager.getPersonalInfo = sinon.stub().yields(null, @user = {"unformatted": "user"}) + @UserInfoController.formatPersonalInfo = sinon.stub().returns(@formatted_user = {"formatted": "user"}) + @ChatApiHandler.sendGlobalMessage = sinon.stub().yields(null, @message = {"mock": "message", user_id: @user_id}) @ChatController.sendMessage @req, @res - it "should tell the editor real time controller about the update with the data from the chat handler", (done)-> - @chatMessage = - content:"hello world" - @ChatHandler.sendMessage.callsArgWith(3, null, @chatMessage) - @res.send = => - @EditorRealTimeController.emitToRoom.calledWith(@project_id, "new-chat-message", @chatMessage).should.equal true - done() - @ChatController.sendMessage @req, @res + it "should look up the user", -> + @UserInfoManager.getPersonalInfo + .calledWith(@user_id) + .should.equal true + + it "should format and inject the user into the message", -> + @UserInfoController.formatPersonalInfo + .calledWith(@user) + .should.equal true + @message.user.should.deep.equal @formatted_user + + it "should tell the chat handler about the message", -> + @ChatApiHandler.sendGlobalMessage + .calledWith(@project_id, @user_id, @content) + .should.equal true + + it "should tell the editor real time controller about the update with the data from the chat handler", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "new-chat-message", @message) + .should.equal true + + it "should return a 204 status code", -> + @res.send.calledWith(204).should.equal true describe "getMessages", -> beforeEach -> - @req.query = @query - - it "should ask the chat handler about the request", (done)-> - - @ChatHandler.getMessages.callsArgWith(2) - @res.send = => - @ChatHandler.getMessages.calledWith(@project_id, @query).should.equal true - done() + @req.query = + limit: @limit = "30" + before: @before = "12345" + @CommentsController._injectUserInfoIntoThreads = sinon.stub().yields() + @ChatApiHandler.getGlobalMessages = sinon.stub().yields(null, @messages = ["mock", "messages"]) @ChatController.getMessages @req, @res - it "should return the messages", (done)-> - messages = [{content:"hello"}] - @ChatHandler.getMessages.callsArgWith(2, null, messages) - @res.send = (sentMessages)=> - @res.set.calledWith('Content-Type', 'application/json').should.equal true - sentMessages.should.deep.equal messages - done() - @ChatController.getMessages @req, @res + it "should ask the chat handler about the request", -> + @ChatApiHandler.getGlobalMessages + .calledWith(@project_id, @limit, @before) + .should.equal true + + it "should return the messages", -> + @res.json.calledWith(@messages).should.equal true \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/Chat/ChatHandlerTests.coffee b/services/web/test/UnitTests/coffee/Chat/ChatHandlerTests.coffee deleted file mode 100644 index 22b6a575cc..0000000000 --- a/services/web/test/UnitTests/coffee/Chat/ChatHandlerTests.coffee +++ /dev/null @@ -1,89 +0,0 @@ -should = require('chai').should() -SandboxedModule = require('sandboxed-module') -assert = require('assert') -path = require('path') -sinon = require('sinon') -modulePath = path.join __dirname, "../../../../app/js/Features/Chat/ChatHandler" -expect = require("chai").expect - -describe "ChatHandler", -> - - beforeEach -> - - @settings = - apis: - chat: - internal_url:"chat.sharelatex.env" - @request = sinon.stub() - @ChatHandler = SandboxedModule.require modulePath, requires: - "settings-sharelatex":@settings - "logger-sharelatex": log:-> - "request": @request - @project_id = "3213213kl12j" - @user_id = "2k3jlkjs9" - @messageContent = "my message here" - - describe "sending message", -> - - beforeEach -> - @messageResponse = - message:"Details" - @request.callsArgWith(1, null, null, @messageResponse) - - it "should post the data to the chat api", (done)-> - - @ChatHandler.sendMessage @project_id, @user_id, @messageContent, (err)=> - @opts = - method:"post" - json: - content:@messageContent - user_id:@user_id - uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages" - @request.calledWith(@opts).should.equal true - done() - - it "should return the message from the post", (done)-> - @ChatHandler.sendMessage @project_id, @user_id, @messageContent, (err, returnedMessage)=> - returnedMessage.should.equal @messageResponse - done() - - describe "get messages", -> - - beforeEach -> - @returnedMessages = [{content:"hello world"}] - @request.callsArgWith(1, null, null, @returnedMessages) - @query = {} - - it "should make get request for room to chat api", (done)-> - - @ChatHandler.getMessages @project_id, @query, (err)=> - @opts = - method:"get" - uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages" - qs:{} - @request.calledWith(@opts).should.equal true - done() - - it "should make get request for room to chat api with query string", (done)-> - @query = {limit:5, before:12345, ignore:"this"} - - @ChatHandler.getMessages @project_id, @query, (err)=> - @opts = - method:"get" - uri:"#{@settings.apis.chat.internal_url}/room/#{@project_id}/messages" - qs: - limit:5 - before:12345 - @request.calledWith(@opts).should.equal true - done() - - it "should return the messages from the request", (done)-> - @ChatHandler.getMessages @project_id, @query, (err, returnedMessages)=> - returnedMessages.should.equal @returnedMessages - done() - - - - - - diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee index 28bf1ab6a2..453296b3d6 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -14,11 +14,20 @@ describe "CollaboratorsInviteController", -> @user = _id: 'id' @AnalyticsManger = recordEvent: sinon.stub() + @sendingUser = null @AuthenticationController = - getSessionUser: (req) => req.session.user + getSessionUser: (req) => + @sendingUser = req.session.user + return @sendingUser + + @RateLimiter = + addCount: sinon.stub + + @LimitationsManager = {} + @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: "../Project/ProjectGetter": @ProjectGetter = {} - '../Subscription/LimitationsManager' : @LimitationsManager = {} + '../Subscription/LimitationsManager' : @LimitationsManager '../User/UserGetter': @UserGetter = {getUser: sinon.stub()} "./CollaboratorsHandler": @CollaboratorsHandler = {} "./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} @@ -28,6 +37,7 @@ describe "CollaboratorsInviteController", -> "../Analytics/AnalyticsManager": @AnalyticsManger '../Authentication/AuthenticationController': @AuthenticationController 'settings-sharelatex': @settings = {} + "../../infrastructure/RateLimiter":@RateLimiter @res = new MockResponse() @req = new MockRequest() @@ -104,15 +114,11 @@ describe "CollaboratorsInviteController", -> describe 'when all goes well', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, null, true) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should produce json response', -> @res.json.callCount.should.equal 1 ({invite: @invite}).should.deep.equal(@res.json.firstCall.args[0]) @@ -122,8 +128,8 @@ describe "CollaboratorsInviteController", -> @LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true it 'should have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 1 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@targetEmail).should.equal true it 'should have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1 @@ -136,22 +142,18 @@ describe "CollaboratorsInviteController", -> describe 'when the user is not allowed to add more collaborators', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, null, true) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should produce json response without an invite', -> @res.json.callCount.should.equal 1 ({invite: null}).should.deep.equal(@res.json.firstCall.args[0]) it 'should not have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 0 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal false + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal false it 'should not have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 @@ -159,23 +161,19 @@ describe "CollaboratorsInviteController", -> describe 'when canAddXCollaborators produces an error', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, null, true) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @err = new Error('woops') @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, @err) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should call next with an error', -> @next.callCount.should.equal 1 @next.calledWith(@err).should.equal true it 'should not have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 0 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal false + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal false it 'should not have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 @@ -183,16 +181,12 @@ describe "CollaboratorsInviteController", -> describe 'when inviteToProject produces an error', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, null, true) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @err = new Error('woops') @CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, @err) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should call next with an error', -> @next.callCount.should.equal 1 @next.calledWith(@err).should.equal true @@ -202,8 +196,8 @@ describe "CollaboratorsInviteController", -> @LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true it 'should have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 1 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@targetEmail).should.equal true it 'should have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1 @@ -212,22 +206,18 @@ describe "CollaboratorsInviteController", -> describe 'when _checkShouldInviteEmail disallows the invite', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, null, false) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, false) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should produce json response with no invite, and an error property', -> @res.json.callCount.should.equal 1 ({invite: null, error: 'cannot_invite_non_user'}).should.deep.equal(@res.json.firstCall.args[0]) it 'should have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 1 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@targetEmail).should.equal true it 'should not have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 @@ -235,26 +225,66 @@ describe "CollaboratorsInviteController", -> describe 'when _checkShouldInviteEmail produces an error', -> beforeEach -> - @_checkShouldInviteEmail = sinon.stub( - @CollaboratorsInviteController, '_checkShouldInviteEmail' - ).callsArgWith(1, new Error('woops')) + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, new Error('woops')) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) @CollaboratorsInviteController.inviteToProject @req, @res, @next - afterEach -> - @_checkShouldInviteEmail.restore() - it 'should call next with an error', -> @next.callCount.should.equal 1 @next.calledWith(@err).should.equal true it 'should have called _checkShouldInviteEmail', -> - @_checkShouldInviteEmail.callCount.should.equal 1 - @_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1 + @CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@targetEmail).should.equal true it 'should not have called inviteToProject', -> @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 + describe 'when the user invites themselves to the project', -> + + beforeEach -> + @req.session.user = {_id: 'abc', email: 'me@example.com'} + @req.body.email = 'me@example.com' + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, true) + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + + it 'should reject action, return json response with error code', -> + @res.json.callCount.should.equal 1 + ({invite: null, error: 'cannot_invite_self'}).should.deep.equal(@res.json.firstCall.args[0]) + + it 'should not have called canAddXCollaborators', -> + @LimitationsManager.canAddXCollaborators.callCount.should.equal 0 + + it 'should not have called _checkShouldInviteEmail', -> + @CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0 + + it 'should not have called inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 + + it 'should not have called emitToRoom', -> + @EditorRealTimeController.emitToRoom.callCount.should.equal 0 + + describe 'when _checkRateLimit returns false', -> + + beforeEach -> + @CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit = sinon.stub().yields(null, false) + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + it 'should send a 429 response', -> + @res.sendStatus.calledWith(429).should.equal true + + it 'should not call inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.called.should.equal false + + it 'should not call emitToRoom', -> + @EditorRealTimeController.emitToRoom.called.should.equal false + describe "viewInvite", -> beforeEach -> @@ -671,13 +701,13 @@ describe "CollaboratorsInviteController", -> beforeEach -> @email = 'user@example.com' - @call = (callback) => - @CollaboratorsInviteController._checkShouldInviteEmail @email, callback describe 'when we should be restricting to existing accounts', -> beforeEach -> @settings.restrictInvitesToExistingAccounts = true + @call = (callback) => + @CollaboratorsInviteController._checkShouldInviteEmail @email, callback describe 'when user account is present', -> @@ -722,18 +752,43 @@ describe "CollaboratorsInviteController", -> expect(shouldAllow).to.equal undefined done() - describe 'when we should not be restricting', -> + describe '_checkRateLimit', -> + beforeEach -> + @settings.restrictInvitesToExistingAccounts = false + @sendingUserId = "32312313" + @LimitationsManager.allowedNumberOfCollaboratorsForUser = sinon.stub() + @LimitationsManager.allowedNumberOfCollaboratorsForUser.withArgs(@sendingUserId).yields(null, 17) - beforeEach -> - @settings.restrictInvitesToExistingAccounts = false + it 'should callback with `true` when rate limit under', (done) -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit @sendingUserId, (err, result)=> + @RateLimiter.addCount.called.should.equal true + result.should.equal true + done() - it 'should callback with `true`', (done) -> - @call (err, shouldAllow) => - expect(err).to.equal null - expect(shouldAllow).to.equal true - done() + it 'should callback with `false` when rate limit hit', (done) -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, false) + @CollaboratorsInviteController._checkRateLimit @sendingUserId, (err, result)=> + @RateLimiter.addCount.called.should.equal true + result.should.equal false + done() + + it 'should call rate limiter with 10x the collaborators', (done) -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit @sendingUserId, (err, result)=> + @RateLimiter.addCount.args[0][0].throttle.should.equal(170) + done() - it 'should not have called getUser', (done) -> - @call (err, shouldAllow) => - @UserGetter.getUser.callCount.should.equal 0 - done() + it 'should call rate limiter with 200 when collaborators is -1', (done) -> + @LimitationsManager.allowedNumberOfCollaboratorsForUser.withArgs(@sendingUserId).yields(null, -1) + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit @sendingUserId, (err, result)=> + @RateLimiter.addCount.args[0][0].throttle.should.equal(200) + done() + + it 'should call rate limiter with 10 when user has no collaborators set', (done) -> + @LimitationsManager.allowedNumberOfCollaboratorsForUser.withArgs(@sendingUserId).yields(null) + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) + @CollaboratorsInviteController._checkRateLimit @sendingUserId, (err, result)=> + @RateLimiter.addCount.args[0][0].throttle.should.equal(10) + done() \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee index ac94fcf10d..177c42d4ba 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -185,7 +185,7 @@ describe "CollaboratorsInviteHandler", -> describe '_sendMessages', -> beforeEach -> - @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, null) + @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(4, null) @CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, null) @call = (callback) => @CollaboratorsInviteHandler._sendMessages @projectId, @sendingUser, @fakeInvite, callback @@ -213,7 +213,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', -> beforeEach -> - @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, new Error('woops')) + @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(4, new Error('woops')) it 'should produce an error', (done) -> @call (err, invite) => diff --git a/services/web/test/UnitTests/coffee/Comments/CommentsControllerTests.coffee b/services/web/test/UnitTests/coffee/Comments/CommentsControllerTests.coffee new file mode 100644 index 0000000000..e55f0d04da --- /dev/null +++ b/services/web/test/UnitTests/coffee/Comments/CommentsControllerTests.coffee @@ -0,0 +1,284 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = path.join __dirname, "../../../../app/js/Features/Comments/CommentsController" +expect = require("chai").expect + +describe "CommentsController", -> + beforeEach -> + @user_id = 'mock-user-id' + @settings = {} + @ChatApiHandler = {} + @EditorRealTimeController = + emitToRoom:sinon.stub() + @AuthenticationController = + getLoggedInUserId: sinon.stub().returns(@user_id) + @CommentsController = SandboxedModule.require modulePath, requires: + "settings-sharelatex": @settings + "logger-sharelatex": log: -> + "../Chat/ChatApiHandler": @ChatApiHandler + "../Editor/EditorRealTimeController": @EditorRealTimeController + '../Authentication/AuthenticationController': @AuthenticationController + '../User/UserInfoManager': @UserInfoManager = {} + '../User/UserInfoController': @UserInfoController = {} + "../DocumentUpdater/DocumentUpdaterHandler": @DocumentUpdaterHandler = {} + @req = {} + @res = + json: sinon.stub() + send: sinon.stub() + + describe "sendComment", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + thread_id: @thread_id = "mock-thread-id" + @req.body = + content: @content = "message-content" + @UserInfoManager.getPersonalInfo = sinon.stub().yields(null, @user = {"unformatted": "user"}) + @UserInfoController.formatPersonalInfo = sinon.stub().returns(@formatted_user = {"formatted": "user"}) + @ChatApiHandler.sendComment = sinon.stub().yields(null, @message = {"mock": "message", user_id: @user_id}) + @CommentsController.sendComment @req, @res + + it "should look up the user", -> + @UserInfoManager.getPersonalInfo + .calledWith(@user_id) + .should.equal true + + it "should format and inject the user into the comment", -> + @UserInfoController.formatPersonalInfo + .calledWith(@user) + .should.equal true + @message.user.should.deep.equal @formatted_user + + it "should tell the chat handler about the message", -> + @ChatApiHandler.sendComment + .calledWith(@project_id, @thread_id, @user_id, @content) + .should.equal true + + it "should tell the editor real time controller about the update with the data from the chat handler", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "new-comment", @thread_id, @message) + .should.equal true + + it "should return a 204 status code", -> + @res.send.calledWith(204).should.equal true + + describe "getThreads", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + @ChatApiHandler.getThreads = sinon.stub().yields(null, @threads = {"mock", "threads"}) + @CommentsController._injectUserInfoIntoThreads = sinon.stub().yields(null, @threads) + @CommentsController.getThreads @req, @res + + it "should ask the chat handler about the request", -> + @ChatApiHandler.getThreads + .calledWith(@project_id) + .should.equal true + + it "should inject the user details into the threads", -> + @CommentsController._injectUserInfoIntoThreads + .calledWith(@threads) + .should.equal true + + it "should return the messages", -> + @res.json.calledWith(@threads).should.equal true + + describe "resolveThread", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + thread_id: @thread_id = "mock-thread-id" + @ChatApiHandler.resolveThread = sinon.stub().yields() + @UserInfoManager.getPersonalInfo = sinon.stub().yields(null, @user = {"unformatted": "user"}) + @UserInfoController.formatPersonalInfo = sinon.stub().returns(@formatted_user = {"formatted": "user"}) + @CommentsController.resolveThread @req, @res + + it "should ask the chat handler to resolve the thread", -> + @ChatApiHandler.resolveThread + .calledWith(@project_id, @thread_id) + .should.equal true + + it "should look up the user", -> + @UserInfoManager.getPersonalInfo + .calledWith(@user_id) + .should.equal true + + it "should tell the client the comment was resolved", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "resolve-thread", @thread_id, @formatted_user) + .should.equal true + + it "should return a success code", -> + @res.send.calledWith(204).should.equal + + describe "reopenThread", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + thread_id: @thread_id = "mock-thread-id" + @ChatApiHandler.reopenThread = sinon.stub().yields() + @CommentsController.reopenThread @req, @res + + it "should ask the chat handler to reopen the thread", -> + @ChatApiHandler.reopenThread + .calledWith(@project_id, @thread_id) + .should.equal true + + it "should tell the client the comment was resolved", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "reopen-thread", @thread_id) + .should.equal true + + it "should return a success code", -> + @res.send.calledWith(204).should.equal + + describe "deleteThread", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + doc_id: @doc_id = "mock-doc-id" + thread_id: @thread_id = "mock-thread-id" + @DocumentUpdaterHandler.deleteThread = sinon.stub().yields() + @ChatApiHandler.deleteThread = sinon.stub().yields() + @CommentsController.deleteThread @req, @res + + it "should ask the doc udpater to delete the thread", -> + @DocumentUpdaterHandler.deleteThread + .calledWith(@project_id, @doc_id, @thread_id) + .should.equal true + + it "should ask the chat handler to delete the thread", -> + @ChatApiHandler.deleteThread + .calledWith(@project_id, @thread_id) + .should.equal true + + it "should tell the client the thread was deleted", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "delete-thread", @thread_id) + .should.equal true + + it "should return a success code", -> + @res.send.calledWith(204).should.equal + + describe "editMessage", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + thread_id: @thread_id = "mock-thread-id" + message_id: @message_id = "mock-thread-id" + @req.body = + content: @content = "mock-content" + @ChatApiHandler.editMessage = sinon.stub().yields() + @CommentsController.editMessage @req, @res + + it "should ask the chat handler to edit the comment", -> + @ChatApiHandler.editMessage + .calledWith(@project_id, @thread_id, @message_id, @content) + .should.equal true + + it "should tell the client the comment was edited", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "edit-message", @thread_id, @message_id, @content) + .should.equal true + + it "should return a success code", -> + @res.send.calledWith(204).should.equal + + describe "deleteMessage", -> + beforeEach -> + @req.params = + project_id: @project_id = "mock-project-id" + thread_id: @thread_id = "mock-thread-id" + message_id: @message_id = "mock-thread-id" + @ChatApiHandler.deleteMessage = sinon.stub().yields() + @CommentsController.deleteMessage @req, @res + + it "should ask the chat handler to deleted the message", -> + @ChatApiHandler.deleteMessage + .calledWith(@project_id, @thread_id, @message_id) + .should.equal true + + it "should tell the client the message was deleted", -> + @EditorRealTimeController.emitToRoom + .calledWith(@project_id, "delete-message", @thread_id, @message_id) + .should.equal true + + it "should return a success code", -> + @res.send.calledWith(204).should.equal + + describe "_injectUserInfoIntoThreads", -> + beforeEach -> + @users = { + "user_id_1": { + "mock": "user_1" + } + "user_id_2": { + "mock": "user_2" + } + } + @UserInfoManager.getPersonalInfo = (user_id, callback) => + return callback(null, @users[user_id]) + sinon.spy @UserInfoManager, "getPersonalInfo" + @UserInfoController.formatPersonalInfo = (user) -> + return { "formatted": user["mock"] } + + it "should inject a user object into messaged and resolved data", (done) -> + @CommentsController._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" + }] + } + } + done() + + it "should only need to look up each user once", (done) -> + @CommentsController._injectUserInfoIntoThreads [{ + messages: [{ + user_id: "user_id_1" + content: "foo" + }, { + user_id: "user_id_1" + content: "bar" + }] + }], (error, threads) => + @UserInfoManager.getPersonalInfo.calledOnce.should.equal true + done() \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee b/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee index e288c46aea..abcc55a0b9 100644 --- a/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee @@ -57,12 +57,13 @@ describe "DocstoreManager", -> @lines = ["mock", "doc", "lines"] @rev = 5 @version = 42 + @ranges = { "mock": "ranges" } @modified = true describe "with a successful response code", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 204, { modified: @modified, rev: @rev }) - @DocstoreManager.updateDoc @project_id, @doc_id, @lines, @version, @callback + @DocstoreManager.updateDoc @project_id, @doc_id, @lines, @version, @ranges, @callback it "should update the doc in the docstore api", -> @request.post @@ -71,6 +72,7 @@ describe "DocstoreManager", -> json: lines: @lines version: @version + ranges: @ranges }) .should.equal true @@ -80,7 +82,7 @@ describe "DocstoreManager", -> describe "with a failed response code", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 500, "") - @DocstoreManager.updateDoc @project_id, @doc_id, @lines, @version, @callback + @DocstoreManager.updateDoc @project_id, @doc_id, @lines, @version, @ranges, @callback it "should call the callback with an error", -> @callback.calledWith(new Error("docstore api responded with non-success code: 500")).should.equal true @@ -100,6 +102,7 @@ describe "DocstoreManager", -> lines: @lines = ["mock", "doc", "lines"] rev: @rev = 5 version: @version = 42 + ranges: @ranges = { "mock": "ranges" } describe "with a successful response code", -> beforeEach -> @@ -115,7 +118,7 @@ describe "DocstoreManager", -> .should.equal true it "should call the callback with the lines, version and rev", -> - @callback.calledWith(null, @lines, @rev, @version).should.equal true + @callback.calledWith(null, @lines, @rev, @version, @ranges).should.equal true describe "with a failed response code", -> beforeEach -> @@ -148,7 +151,7 @@ describe "DocstoreManager", -> .should.equal true it "should call the callback with the lines, version and rev", -> - @callback.calledWith(null, @lines, @rev, @version).should.equal true + @callback.calledWith(null, @lines, @rev, @version, @ranges).should.equal true describe "getAllDocs", -> describe "with a successful response code", -> @@ -183,6 +186,38 @@ describe "DocstoreManager", -> }, "error getting all docs from docstore") .should.equal true + describe "getAllRanges", -> + describe "with a successful response code", -> + beforeEach -> + @request.get = sinon.stub().callsArgWith(1, null, statusCode: 204, @docs = [{ _id: "mock-doc-id", ranges: "mock-ranges" }]) + @DocstoreManager.getAllRanges @project_id, @callback + + it "should get all the project doc ranges in the docstore api", -> + @request.get + .calledWith({ + url: "#{@settings.apis.docstore.url}/project/#{@project_id}/ranges" + json: true + }) + .should.equal true + + it "should call the callback with the docs", -> + @callback.calledWith(null, @docs).should.equal true + + describe "with a failed response code", -> + beforeEach -> + @request.get = sinon.stub().callsArgWith(1, null, statusCode: 500, "") + @DocstoreManager.getAllRanges @project_id, @callback + + it "should call the callback with an error", -> + @callback.calledWith(new Error("docstore api responded with non-success code: 500")).should.equal true + + it "should log the error", -> + @logger.error + .calledWith({ + err: new Error("docstore api responded with a non-success code: 500") + project_id: @project_id + }, "error getting all doc ranges from docstore") + .should.equal true describe "archiveProject", -> describe "with a successful response code", -> diff --git a/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index aaae05219b..681915abc6 100644 --- a/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee @@ -8,8 +8,7 @@ path = require 'path' _ = require 'underscore' modulePath = path.join __dirname, '../../../../app/js/Features/DocumentUpdater/DocumentUpdaterHandler' -describe 'DocumentUpdaterHandler - Flushing documents :', -> - +describe 'DocumentUpdaterHandler', -> beforeEach -> @project_id = "project-id-923" @doc_id = "doc-id-394" @@ -267,6 +266,7 @@ describe 'DocumentUpdaterHandler - Flushing documents :', -> lines: @lines version: @version ops: @ops = ["mock-op-1", "mock-op-2"] + ranges: @ranges = {"mock":"ranges"} @fromVersion = 2 @request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body) @handler.getDocument @project_id, @doc_id, @fromVersion, @callback @@ -276,7 +276,7 @@ describe 'DocumentUpdaterHandler - Flushing documents :', -> @request.get.calledWith(url).should.equal true it "should call the callback with the lines and version", -> - @callback.calledWith(null, @lines, @version, @ops).should.equal true + @callback.calledWith(null, @lines, @version, @ranges, @ops).should.equal true describe "when the document updater API returns an error", -> beforeEach -> @@ -295,3 +295,73 @@ describe 'DocumentUpdaterHandler - Flushing documents :', -> @callback .calledWith(new Error("doc updater returned failure status code: 500")) .should.equal true + + describe "acceptChange", -> + beforeEach -> + @change_id = "mock-change-id-1" + @callback = sinon.stub() + + describe "successfully", -> + beforeEach -> + @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body) + @handler.acceptChange @project_id, @doc_id, @change_id, @callback + + it 'should accept the change in the document updater', -> + url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}/change/#{@change_id}/accept" + @request.post.calledWith(url).should.equal true + + it "should call the callback", -> + @callback.calledWith(null).should.equal true + + describe "when the document updater API returns an error", -> + beforeEach -> + @request.post = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null) + @handler.acceptChange @project_id, @doc_id, @change_id, @callback + + it "should return an error to the callback", -> + @callback.calledWith(@error).should.equal true + + describe "when the document updater returns a failure error code", -> + beforeEach -> + @request.post = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "") + @handler.acceptChange @project_id, @doc_id, @change_id, @callback + + it "should return the callback with an error", -> + @callback + .calledWith(new Error("doc updater returned failure status code: 500")) + .should.equal true + + describe "deleteThread", -> + beforeEach -> + @thread_id = "mock-thread-id-1" + @callback = sinon.stub() + + describe "successfully", -> + beforeEach -> + @request.del = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body) + @handler.deleteThread @project_id, @doc_id, @thread_id, @callback + + it 'should delete the thread in the document updater', -> + url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}/comment/#{@thread_id}" + @request.del.calledWith(url).should.equal true + + it "should call the callback", -> + @callback.calledWith(null).should.equal true + + describe "when the document updater API returns an error", -> + beforeEach -> + @request.del = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null) + @handler.deleteThread @project_id, @doc_id, @thread_id, @callback + + it "should return an error to the callback", -> + @callback.calledWith(@error).should.equal true + + describe "when the document updater returns a failure error code", -> + beforeEach -> + @request.del = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "") + @handler.deleteThread @project_id, @doc_id, @thread_id, @callback + + it "should return the callback with an error", -> + @callback + .calledWith(new Error("doc updater returned failure status code: 500")) + .should.equal true \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/DocumentUpdater/GetNumberOfDocsInMemoryTests.coffee b/services/web/test/UnitTests/coffee/DocumentUpdater/GetNumberOfDocsInMemoryTests.coffee deleted file mode 100644 index 213fd2257b..0000000000 --- a/services/web/test/UnitTests/coffee/DocumentUpdater/GetNumberOfDocsInMemoryTests.coffee +++ /dev/null @@ -1,43 +0,0 @@ -path = require("path") -sinon = require("sinon") -SandboxedModule = require('sandboxed-module') - -modulePath = path.join __dirname, '../../../../app/js/Features/DocumentUpdater/DocumentUpdaterHandler' - -describe "getNumberOfDocsInMemory", -> - beforeEach -> - @host = "doc.updater" - @noOfDocs = 42 - @callback = sinon.stub() - @DocumentUpdateHandler = SandboxedModule.require modulePath, requires: - "redis-sharelatex" : - createClient: () -> - auth:-> - "soa-req-id": null - "logger-sharelatex": @logger = - log: sinon.stub() - error: sinon.stub() - "../../infrastructure/Metrics" : @metrics - "../../Features/Project/ProjectLocator": @ProjectLocator = {} - "../../models/Project":Project:{} - "request" : defaults: () => @request = {} - "settings-sharelatex": - apis: documentupdater: url: @host - redis: web:{} - - - @request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, JSON.stringify(total: @noOfDocs)) - @DocumentUpdateHandler.getNumberOfDocsInMemory @callback - - it "should call the doc updater", -> - @request.get - .calledWith("#{@host}/total") - .should.equal true - - it "should return the number of docs", -> - @callback - .calledWith(null, @noOfDocs) - .should.equal true - - - diff --git a/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee b/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee index a554319baa..fedfa1c1b3 100644 --- a/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee @@ -23,6 +23,7 @@ describe "DocumentController", -> @doc_id = "doc-id-123" @doc_lines = ["one", "two", "three"] @version = 42 + @ranges = {"mock": "ranges"} @rev = 5 describe "getDocument", -> @@ -33,7 +34,7 @@ describe "DocumentController", -> describe "when the document exists", -> beforeEach -> - @ProjectEntityHandler.getDoc = sinon.stub().callsArgWith(2, null, @doc_lines, @rev, @version) + @ProjectEntityHandler.getDoc = sinon.stub().callsArgWith(2, null, @doc_lines, @rev, @version, @ranges) @DocumentController.getDocument(@req, @res, @next) it "should get the document from Mongo", -> @@ -46,6 +47,7 @@ describe "DocumentController", -> @res.body.should.equal JSON.stringify lines: @doc_lines version: @version + ranges: @ranges describe "when the document doesn't exist", -> beforeEach -> @@ -68,11 +70,12 @@ describe "DocumentController", -> @req.body = lines: @doc_lines version: @version + ranges: @ranges @DocumentController.setDocument(@req, @res, @next) it "should update the document in Mongo", -> @ProjectEntityHandler.updateDocLines - .calledWith(@project_id, @doc_id, @doc_lines, @version) + .calledWith(@project_id, @doc_id, @doc_lines, @version, @ranges) .should.equal true it "should return a successful response", -> diff --git a/services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee b/services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee index 4f08dd6790..beba91fcb3 100644 --- a/services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee +++ b/services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee @@ -10,6 +10,9 @@ describe "EmailSender", -> beforeEach -> + @RateLimiter = + addCount:sinon.stub() + @settings = email: transport: "ses" @@ -21,11 +24,15 @@ describe "EmailSender", -> @sesClient = sendMail: sinon.stub() + @ses = createTransport: => @sesClient + + @sender = SandboxedModule.require modulePath, requires: 'nodemailer': @ses "settings-sharelatex":@settings + '../../infrastructure/RateLimiter':@RateLimiter "logger-sharelatex": log:-> warn:-> @@ -84,6 +91,29 @@ describe "EmailSender", -> args.replyTo.should.equal @opts.replyTo done() + + it "should not send an email when the rate limiter says no", (done)-> + @opts.sendingUser_id = "12321312321" + @RateLimiter.addCount.callsArgWith(1, null, false) + @sender.sendEmail @opts, => + @sesClient.sendMail.called.should.equal false + done() + + it "should send the email when the rate limtier says continue", (done)-> + @sesClient.sendMail.callsArgWith(1) + @opts.sendingUser_id = "12321312321" + @RateLimiter.addCount.callsArgWith(1, null, true) + @sender.sendEmail @opts, => + @sesClient.sendMail.called.should.equal true + done() + + it "should not check the rate limiter when there is no sendingUser_id", (done)-> + @sesClient.sendMail.callsArgWith(1) + @sender.sendEmail @opts, => + @sesClient.sendMail.called.should.equal true + @RateLimiter.addCount.called.should.equal false + done() + describe 'with plain-text email content', () -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/TrackChanges/TrackChangesControllerTests.coffee b/services/web/test/UnitTests/coffee/History/HistoryControllerTests.coffee similarity index 83% rename from services/web/test/UnitTests/coffee/TrackChanges/TrackChangesControllerTests.coffee rename to services/web/test/UnitTests/coffee/History/HistoryControllerTests.coffee index bcc57b58b8..577aae6a9d 100644 --- a/services/web/test/UnitTests/coffee/TrackChanges/TrackChangesControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/History/HistoryControllerTests.coffee @@ -1,21 +1,21 @@ chai = require('chai') chai.should() sinon = require("sinon") -modulePath = "../../../../app/js/Features/TrackChanges/TrackChangesController" +modulePath = "../../../../app/js/Features/History/HistoryController" SandboxedModule = require('sandboxed-module') -describe "TrackChangesController", -> +describe "HistoryController", -> beforeEach -> @user_id = "user-id-123" @AuthenticationController = getLoggedInUserId: sinon.stub().returns(@user_id) - @TrackChangesController = SandboxedModule.require modulePath, requires: + @HistoryController = SandboxedModule.require modulePath, requires: "request" : @request = sinon.stub() "settings-sharelatex": @settings = {} "logger-sharelatex": @logger = {log: sinon.stub(), error: sinon.stub()} "../Authentication/AuthenticationController": @AuthenticationController - describe "proxyToTrackChangesApi", -> + describe "proxyToHistoryApi", -> beforeEach -> @req = { url: "/mock/url", method: "POST" } @res = "mock-res" @@ -28,7 +28,7 @@ describe "TrackChangesController", -> pipe: sinon.stub() on: (event, handler) -> @events[event] = handler @request.returns @proxy - @TrackChangesController.proxyToTrackChangesApi @req, @res, @next + @HistoryController.proxyToHistoryApi @req, @res, @next describe "successfully", -> it "should get the user id", -> diff --git a/services/web/test/UnitTests/coffee/TrackChanges/TrackChangesManagerTests.coffee b/services/web/test/UnitTests/coffee/History/HistoryManagerTests.coffee similarity index 81% rename from services/web/test/UnitTests/coffee/TrackChanges/TrackChangesManagerTests.coffee rename to services/web/test/UnitTests/coffee/History/HistoryManagerTests.coffee index 90b36f89c5..65b22812ea 100644 --- a/services/web/test/UnitTests/coffee/TrackChanges/TrackChangesManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/History/HistoryManagerTests.coffee @@ -2,12 +2,12 @@ chai = require('chai') expect = chai.expect chai.should() sinon = require("sinon") -modulePath = "../../../../app/js/Features/TrackChanges/TrackChangesManager" +modulePath = "../../../../app/js/Features/History/HistoryManager" SandboxedModule = require('sandboxed-module') -describe "TrackChangesManager", -> +describe "HistoryManager", -> beforeEach -> - @TrackChangesManager = SandboxedModule.require modulePath, requires: + @HistoryManager = SandboxedModule.require modulePath, requires: "request" : @request = sinon.stub() "settings-sharelatex": @settings = apis: @@ -22,7 +22,7 @@ describe "TrackChangesManager", -> describe "with a successful response code", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 204, "") - @TrackChangesManager.flushProject @project_id, @callback + @HistoryManager.flushProject @project_id, @callback it "should flush the project in the track changes api", -> @request.post @@ -35,7 +35,7 @@ describe "TrackChangesManager", -> describe "with a failed response code", -> beforeEach -> @request.post = sinon.stub().callsArgWith(1, null, statusCode: 500, "") - @TrackChangesManager.flushProject @project_id, @callback + @HistoryManager.flushProject @project_id, @callback it "should call the callback with an error", -> @callback.calledWith(new Error("track-changes api responded with a non-success code: 500")).should.equal true @@ -52,12 +52,12 @@ describe "TrackChangesManager", -> it "should call the post endpoint", (done)-> @request.post.callsArgWith(1, null, {}) - @TrackChangesManager.archiveProject @project_id, (err)=> + @HistoryManager.archiveProject @project_id, (err)=> @request.post.calledWith("#{@settings.apis.trackchanges.url}/project/#{@project_id}/archive") done() it "should return an error on a non success", (done)-> @request.post.callsArgWith(1, null, {statusCode:500}) - @TrackChangesManager.archiveProject @project_id, (err)=> + @HistoryManager.archiveProject @project_id, (err)=> expect(err).to.exist done() \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee index 89c6479734..d11507361c 100644 --- a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee @@ -145,18 +145,27 @@ describe "PasswordResetController", -> done() @PasswordResetController.setNewUserPassword @req, @res - it "should login user if login_after is set", (done) -> - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" }) - @PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123") - @req.body.login_after = "true" - @AuthenticationController.doLogin = (options, req, res, next)=> - @UserGetter.getUser.calledWith(@user_id).should.equal true - expect(options).to.deep.equal { - email: "joe@example.com", - password: @password - } + describe 'when login_after is set', -> + + beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" }) + @PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123") + @req.body.login_after = "true" + @res.json = sinon.stub() + @AuthenticationController.afterLoginSessionSetup = sinon.stub().callsArgWith(2, null) + @AuthenticationController._getRedirectFromSession = sinon.stub().returns('/some/path') + + it "should login user if login_after is set", (done) -> + @PasswordResetController.setNewUserPassword @req, @res + @AuthenticationController.afterLoginSessionSetup.callCount.should.equal 1 + @AuthenticationController.afterLoginSessionSetup.calledWith( + @req, + {email: 'joe@example.com'} + ).should.equal true + @AuthenticationController._getRedirectFromSession.callCount.should.equal 1 + @res.json.callCount.should.equal 1 + @res.json.calledWith({redir: '/some/path'}).should.equal true done() - @PasswordResetController.setNewUserPassword @req, @res describe "renderSetPasswordForm", -> diff --git a/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee index 5a0c860ab2..f3dcda07cf 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee @@ -382,7 +382,9 @@ describe 'ProjectEntityHandler', -> beforeEach -> @lines = ["mock", "doc", "lines"] @rev = 5 - @DocstoreManager.getDoc = sinon.stub().callsArgWith(3, null, @lines, @rev) + @version = 42 + @ranges = {"mock": "ranges"} + @DocstoreManager.getDoc = sinon.stub().callsArgWith(3, null, @lines, @rev, @version, @ranges) @ProjectEntityHandler.getDoc project_id, doc_id, @callback it "should call the docstore", -> @@ -391,7 +393,7 @@ describe 'ProjectEntityHandler', -> .should.equal true it "should call the callback with the lines, version and rev", -> - @callback.calledWith(null, @lines, @rev).should.equal true + @callback.calledWith(null, @lines, @rev, @version, @ranges).should.equal true describe 'addDoc', -> beforeEach -> @@ -590,6 +592,7 @@ describe 'ProjectEntityHandler', -> _id: doc_id } @version = 42 + @ranges = {"mock":"ranges"} @ProjectGetter.getProjectWithoutDocLines = sinon.stub().callsArgWith(1, null, @project) @projectLocator.findElement = sinon.stub().callsArgWith(1, null, @doc, {fileSystem: @path}) @tpdsUpdateSender.addDoc = sinon.stub().callsArg(1) @@ -599,7 +602,7 @@ describe 'ProjectEntityHandler', -> describe "when the doc has been modified", -> beforeEach -> @DocstoreManager.updateDoc = sinon.stub().yields(null, true, @rev = 5) - @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @callback + @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @ranges, @callback it "should get the project without doc lines", -> @ProjectGetter.getProjectWithoutDocLines @@ -617,7 +620,7 @@ describe 'ProjectEntityHandler', -> it "should update the doc in the docstore", -> @DocstoreManager.updateDoc - .calledWith(project_id, doc_id, @lines, @version) + .calledWith(project_id, doc_id, @lines, @version, @ranges) .should.equal true it "should mark the project as updated", -> @@ -642,7 +645,7 @@ describe 'ProjectEntityHandler', -> describe "when the doc has not been modified", -> beforeEach -> @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5) - @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @callback + @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @ranges, @callback it "should not mark the project as updated", -> @projectUpdater.markAsUpdated.called.should.equal false @@ -656,7 +659,7 @@ describe 'ProjectEntityHandler', -> describe "when the project is not found", -> beforeEach -> @ProjectGetter.getProjectWithoutDocLines = sinon.stub().callsArgWith(1, null, null) - @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @callback + @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @ranges, @version, @callback it "should return a not found error", -> @callback.calledWith(new Errors.NotFoundError()).should.equal true @@ -664,7 +667,7 @@ describe 'ProjectEntityHandler', -> describe "when the doc is not found", -> beforeEach -> @projectLocator.findElement = sinon.stub().callsArgWith(1, null, null, null) - @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @version, @callback + @ProjectEntityHandler.updateDocLines project_id, doc_id, @lines, @ranges, @version, @callback it "should log out the error", -> @logger.error diff --git a/services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee b/services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee index 2c4dc59262..bbb4dcd675 100644 --- a/services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee +++ b/services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee @@ -1,78 +1,74 @@ SandboxedModule = require('sandboxed-module') sinon = require('sinon') require('chai').should() +expect = require('chai').expect modulePath = require('path').join __dirname, '../../../../app/js/Features/Security/LoginRateLimiter' -buildKey = (k)-> - return "LoginRateLimit:#{k}" describe "LoginRateLimiter", -> + beforeEach -> @email = "bob@bob.com" - @incrStub = sinon.stub() - @getStub = sinon.stub() - @execStub = sinon.stub() - @expireStub = sinon.stub() - @delStub = sinon.stub().callsArgWith(1) - - @rclient = - auth:-> - del: @delStub - multi: => - incr: @incrStub - expire: @expireStub - get: @getStub - exec: @execStub + @RateLimiter = + clearRateLimit: sinon.stub() + addCount: sinon.stub() @LoginRateLimiter = SandboxedModule.require modulePath, requires: - 'redis-sharelatex' : createClient: () => @rclient - "settings-sharelatex":{redis:{}} - + '../../infrastructure/RateLimiter': @RateLimiter + describe "processLoginRequest", -> - it "should inc the counter for login requests in redis", (done)-> - @execStub.callsArgWith(0, "null", ["",""]) - @LoginRateLimiter.processLoginRequest @email, => - @incrStub.calledWith(buildKey(@email)).should.equal true + beforeEach -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) + + it 'should call RateLimiter.addCount', (done) -> + @LoginRateLimiter.processLoginRequest @email, (err, allow) => + @RateLimiter.addCount.callCount.should.equal 1 + expect(@RateLimiter.addCount.lastCall.args[0].endpointName).to.equal 'login' + expect(@RateLimiter.addCount.lastCall.args[0].subjectName).to.equal @email done() - it "should set a expire", (done)-> - @execStub.callsArgWith(0, "null", ["",""]) - @LoginRateLimiter.processLoginRequest @email, => - @expireStub.calledWith(buildKey(@email), 60 * 2).should.equal true - done() + describe 'when login is allowed', -> - it "should return true if the count is below 10", (done)-> - @execStub.callsArgWith(0, "null", ["", 9]) - @LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=> - isAllowed.should.equal true - done() + beforeEach -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true) - it "should return true if the count is 10", (done)-> - @execStub.callsArgWith(0, "null", ["", 10]) - @LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=> - isAllowed.should.equal true - done() + it 'should call pass allow=true', (done) -> + @LoginRateLimiter.processLoginRequest @email, (err, allow) => + expect(err).to.equal null + expect(allow).to.equal true + done() - it "should return false if the count is above 10", (done)-> - @execStub.callsArgWith(0, "null", ["", 11]) - @LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=> - isAllowed.should.equal false - done() + describe 'when login is blocked', -> + beforeEach -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, null, false) - describe "smoke test user", -> - - it "should have a higher limit", (done)-> - done() + it 'should call pass allow=false', (done) -> + @LoginRateLimiter.processLoginRequest @email, (err, allow) => + expect(err).to.equal null + expect(allow).to.equal false + done() + describe 'when addCount produces an error', -> + beforeEach -> + @RateLimiter.addCount = sinon.stub().callsArgWith(1, new Error('woops')) + it 'should produce an error', (done) -> + @LoginRateLimiter.processLoginRequest @email, (err, allow) => + expect(err).to.not.equal null + expect(err).to.be.instanceof Error + done() describe "recordSuccessfulLogin", -> - it "should delete the user key", (done)-> + beforeEach -> + @RateLimiter.clearRateLimit = sinon.stub().callsArgWith 2, null + + it "should call clearRateLimit", (done)-> @LoginRateLimiter.recordSuccessfulLogin @email, => - @delStub.calledWith(buildKey(@email)).should.equal true - done() \ No newline at end of file + @RateLimiter.clearRateLimit.callCount.should.equal 1 + @RateLimiter.clearRateLimit.calledWith('login', @email).should.equal true + done() diff --git a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee index 93f00afad5..f81433e156 100644 --- a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee @@ -6,17 +6,17 @@ Settings = require("settings-sharelatex") describe "LimitationsManager", -> beforeEach -> - @project = { _id: "project-id" } - @user = { _id: "user-id", features:{} } + @project = { _id: @project_id = "project-id" } + @user = { _id: @user_id = "user-id", features:{} } @Project = findById: (project_id, fields, callback) => if project_id == @project_id callback null, @project else callback null, null - @User = - findById: (user_id, callback) => - if user_id == @user.id + @UserGetter = + getUser: (user_id, filter, callback) => + if user_id == @user_id callback null, @user else callback null, null @@ -26,7 +26,7 @@ describe "LimitationsManager", -> @LimitationsManager = SandboxedModule.require modulePath, requires: '../../models/Project' : Project: @Project - '../../models/User' : User: @User + '../User/UserGetter' : @UserGetter './SubscriptionLocator':@SubscriptionLocator 'settings-sharelatex' : @Settings = {} "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} @@ -37,6 +37,7 @@ describe "LimitationsManager", -> describe "when the project is owned by a user without a subscription", -> beforeEach -> @Settings.defaultPlanCode = collaborators: 23 + @project.owner_ref = @user_id delete @user.features @callback = sinon.stub() @LimitationsManager.allowedNumberOfCollaboratorsInProject(@project_id, @callback) @@ -46,6 +47,7 @@ describe "LimitationsManager", -> describe "when the project is owned by a user with a subscription", -> beforeEach -> + @project.owner_ref = @user_id @user.features = collaborators: 21 @callback = sinon.stub() @@ -53,6 +55,27 @@ describe "LimitationsManager", -> it "should return the number of collaborators the user is allowed", -> @callback.calledWith(null, @user.features.collaborators).should.equal true + + describe "allowedNumberOfCollaboratorsForUser", -> + describe "when the user has no features", -> + beforeEach -> + @Settings.defaultPlanCode = collaborators: 23 + delete @user.features + @callback = sinon.stub() + @LimitationsManager.allowedNumberOfCollaboratorsForUser(@user_id, @callback) + + it "should return the default number", -> + @callback.calledWith(null, @Settings.defaultPlanCode.collaborators).should.equal true + + describe "when the user has features", -> + beforeEach -> + @user.features = + collaborators: 21 + @callback = sinon.stub() + @LimitationsManager.allowedNumberOfCollaboratorsForUser(@user_id, @callback) + + it "should return the number of collaborators the user is allowed", -> + @callback.calledWith(null, @user.features.collaborators).should.equal true describe "canAddXCollaborators", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/TrackChanges/RangesManagerTests.coffee b/services/web/test/UnitTests/coffee/TrackChanges/RangesManagerTests.coffee new file mode 100644 index 0000000000..b9c95040c1 --- /dev/null +++ b/services/web/test/UnitTests/coffee/TrackChanges/RangesManagerTests.coffee @@ -0,0 +1,55 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +sinon = require('sinon') +path = require "path" +modulePath = path.join __dirname, "../../../../app/js/Features/TrackChanges/RangesManager" +expect = require("chai").expect + +describe "RangesManager", -> + beforeEach -> + @RangesManager = SandboxedModule.require modulePath, requires: + "../DocumentUpdater/DocumentUpdaterHandler": @DocumentUpdaterHandler = {} + "../Docstore/DocstoreManager": @DocstoreManager = {} + "../User/UserInfoManager": @UserInfoManager = {} + + describe "getAllChangesUsers", -> + beforeEach -> + @project_id = "mock-project-id" + @user_id1 = "mock-user-id-1" + @user_id1 = "mock-user-id-2" + @docs = [{ + ranges: + changes: [{ + op: { i: "foo", p: 42 } + metadata: + user_id: @user_id1 + }, { + op: { i: "bar", p: 102 } + metadata: + user_id: @user_id2 + }] + }, { + ranges: + changes: [{ + op: { i: "baz", p: 3 } + metadata: + user_id: @user_id1 + }] + }] + @users = {} + @users[@user_id1] = {"mock": "user-1"} + @users[@user_id2] = {"mock": "user-2"} + @UserInfoManager.getPersonalInfo = (user_id, callback) => callback null, @users[user_id] + sinon.spy @UserInfoManager, "getPersonalInfo" + @RangesManager.getAllRanges = sinon.stub().yields(null, @docs) + + it "should return an array of unique users", (done) -> + @RangesManager.getAllChangesUsers @project_id, (error, users) => + users.should.deep.equal [{"mock": "user-1"}, {"mock": "user-2"}] + done() + + it "should only call getPersonalInfo once for each user", (done) -> + @RangesManager.getAllChangesUsers @project_id, (error, users) => + @UserInfoManager.getPersonalInfo.calledTwice.should.equal true + done() \ No newline at end of file diff --git a/services/web/test/UnitTests/coffee/User/UserInfoControllerTests.coffee b/services/web/test/UnitTests/coffee/User/UserInfoControllerTests.coffee index df895e630d..37a1c034f0 100644 --- a/services/web/test/UnitTests/coffee/User/UserInfoControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/User/UserInfoControllerTests.coffee @@ -93,18 +93,18 @@ describe "UserInfoController", -> first_name: @user.first_name last_name: @user.last_name email: @user.email - @UserInfoController._formatPersonalInfo = sinon.stub().callsArgWith(1, null, @formattedInfo) + @UserInfoController.formatPersonalInfo = sinon.stub().returns(@formattedInfo) @UserInfoController.sendFormattedPersonalInfo @user, @res it "should format the user details for the response", -> - @UserInfoController._formatPersonalInfo + @UserInfoController.formatPersonalInfo .calledWith(@user) .should.equal true it "should send the formatted details back to the client", -> @res.body.should.equal JSON.stringify(@formattedInfo) - describe "_formatPersonalInfo", -> + describe "formatPersonalInfo", -> it "should return the correctly formatted data", -> @user = _id: ObjectId() @@ -115,14 +115,13 @@ describe "UserInfoController", -> signUpDate: new Date() role:"student" institution:"sheffield" - @UserInfoController._formatPersonalInfo @user, (error, info) => - expect(info).to.deep.equal { - id: @user._id.toString() - first_name: @user.first_name - last_name: @user.last_name - email: @user.email - signUpDate: @user.signUpDate - role: @user.role - institution: @user.institution - } + expect(@UserInfoController.formatPersonalInfo(@user)).to.deep.equal { + id: @user._id.toString() + first_name: @user.first_name + last_name: @user.last_name + email: @user.email + signUpDate: @user.signUpDate + role: @user.role + institution: @user.institution + } diff --git a/services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee b/services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee index 21c0d96f4f..06efac5d8b 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee +++ b/services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee @@ -6,7 +6,7 @@ expect = chai.expect modulePath = "../../../../app/js/infrastructure/RateLimiter.js" SandboxedModule = require('sandboxed-module') -describe "FileStoreHandler", -> +describe "RateLimiter", -> beforeEach -> @settings = @@ -15,23 +15,27 @@ describe "FileStoreHandler", -> port:"1234" host:"somewhere" password: "password" - @redbackInstance = - addCount: sinon.stub() + @rclient = + incr: sinon.stub() + get: sinon.stub() + expire: sinon.stub() + exec: sinon.stub() + @rclient.multi = sinon.stub().returns(@rclient) + @RedisWrapper = + client: sinon.stub().returns(@rclient) - @redback = - createRateLimit: sinon.stub().returns(@redbackInstance) - @redis = - createClient: -> - return auth:-> + @limiterFn = sinon.stub() + @RollingRateLimiter = (opts) => + return @limiterFn @limiter = SandboxedModule.require modulePath, requires: + "rolling-rate-limiter": @RollingRateLimiter "settings-sharelatex":@settings "logger-sharelatex" : @logger = {log:sinon.stub(), err:sinon.stub()} - "redis-sharelatex": @redis - "redback": use: => @redback + "./RedisWrapper": @RedisWrapper @endpointName = "compiles" - @subject = "some project id" + @subject = "some-project-id" @timeInterval = 20 @throttleLimit = 5 @@ -40,43 +44,48 @@ describe "FileStoreHandler", -> subjectName: @subject throttle: @throttleLimit timeInterval: @timeInterval + @key = "RateLimiter:#{@endpointName}:{#{@subject}}" - describe "addCount", -> + + + describe 'when action is permitted', -> beforeEach -> - @redbackInstance.addCount.callsArgWith(2, null, 10) + @limiterFn = sinon.stub().callsArgWith(1, null, 0, 22) - it "should use correct namespace", (done)-> - @limiter.addCount @details, => - @redback.createRateLimit.calledWith(@endpointName).should.equal true + it 'should not produce and error', (done) -> + @limiter.addCount {}, (err, should) -> + expect(err).to.equal null done() - it "should only call it once", (done)-> - @limiter.addCount @details, => - @redbackInstance.addCount.callCount.should.equal 1 + it 'should callback with true', (done) -> + @limiter.addCount {}, (err, should) -> + expect(should).to.equal true done() - it "should use the subjectName", (done)-> - @limiter.addCount @details, => - @redbackInstance.addCount.calledWith(@details.subjectName, @details.timeInterval).should.equal true + describe 'when action is not permitted', -> + + beforeEach -> + @limiterFn = sinon.stub().callsArgWith(1, null, 4000, 0) + + it 'should not produce and error', (done) -> + @limiter.addCount {}, (err, should) -> + expect(err).to.equal null done() - it "should return true if the count is less than throttle", (done)-> - @details.throttle = 100 - @limiter.addCount @details, (err, canProcess)=> - canProcess.should.equal true + it 'should callback with false', (done) -> + @limiter.addCount {}, (err, should) -> + expect(should).to.equal false done() - it "should return true if the count is less than throttle", (done)-> - @details.throttle = 1 - @limiter.addCount @details, (err, canProcess)=> - canProcess.should.equal false - done() + describe 'when limiter produces an error', -> - it "should return false if the limit is matched", (done)-> - @details.throttle = 10 - @limiter.addCount @details, (err, canProcess)=> - canProcess.should.equal false - done() + beforeEach -> + @limiterFn = sinon.stub().callsArgWith(1, new Error('woops')) + it 'should produce and error', (done) -> + @limiter.addCount {}, (err, should) -> + expect(err).to.not.equal null + expect(err).to.be.instanceof Error + done() diff --git a/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee b/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee new file mode 100644 index 0000000000..83ea202dcd --- /dev/null +++ b/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee @@ -0,0 +1,66 @@ +assert = require("chai").assert +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/infrastructure/RedisWrapper.js" +SandboxedModule = require('sandboxed-module') + +describe 'RedisWrapper', -> + + beforeEach -> + @featureName = 'somefeature' + @settings = + redis: + web: + port:"1234" + host:"somewhere" + password: "password" + somefeature: {} + @normalRedisInstance = + thisIsANormalRedisInstance: true + n: 1 + @clusterRedisInstance = + thisIsAClusterRedisInstance: true + n: 2 + @redis = + createClient: sinon.stub().returns(@normalRedisInstance) + @ioredis = + Cluster: sinon.stub().returns(@clusterRedisInstance) + @logger = {log: sinon.stub()} + + @RedisWrapper = SandboxedModule.require modulePath, requires: + 'logger-sharelatex': @logger + 'settings-sharelatex': @settings + 'redis-sharelatex': @redis + 'ioredis': @ioredis + + describe 'client', -> + + beforeEach -> + @call = () => + @RedisWrapper.client(@featureName) + + describe 'when feature uses cluster', -> + + beforeEach -> + @settings.redis.somefeature = + cluster: [1, 2, 3] + + it 'should return a cluster client', -> + client = @call() + expect(client).to.equal @clusterRedisInstance + expect(client.__is_redis_cluster).to.equal true + + describe 'when feature uses normal redis', -> + + beforeEach -> + @settings.redis.somefeature = + port:"1234" + host:"somewhere" + password: "password" + + it 'should return a regular redis client', -> + client = @call() + expect(client).to.equal @normalRedisInstance + expect(client.__is_redis_cluster).to.equal undefined diff --git a/services/web/test/acceptance/coffee/RegistrationTests.coffee b/services/web/test/acceptance/coffee/RegistrationTests.coffee index 2bf96f86de..20ea0a31b1 100644 --- a/services/web/test/acceptance/coffee/RegistrationTests.coffee +++ b/services/web/test/acceptance/coffee/RegistrationTests.coffee @@ -1,9 +1,11 @@ expect = require("chai").expect +assert = require("chai").assert async = require("async") User = require "./helpers/User" request = require "./helpers/request" settings = require "settings-sharelatex" redis = require "./helpers/redis" +_ = require 'lodash' @@ -32,6 +34,41 @@ tryLoginThroughRegistrationForm = (user, email, password, callback=(err, respons }, callback +describe "LoginRateLimit", -> + + before -> + @user = new User() + @badEmail = 'bademail@example.com' + @badPassword = 'badpassword' + + it 'should rate limit login attempts after 10 within two minutes', (done) -> + @user.request.get '/login', (err, res, body) => + async.timesSeries( + 15 + , (n, cb) => + @user.getCsrfToken (error) => + return cb(error) if error? + @user.request.post { + url: "/login" + json: + email: @badEmail + password: @badPassword + }, (err, response, body) => + cb(null, body?.message?.text) + , (err, results) => + # ten incorrect-credentials messages, then five rate-limit messages + expect(results.length).to.equal 15 + assert.deepEqual( + results, + _.concat( + _.fill([1..10], 'Your email or password is incorrect. Please try again'), + _.fill([1..5], 'This account has had too many login requests. Please wait 2 minutes before trying to log in again') + ) + ) + done() + ) + + describe "LoginViaRegistration", -> before (done) ->