diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index e2db435299..7d49df07af 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -95,10 +95,10 @@ module.exports = (grunt) -> paths: "moment": "libs/moment-2.9.0" "mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML" - "libs/pdf": "libs/pdfjs-1.0.1040/pdf" + "libs/pdf": "libs/pdfjs-1.3.91/pdf" shim: "libs/pdf": - deps: ["libs/pdfjs-1.0.1040/compatibility"] + deps: ["libs/pdfjs-1.3.91/compatibility"] skipDirOptimize: true modules: [ diff --git a/services/web/app.coffee b/services/web/app.coffee index a314885ccf..3d45307e5d 100644 --- a/services/web/app.coffee +++ b/services/web/app.coffee @@ -3,8 +3,12 @@ logger = require 'logger-sharelatex' logger.initialize("web-sharelatex") logger.logger.serializers.user = require("./app/js/infrastructure/LoggerSerializers").user logger.logger.serializers.project = require("./app/js/infrastructure/LoggerSerializers").project +if Settings.sentry?.dsn? + logger.initializeErrorReporting(Settings.sentry.dsn) + metrics = require("metrics-sharelatex") metrics.initialize("web") +metrics.memory.monitor(logger) Server = require("./app/js/infrastructure/Server") Errors = require "./app/js/errors" @@ -15,6 +19,10 @@ argv = require("optimist") .argv Server.app.use (error, req, res, next) -> + if error?.code is 'EBADCSRFTOKEN' + logger.log err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf" + res.sendStatus(403) + return logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear" res.statusCode = error.status or 500 if res.statusCode == 500 diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index c992fec578..312b441158 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -8,13 +8,16 @@ querystring = require('querystring') Url = require("url") Settings = require "settings-sharelatex" basicAuth = require('basic-auth-connect') - +UserHandler = require("../User/UserHandler") module.exports = AuthenticationController = login: (req, res, next = (error) ->) -> - email = req.body?.email?.toLowerCase() - password = req.body?.password - redir = Url.parse(req.body?.redir or "/project").path + AuthenticationController.doLogin req.body, req, res, next + + doLogin: (options, req, res, next) -> + email = options.email?.toLowerCase() + password = options.password + redir = Url.parse(options.redir or "/project").path LoginRateLimiter.processLoginRequest email, (err, isAllowed)-> if !isAllowed logger.log email:email, "too many login requests" @@ -26,17 +29,18 @@ module.exports = AuthenticationController = AuthenticationManager.authenticate email: email, password, (error, user) -> return next(error) if error? if user? + UserHandler.setupLoginData user, -> LoginRateLimiter.recordSuccessfulLogin email AuthenticationController._recordSuccessfulLogin user._id AuthenticationController.establishUserSession req, user, (error) -> return next(error) if error? req.session.justLoggedIn = true logger.log email: email, user_id: user._id.toString(), "successful log in" - res.send redir: redir + res.json redir: redir else AuthenticationController._recordFailedLogin() logger.log email: email, "failed log in" - res.send message: + res.json message: text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error' diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee index 254c5c6c41..d815b426fe 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee @@ -1,4 +1,3 @@ -Settings = require 'settings-sharelatex' User = require("../../models/User").User {db, ObjectId} = require("../../infrastructure/mongojs") crypto = require 'crypto' diff --git a/services/web/app/coffee/Features/Blog/BlogController.coffee b/services/web/app/coffee/Features/Blog/BlogController.coffee index 186d11d348..8f726602c3 100644 --- a/services/web/app/coffee/Features/Blog/BlogController.coffee +++ b/services/web/app/coffee/Features/Blog/BlogController.coffee @@ -4,15 +4,13 @@ logger = require("logger-sharelatex") _ = require("underscore") ErrorController = require "../Errors/ErrorController" -extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps"] - module.exports = BlogController = getPage: (req, res, next)-> url = req.url?.toLowerCase() blogUrl = "#{settings.apis.blog.url}#{url}" - extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps"] + extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps", ".gif"] shouldProxy = _.find extensionsToProxy, (extension)-> url.indexOf(extension) != -1 diff --git a/services/web/app/coffee/Features/Compile/ClsiManager.coffee b/services/web/app/coffee/Features/Compile/ClsiManager.coffee index e03916c0fe..8b7ef023bd 100755 --- a/services/web/app/coffee/Features/Compile/ClsiManager.coffee +++ b/services/web/app/coffee/Features/Compile/ClsiManager.coffee @@ -58,8 +58,8 @@ module.exports = ClsiManager = return outputFiles VALID_COMPILERS: ["pdflatex", "latex", "xelatex", "lualatex"] - _buildRequest: (project_id, settingsOverride={}, callback = (error, request) ->) -> - Project.findById project_id, {compiler: 1, rootDoc_id: 1}, (error, project) -> + _buildRequest: (project_id, options={}, callback = (error, request) ->) -> + Project.findById project_id, {compiler: 1, rootDoc_id: 1, imageName: 1}, (error, project) -> return callback(error) if error? return callback(new Errors.NotFoundError("project does not exist: #{project_id}")) if !project? @@ -82,7 +82,7 @@ module.exports = ClsiManager = content: doc.lines.join("\n") if project.rootDoc_id? and doc._id.toString() == project.rootDoc_id.toString() rootResourcePath = path - if settingsOverride.rootDoc_id? and doc._id.toString() == settingsOverride.rootDoc_id.toString() + if options.rootDoc_id? and doc._id.toString() == options.rootDoc_id.toString() rootResourcePathOverride = path rootResourcePath = rootResourcePathOverride if rootResourcePathOverride? @@ -101,7 +101,9 @@ module.exports = ClsiManager = compile: options: compiler: project.compiler - timeout: settingsOverride.timeout + timeout: options.timeout + imageName: project.imageName + draft: !!options.draft rootResourcePath: rootResourcePath resources: resources } @@ -110,8 +112,11 @@ module.exports = ClsiManager = ClsiManager._buildRequest project_id, options, (error, req) -> compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup) filename = file || req?.compile?.rootResourcePath + wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}" + if req.compile.options.imageName? + wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}" request.get { - url: "#{compilerUrl}/project/#{project_id}/wordcount?file=#{filename}" + url: wordcount_url }, (error, response, body) -> return callback(error) if error? if 200 <= response.statusCode < 300 diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee index 587f00647c..89fbc87e2e 100755 --- a/services/web/app/coffee/Features/Compile/CompileController.coffee +++ b/services/web/app/coffee/Features/Compile/CompileController.coffee @@ -25,6 +25,8 @@ module.exports = CompileController = options.rootDoc_id = req.body.settingsOverride.rootDoc_id if req.body?.compiler options.compiler = req.body.compiler + if req.body?.draft + options.draft = req.body.draft logger.log {options, project_id}, "got compile request" CompileManager.compile project_id, user_id, options, (error, status, outputFiles, output, limits) -> return next(error) if error? diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index b7760b40c5..dcf0615b25 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -1,6 +1,5 @@ request = require 'request' request = request.defaults() -async = require 'async' settings = require 'settings-sharelatex' _ = require 'underscore' async = require 'async' @@ -116,7 +115,7 @@ 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}") - setDocument : (project_id, doc_id, docLines, source, callback = (error) ->)-> + setDocument : (project_id, doc_id, user_id, docLines, source, callback = (error) ->)-> timer = new metrics.Timer("set-document") url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}" body = @@ -124,7 +123,8 @@ module.exports = DocumentUpdaterHandler = json: lines: docLines source: source - logger.log project_id:project_id, doc_id: doc_id, source: source, "setting doc in document updater" + user_id: user_id + logger.log project_id:project_id, doc_id: doc_id, source: source, user_id: user_id, "setting doc in document updater" request.post body, (error, res, body)-> timer.done() if error? diff --git a/services/web/app/coffee/Features/Documents/DocumentController.coffee b/services/web/app/coffee/Features/Documents/DocumentController.coffee index 4ac575af58..8cae627ab6 100644 --- a/services/web/app/coffee/Features/Documents/DocumentController.coffee +++ b/services/web/app/coffee/Features/Documents/DocumentController.coffee @@ -6,15 +6,20 @@ module.exports = getDocument: (req, res, next = (error) ->) -> project_id = req.params.Project_id 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) -> if error? logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument" return next(error) - res.type "json" - res.send JSON.stringify { - lines: lines - } + if plain + res.type "text/plain" + res.send lines.join('\n') + else + res.type "json" + res.send JSON.stringify { + lines: lines + } setDocument: (req, res, next = (error) ->) -> project_id = req.params.Project_id diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index e2ca000474..38f788dd41 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -13,8 +13,8 @@ LockManager = require("../../infrastructure/LockManager") _ = require('underscore') module.exports = EditorController = - setDoc: (project_id, doc_id, docLines, source, callback = (err)->)-> - DocumentUpdaterHandler.setDocument project_id, doc_id, docLines, source, (err)=> + setDoc: (project_id, doc_id, user_id, docLines, source, callback = (err)->)-> + DocumentUpdaterHandler.setDocument project_id, doc_id, user_id, docLines, source, (err)=> logger.log project_id:project_id, doc_id:doc_id, "notifying users that the document has been updated" DocumentUpdaterHandler.flushDocToMongo project_id, doc_id, callback @@ -33,10 +33,12 @@ module.exports = EditorController = logger.log {project_id, folder_id, docName, source}, "sending new doc to project" Metrics.inc "editor.add-doc" ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, (err, doc, folder_id)=> + if err? + logger.err err:err, project_id:project_id, docName:docName, "error adding doc without lock" + return callback(err) EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc, source) callback(err, doc) - addFile: (project_id, folder_id, fileName, path, source, callback = (error, file)->)-> LockManager.getLock project_id, (err)-> if err? @@ -46,20 +48,20 @@ module.exports = EditorController = LockManager.releaseLock project_id, -> callback(error, file) - addFileWithoutLock: (project_id, folder_id, fileName, path, source, callback = (error, file)->)-> fileName = fileName.trim() logger.log {project_id, folder_id, fileName, path}, "sending new file to project" Metrics.inc "editor.add-file" ProjectEntityHandler.addFile project_id, folder_id, fileName, path, (err, fileRef, folder_id)=> + if err? + logger.err err:err, project_id:project_id, folder_id:folder_id, fileName:fileName, "error adding file without lock" + return callback(err) EditorRealTimeController.emitToRoom(project_id, 'reciveNewFile', folder_id, fileRef, source) callback(err, fileRef) replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)-> ProjectEntityHandler.replaceFile project_id, file_id, fsPath, callback - - addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)-> LockManager.getLock project_id, (err)-> if err? @@ -74,6 +76,9 @@ module.exports = EditorController = logger.log {project_id, folder_id, folderName, source}, "sending new folder to project" Metrics.inc "editor.add-folder" ProjectEntityHandler.addFolder project_id, folder_id, folderName, (err, folder, folder_id)=> + if err? + logger.err err:err, project_id:project_id, folder_id:folder_id, folderName:folderName, "error adding folder without lock" + return callback(err) @p.notifyProjectUsersOfNewFolder project_id, folder_id, folder, (error) -> callback error, folder @@ -90,6 +95,9 @@ module.exports = EditorController = mkdirpWithoutLock: (project_id, path, callback)-> logger.log project_id:project_id, path:path, "making directories if they don't exist" ProjectEntityHandler.mkdirp project_id, path, (err, newFolders, lastFolder)=> + if err? + logger.err err:err, project_id:project_id, path:path, "error mkdirp without lock" + return callback(err) self = @ jobs = _.map newFolders, (folder, index)-> return (cb)-> @@ -109,7 +117,10 @@ module.exports = EditorController = deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)-> logger.log {project_id, entity_id, entityType, source}, "start delete process of entity" Metrics.inc "editor.delete-entity" - ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, => + ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)-> + if err? + logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity" + return callback(err) logger.log project_id:project_id, entity_id:entity_id, entityType:entityType, "telling users entity has been deleted" EditorRealTimeController.emitToRoom(project_id, 'removeEntity', entity_id, source) if callback? @@ -143,19 +154,28 @@ module.exports = EditorController = newName = sanitize.escape(newName) Metrics.inc "editor.rename-entity" logger.log entity_id:entity_id, entity_id:entity_id, entity_id:entity_id, "reciving new name for entity for project" - ProjectEntityHandler.renameEntity project_id, entity_id, entityType, newName, => + ProjectEntityHandler.renameEntity project_id, entity_id, entityType, newName, -> + if err? + logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, newName:newName, "error renaming entity" + return callback(err) if newName.length > 0 EditorRealTimeController.emitToRoom project_id, 'reciveEntityRename', entity_id, newName callback?() -# + moveEntity: (project_id, entity_id, folder_id, entityType, callback)-> Metrics.inc "editor.move-entity" ProjectEntityHandler.moveEntity project_id, entity_id, folder_id, entityType, => + if err? + logger.err err:err, project_id:project_id, entity_id:entity_id, folder_id:folder_id, "error moving entity" + return callback(err) EditorRealTimeController.emitToRoom project_id, 'reciveEntityMove', entity_id, folder_id callback?() renameProject: (project_id, newName, callback = (err) ->) -> - ProjectDetailsHandler.renameProject project_id, newName, => + ProjectDetailsHandler.renameProject project_id, newName, -> + if err? + logger.err err:err, project_id:project_id, newName:newName, "error renaming project" + return callback(err) EditorRealTimeController.emitToRoom project_id, 'projectNameUpdated', newName callback() diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index 2619316e6d..c52d0f5c67 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -67,11 +67,16 @@ module.exports = EditorHttpController = project_id = req.params.Project_id name = req.body.name parent_folder_id = req.body.parent_folder_id + logger.log project_id:project_id, name:name, parent_folder_id:parent_folder_id, "getting request to add doc to project" if !EditorHttpController._nameIsAcceptableLength(name) return res.sendStatus 400 EditorController.addDoc project_id, parent_folder_id, name, [], "editor", (error, doc) -> - return next(error) if error? - res.json doc + if error == "project_has_to_many_files" + res.status(400).json(req.i18n.translate("project_has_to_many_files")) + else if error? + next(error) + else + res.json doc addFolder: (req, res, next) -> project_id = req.params.Project_id @@ -80,8 +85,12 @@ module.exports = EditorHttpController = if !EditorHttpController._nameIsAcceptableLength(name) return res.sendStatus 400 EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) -> - return next(error) if error? - res.json doc + if error == "project_has_to_many_files" + res.status(400).json(req.i18n.translate("project_has_to_many_files")) + else if error? + next(error) + else + res.json doc renameEntity: (req, res, next) -> project_id = req.params.Project_id diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index 8d37f38c85..e14b9e4582 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -1,5 +1,4 @@ _ = require('underscore') - PersonalEmailLayout = require("./Layouts/PersonalEmailLayout") NotificationEmailLayout = require("./Layouts/NotificationEmailLayout") settings = require("settings-sharelatex") @@ -15,8 +14,6 @@ templates.registered =

Click here to set your password and log in.

-

Once you have reset your password you can log in here.

-

If you have any questions or problems, please contact #{settings.adminEmail}.

""" diff --git a/services/web/app/coffee/Features/Email/EmailSender.coffee b/services/web/app/coffee/Features/Email/EmailSender.coffee index 680d7708c3..c5ff09b1b0 100644 --- a/services/web/app/coffee/Features/Email/EmailSender.coffee +++ b/services/web/app/coffee/Features/Email/EmailSender.coffee @@ -1,8 +1,9 @@ logger = require('logger-sharelatex') metrics = require('../../infrastructure/Metrics') Settings = require('settings-sharelatex') -metrics = require("../../infrastructure/Metrics") nodemailer = require("nodemailer") +sesTransport = require('nodemailer-ses-transport') +_ = require("underscore") if Settings.email? and Settings.email.fromAddress? defaultFromAddress = Settings.email.fromAddress @@ -15,15 +16,24 @@ client = logger.log options:options, "Would send email if enabled." callback() -if Settings.email? - if Settings.email.transport? and Settings.email.parameters? - nm_client = nodemailer.createTransport( Settings.email.transport, Settings.email.parameters ) - if nm_client - client = nm_client - else - logger.warn "Failed to create email transport. Please check your settings. No email will be sent." - else - logger.warn "Email transport and/or parameters not defined. No emails will be sent." +if Settings?.email?.parameters?.AWSAccessKeyID? + logger.log "using aws ses for email" + nm_client = nodemailer.createTransport(sesTransport(Settings.email.parameters)) +else if Settings?.email?.parameters? + smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth") + + + logger.log "using smtp for email" + nm_client = nodemailer.createTransport(smtp) +else + nm_client = client + logger.warn "Email transport and/or parameters not defined. No emails will be sent." + +if nm_client? + client = nm_client +else + logger.warn "Failed to create email transport. Please check your settings. No email will be sent." + module.exports = sendEmail : (options, callback = (error) ->)-> @@ -42,4 +52,3 @@ module.exports = else logger.log "Message sent to #{options.to}" callback(err) - diff --git a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee index 34ab6f8ece..09790cf99f 100644 --- a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee +++ b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee @@ -6,24 +6,32 @@ settings = require("settings-sharelatex") oneMinInMs = 60 * 1000 fiveMinsInMs = oneMinInMs * 5 -module.exports = +module.exports = FileStoreHandler = uploadFileFromDisk: (project_id, file_id, fsPath, callback)-> - logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "uploading file from disk" - readStream = fs.createReadStream(fsPath) - opts = - method: "post" - uri: @_buildUrl(project_id, file_id) - timeout:fiveMinsInMs - writeStream = request(opts) - readStream.pipe writeStream - writeStream.on "end", callback - readStream.on "error", (err)-> - logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk" - callback err - writeStream.on "error", (err)-> - logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk" - callback err + fs.lstat fsPath, (err, stat)-> + if err? + logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file" + callback(err) + if !stat.isFile() + logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining" + return callback(new Error("can not upload symlink")) + + logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "uploading file from disk" + readStream = fs.createReadStream(fsPath) + opts = + method: "post" + uri: FileStoreHandler._buildUrl(project_id, file_id) + timeout:fiveMinsInMs + writeStream = request(opts) + readStream.pipe writeStream + writeStream.on "end", callback + readStream.on "error", (err)-> + logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk" + callback err + writeStream.on "error", (err)-> + logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk" + callback err getFileStream: (project_id, file_id, query, callback)-> logger.log project_id:project_id, file_id:file_id, query:query, "getting file stream from file store" diff --git a/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee new file mode 100644 index 0000000000..314142b3af --- /dev/null +++ b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee @@ -0,0 +1,16 @@ + +NotificationsHandler = require("./NotificationsHandler") + +module.exports = + + groupPlan: (user, licence)-> + key : "join-sub-#{licence.subscription_id}" + + create: (callback = ->)-> + messageOpts = + groupName: licence.name + subscription_id: licence.subscription_id + NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback + + read: (callback = ->)-> + NotificationsHandler.markAsReadWithKey user._id, @key, callback diff --git a/services/web/app/coffee/Features/Notifications/NotificationsController.coffee b/services/web/app/coffee/Features/Notifications/NotificationsController.coffee new file mode 100644 index 0000000000..c4931f5327 --- /dev/null +++ b/services/web/app/coffee/Features/Notifications/NotificationsController.coffee @@ -0,0 +1,19 @@ +NotificationsHandler = require("./NotificationsHandler") +logger = require("logger-sharelatex") +_ = require("underscore") + +module.exports = + + getAllUnreadNotifications: (req, res)-> + NotificationsHandler.getUserNotifications req.session.user._id, (err, unreadNotifications)-> + unreadNotifications = _.map unreadNotifications, (notification)-> + notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts) + return notification + res.send(unreadNotifications) + + markNotificationAsRead: (req, res)-> + user_id = req.session.user._id + notification_id = req.params.notification_id + NotificationsHandler.markAsRead user_id, notification_id, -> + res.send() + logger.log user_id:user_id, notification_id:notification_id, "mark notification as read" diff --git a/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee new file mode 100644 index 0000000000..a26bfff16a --- /dev/null +++ b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee @@ -0,0 +1,52 @@ +settings = require("settings-sharelatex") +request = require("request") +logger = require("logger-sharelatex") + +oneSecond = 1000 +module.exports = + + getUserNotifications: (user_id, callback)-> + opts = + uri: "#{settings.apis.notifications.url}/user/#{user_id}" + json: true + timeout: oneSecond + request.get opts, (err, res, unreadNotifications)-> + statusCode = if res? then res.statusCode else 500 + if err? or statusCode != 200 + e = new Error("something went wrong getting notifications, #{err}, #{statusCode}") + logger.err err:err, "something went wrong getting notifications" + callback(null, []) + else + if !unreadNotifications? + unreadNotifications = [] + callback(null, unreadNotifications) + + createNotification: (user_id, key, templateKey, messageOpts, callback)-> + opts = + uri: "#{settings.apis.notifications.url}/user/#{user_id}" + timeout: oneSecond + json: { + key:key + messageOpts:messageOpts + templateKey:templateKey + } + logger.log opts:opts, "creating notification for user" + request.post opts, callback + + markAsReadWithKey: (user_id, key, callback)-> + opts = + uri: "#{settings.apis.notifications.url}/user/#{user_id}" + timeout: oneSecond + json: { + key:key + } + logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api" + request.del opts, callback + + + markAsRead: (user_id, notification_id, callback)-> + opts = + uri: "#{settings.apis.notifications.url}/user/#{user_id}/notification/#{notification_id}" + timeout:oneSecond + logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api" + request.del opts, callback diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee index d574a6838f..fb0f75beee 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee @@ -1,5 +1,7 @@ PasswordResetHandler = require("./PasswordResetHandler") RateLimiter = require("../../infrastructure/RateLimiter") +AuthenticationController = require("../Authentication/AuthenticationController") +UserGetter = require("../User/UserGetter") logger = require "logger-sharelatex" module.exports = @@ -37,14 +39,19 @@ module.exports = title:"set_password" passwordResetToken: req.session.resetToken - setNewUserPassword: (req, res)-> + setNewUserPassword: (req, res, next)-> {passwordResetToken, password} = req.body if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0 return res.sendStatus 400 delete req.session.resetToken - PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found) -> + PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) -> return next(err) if err? if found - res.sendStatus 200 + 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 + else + res.sendStatus 200 else - res.send 404, {message: req.i18n.translate("password_reset_token_expired")} + res.sendStatus 404 diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee index f4bad9c593..4e67e9f1f4 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee @@ -23,11 +23,11 @@ module.exports = return callback(error) if error? callback null, true - setNewUserPassword: (token, password, callback = (error, found) ->)-> + setNewUserPassword: (token, password, callback = (error, found, user_id) ->)-> OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)-> if err then return callback(err) if !user_id? - return callback null, false + return callback null, false, null AuthenticationManager.setUserPassword user_id, password, (err) -> if err then return callback(err) - callback null, true \ No newline at end of file + callback null, true, user_id \ No newline at end of file diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 67c863bde4..9a04fafd15 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -9,6 +9,7 @@ Project = require('../../models/Project').Project User = require('../../models/User').User TagsHandler = require("../Tags/TagsHandler") SubscriptionLocator = require("../Subscription/SubscriptionLocator") +NotificationsHandler = require("../Notifications/NotificationsHandler") LimitationsManager = require("../Subscription/LimitationsManager") _ = require("underscore") Settings = require("settings-sharelatex") @@ -51,7 +52,7 @@ module.exports = ProjectController = deleteProject: (req, res) -> project_id = req.params.Project_id forever = req.query?.forever? - logger.log project_id: project_id, forever: forever, "received request to delete project" + logger.log project_id: project_id, forever: forever, "received request to archive project" if forever doDelete = projectDeleter.deleteProject @@ -125,6 +126,8 @@ module.exports = ProjectController = async.parallel { tags: (cb)-> TagsHandler.getAllTags user_id, cb + notifications: (cb)-> + NotificationsHandler.getUserNotifications user_id, cb projects: (cb)-> Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb hasSubscription: (cb)-> @@ -137,6 +140,9 @@ module.exports = ProjectController = return next(err) logger.log results:results, user_id:user_id, "rendering project list" tags = results.tags[0] + notifications = require("underscore").map results.notifications, (notification)-> + notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts) + return notification projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2] user = results.user ProjectController._injectProjectOwners projects, (error, projects) -> @@ -147,6 +153,7 @@ module.exports = ProjectController = priority_title: true projects: projects tags: tags + notifications: notifications or [] user: user hasSubscription: results.hasSubscription[0] } @@ -168,15 +175,15 @@ module.exports = ProjectController = return res.render("general/closed", {title:"updating_site"}) if req.session.user? - user_id = req.session.user._id + user_id = req.session.user._id anonymous = false else anonymous = true user_id = 'openUser' - + project_id = req.params.Project_id logger.log project_id:project_id, "loading editor" - + async.parallel { project: (cb)-> Project.findPopulatedById project_id, cb @@ -193,7 +200,7 @@ module.exports = ProjectController = SubscriptionLocator.getUsersSubscription user_id, cb activate: (cb)-> InactiveProjectManager.reactivateProjectIfRequired project_id, cb - markAsOpened: (cb)-> + markAsOpened: (cb)-> #don't need to wait for this to complete ProjectUpdateHandler.markAsOpened project_id, -> cb() @@ -205,6 +212,7 @@ module.exports = ProjectController = user = results.user subscription = results.subscription + daysSinceLastUpdated = (new Date() - project.lastUpdated) /86400000 logger.log project_id:project_id, daysSinceLastUpdated:daysSinceLastUpdated, "got db results for loading editor" @@ -214,6 +222,7 @@ module.exports = ProjectController = if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt? allowedFreeTrial = !!subscription.freeTrial.allowed || true + logger.log project_id:project_id, "rendering editor page" res.render 'project/editor', title: project.name @@ -310,5 +319,4 @@ do generateThemeList = () -> for file in files if file.slice(-2) == "js" and file.match(/^theme-/) cleanName = file.slice(0,-3).slice(6) - THEME_LIST.push cleanName - + THEME_LIST.push cleanName \ No newline at end of file diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index c461c04341..a21dfc7f9e 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -19,6 +19,8 @@ module.exports = project = new Project owner_ref : new ObjectId(owner_id) name : projectName + if Settings.currentImageName? + project.imageName = Settings.currentImageName project.rootFolder[0] = rootFolder User.findById owner_id, "ace.spellCheckLanguage", (err, user)-> project.spellCheckLanguage = user.ace.spellCheckLanguage @@ -33,7 +35,9 @@ module.exports = self._buildTemplate "mainbasic.tex", owner_id, projectName, (error, docLines)-> return callback(error) if error? ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, (error, doc)-> - return callback(error) if error? + if error? + logger.err err:error, "error adding doc when creating basic project" + return callback(error) ProjectEntityHandler.setRootDoc project._id, doc._id, (error) -> callback(error, project) diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee index 6fdba733e7..cdbc933135 100644 --- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee @@ -1,4 +1,5 @@ Project = require('../../models/Project').Project +ProjectGetter = require("./ProjectGetter") logger = require('logger-sharelatex') documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') tagsHandler = require("../Tags/TagsHandler") @@ -33,10 +34,10 @@ module.exports = ProjectDeleter = Project.remove _id: project_id, callback archiveProject: (project_id, callback = (error) ->)-> - logger.log project_id:project_id, "deleting project" - Project.findById project_id, (err, project)=> + logger.log project_id:project_id, "archived project from user request" + ProjectGetter.getProject project_id, {owner_ref:true, collaberator_refs:true, readOnly_refs:true}, (err, project)=> if err? or !project? - logger.err err:err, project_id:project_id, "error getting project to delete it" + logger.err err:err, project_id:project_id, "error getting project to archived it" callback(err) else async.series [ @@ -57,8 +58,10 @@ module.exports = ProjectDeleter = Project.update {_id:project_id}, { $set: { archived: true }}, cb ], (err)-> if err? - logger.err err:err, "problem deleting project" - callback(err) + logger.err err:err, "problem archived project" + return callback(err) + logger.log project_id:project_id, "succesfully archived project from user request" + callback() restoreProject: (project_id, callback = (error) ->) -> Project.update {_id:project_id}, { $unset: { archived: true }}, callback diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index a0f8cca509..3b3233157d 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -8,7 +8,7 @@ _ = require("underscore") module.exports = getDetails: (project_id, callback)-> - ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> + ProjectGetter.getProject project_id, {name:true, description:true, compiler:true, features:true, owner_ref:true}, (err, project)-> if err? logger.err err:err, project_id:project_id, "error getting project" return callback(err) @@ -37,7 +37,7 @@ module.exports = renameProject: (project_id, newName, callback = ->)-> logger.log project_id: project_id, newName:newName, "renaming project" - ProjectGetter.getProject project_id, {"name":1}, (err, project)-> + ProjectGetter.getProject project_id, {name:true}, (err, project)-> if err? or !project? logger.err err:err, project_id:project_id, "error getting project or could not find it todo project rename" return callback(err) diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index 69f9eb309a..bf775caeeb 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -4,61 +4,86 @@ projectLocator = require('./ProjectLocator') projectOptionsHandler = require('./ProjectOptionsHandler') DocumentUpdaterHandler = require("../DocumentUpdater/DocumentUpdaterHandler") DocstoreManager = require "../Docstore/DocstoreManager" -Project = require("../../models/Project").Project +ProjectGetter = require("./ProjectGetter") _ = require('underscore') async = require('async') +logger = require("logger-sharelatex") -module.exports = - duplicate: (owner, originalProjectId, newProjectName, callback)-> - DocumentUpdaterHandler.flushProjectToMongo originalProjectId, (err) -> - return callback(err) if err? - Project.findById originalProjectId, (err, originalProject) -> - return callback(err) if err? - projectCreationHandler.createBlankProject owner._id, newProjectName, (err, newProject)-> - return callback(err) if err? - projectLocator.findRootDoc {project:originalProject}, (err, originalRootDoc)-> - return callback(err) if err? - DocstoreManager.getAllDocs originalProjectId, (err, docContentsArray) -> - return callback(err) if err? - docContents = {} - for docContent in docContentsArray - docContents[docContent._id] = docContent +module.exports = ProjectDuplicator = - projectOptionsHandler.setCompiler newProject._id, originalProject.compiler + _copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)-> + setRootDoc = _.once (doc_id)-> + projectEntityHandler.setRootDoc newProject._id, doc_id - setRootDoc = _.once (doc_id)-> - projectEntityHandler.setRootDoc newProject, doc_id + jobs = originalFolder.docs.map (doc)-> + return (cb)-> + content = docContents[doc._id.toString()] + projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)-> + if err? + logger.err err:err, "error copying doc" + return callback(err) + if originalRootDoc? and newDoc.name == originalRootDoc.name + setRootDoc newDoc._id + cb() - copyDocs = (originalFolder, newParentFolder, callback)-> - jobs = originalFolder.docs.map (doc)-> - return (callback)-> - content = docContents[doc._id.toString()] - return callback(new Error("doc_id not found: #{doc._id}")) if !content? - projectEntityHandler.addDoc newProject, newParentFolder._id, doc.name, content.lines, (err, newDoc)-> - if originalRootDoc? and newDoc.name == originalRootDoc.name - setRootDoc newDoc._id - callback() - async.series jobs, callback + async.series jobs, callback - copyFiles = (originalFolder, newParentFolder, callback)-> - jobs = originalFolder.fileRefs.map (file)-> - return (callback)-> - projectEntityHandler.copyFileFromExistingProject newProject, newParentFolder._id, originalProject._id, file, callback - async.parallelLimit jobs, 5, callback + _copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)-> + jobs = originalFolder.fileRefs.map (file)-> + return (cb)-> + projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb + async.parallelLimit jobs, 5, callback - copyFolder = (folder, desFolder, callback)-> - jobs = folder.folders.map (childFolder)-> - return (callback)-> - projectEntityHandler.addFolder newProject, desFolder._id, childFolder.name, (err, newFolder)-> - copyFolder childFolder, newFolder, callback - jobs.push (cb)-> - copyDocs folder, desFolder, cb - jobs.push (cb)-> - copyFiles folder, desFolder, cb - async.series jobs, callback + _copyFolderRecursivly: (newProject_id, originalProject_id, originalRootDoc, originalFolder, desFolder, docContents, callback)-> + ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)-> + if err? + logger.err project_id:newProject_id, "could not get project" + return cb(err) - copyFolder originalProject.rootFolder[0], newProject.rootFolder[0], -> - callback(err, newProject) + jobs = originalFolder.folders.map (childFolder)-> + return (cb)-> + projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)-> + return cb(err) if err? + ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb + jobs.push (cb)-> + ProjectDuplicator._copyFiles newProject, originalProject_id, originalFolder, desFolder, cb + jobs.push (cb)-> + ProjectDuplicator._copyDocs newProject, originalRootDoc, originalFolder, desFolder, docContents, cb + + async.series jobs, callback + + duplicate: (owner, originalProject_id, newProjectName, callback)-> + + jobs = + flush: (cb)-> + DocumentUpdaterHandler.flushProjectToMongo originalProject_id, cb + originalProject: (cb)-> + ProjectGetter.getProject originalProject_id, {compiler:true, rootFolder:true, rootDoc_id:true}, cb + newProject: (cb)-> + projectCreationHandler.createBlankProject owner._id, newProjectName, cb + originalRootDoc: (cb)-> + projectLocator.findRootDoc {project_id:originalProject_id}, cb + docContentsArray: (cb)-> + DocstoreManager.getAllDocs originalProject_id, cb + + async.series jobs, (err, results)-> + if err? + logger.err err:err, originalProject_id:originalProject_id, "error duplicating project" + return callback(err) + {originalProject, newProject, originalRootDoc, docContentsArray} = results + + originalRootDoc = originalRootDoc[0] + + docContents = {} + for docContent in docContentsArray + docContents[docContent._id] = docContent + + projectOptionsHandler.setCompiler newProject._id, originalProject.compiler, -> + + ProjectDuplicator._copyFolderRecursivly newProject._id, originalProject_id, originalRootDoc, originalProject.rootFolder[0], newProject.rootFolder[0], docContents, -> + if err? + logger.err err:err, originalProject_id:originalProject_id, newProjectName:newProjectName, "error cloning project" + callback(err, newProject) diff --git a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee index 338c660c0c..f12a7d548e 100644 --- a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee @@ -26,6 +26,8 @@ module.exports = ProjectEditorHandler = dropbox:false compileTimeout: 60 compileGroup:"standard" + templates: false + references: false if project.owner_ref.features? if project.owner_ref.features.collaborators? @@ -37,7 +39,11 @@ module.exports = ProjectEditorHandler = if project.owner_ref.features.compileTimeout? result.features.compileTimeout = project.owner_ref.features.compileTimeout if project.owner_ref.features.compileGroup? - result.features.compileGroup = project.owner_ref.features.compileGroup + result.features.compileGroup = project.owner_ref.features.compileGroup + if project.owner_ref.features.templates? + result.features.templates = project.owner_ref.features.templates + if project.owner_ref.features.references? + result.features.references = project.owner_ref.features.references result.owner = @buildUserModelView project.owner_ref, "owner" diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index f78a638c14..9b771625b3 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -1,4 +1,5 @@ Project = require('../../models/Project').Project +settings = require "settings-sharelatex" Doc = require('../../models/Doc').Doc Folder = require('../../models/Folder').Folder File = require('../../models/File').File @@ -75,23 +76,21 @@ module.exports = ProjectEntityHandler = documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler') documentUpdaterHandler.flushProjectToMongo project_id, (error) -> return callback(error) if error? - Project.findById project_id, (error, project) -> + ProjectGetter.getProject project_id, {name:true}, (error, project) -> return callback(error) if error? requests = [] self.getAllDocs project_id, (error, docs) -> return callback(error) if error? for docPath, doc of docs do (docPath, doc) -> - requests.push (callback) -> - tpdsUpdateSender.addDoc {project_id:project_id, doc_id:doc._id, path:docPath, project_name:project.name, rev:doc.rev||0}, - callback + requests.push (cb) -> + tpdsUpdateSender.addDoc {project_id:project_id, doc_id:doc._id, path:docPath, project_name:project.name, rev:doc.rev||0}, cb self.getAllFiles project_id, (error, files) -> return callback(error) if error? for filePath, file of files do (filePath, file) -> - requests.push (callback) -> - tpdsUpdateSender.addFile {project_id:project_id, file_id:file._id, path:filePath, project_name:project.name, rev:file.rev}, - callback + requests.push (cb) -> + tpdsUpdateSender.addFile {project_id:project_id, file_id:file._id, path:filePath, project_name:project.name, rev:file.rev}, cb async.series requests, (err) -> logger.log project_id:project_id, "finished flushing project to tpds" callback(err) @@ -110,27 +109,36 @@ module.exports = ProjectEntityHandler = options = {} DocstoreManager.getDoc project_id, doc_id, options, callback - addDoc: (project_or_id, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=> - Project.getProject project_or_id, "", (err, project) -> - logger.log project: project._id, folder_id: folder_id, doc_name: docName, "adding doc" - return callback(err) if err? - confirmFolder project, folder_id, (folder_id)=> - 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, (err, modified, rev) -> - return callback(err) if err? - Project.putElement project._id, folder_id, doc, "doc", (err, result)=> - return callback(err) if err? - tpdsUpdateSender.addDoc { - project_id: project._id, - doc_id: doc._id - path: result.path.fileSystem, - project_name: project.name, - rev: 0 - }, (err) -> - return callback(err) if err? - callback(null, doc, folder_id) + + addDoc: (project_id, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=> + ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> + if err? + logger.err project_id:project_id, err:err, "error getting project for add doc" + return callback(err) + ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, callback + + addDocWithProject: (project, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=> + project_id = project._id + logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project" + confirmFolder project, folder_id, (folder_id)=> + 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, (err, modified, rev) -> + return callback(err) if err? + + ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=> + return callback(err) if err? + tpdsUpdateSender.addDoc { + project_id: project_id, + doc_id: doc?._id + path: result?.path?.fileSystem, + project_name: project.name, + rev: 0 + }, (err) -> + if err? + logger.err err:err, "error adding doc to tpdsworker, contining anyway" + callback(null, doc, folder_id) restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) -> # getDoc will return the deleted doc's lines, but we don't actually remove @@ -139,22 +147,34 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.addDoc project_id, null, name, lines, callback - addFile: (project_or_id, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)-> - ProjectGetter.getProjectWithOnlyFolders project_or_id, (err, project) -> - logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file" - return callback(err) if err? - confirmFolder project, folder_id, (folder_id)-> - fileRef = new File name : fileName - FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err)-> - if err? - logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3" - return callback(err) - Project.putElement project._id, folder_id, fileRef, "file", (err, result)=> - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result.path.fileSystem, project_name:project.name, rev:fileRef.rev}, -> - callback(err, fileRef, folder_id) + addFile: (project_id, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)-> + ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> + if err? + logger.err project_id:project_id, err:err, "error getting project for add file" + return callback(err) + ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, callback - replaceFile: (project_or_id, file_id, fsPath, callback)-> - Project.getProject project_or_id, "", (err, project) -> + addFileWithProject: (project, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file" + return callback(err) if err? + confirmFolder project, folder_id, (folder_id)-> + fileRef = new File name : fileName + FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err)-> + if err? + logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3" + return callback(err) + ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=> + if err? + logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project" + return callback(err) + tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err)-> + if err? + logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error sending file to tpdsworker" + callback(null, fileRef, folder_id) + + replaceFile: (project_id, file_id, fsPath, callback)-> + ProjectGetter.getProject project_id, {name:true}, (err, project) -> return callback(err) if err? findOpts = project_id:project._id @@ -182,21 +202,36 @@ module.exports = ProjectEntityHandler = Project.update conditons, update, {}, (err, second)-> callback() - copyFileFromExistingProject: (project_or_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> - Project.getProject project_or_id, "", (err, project) -> - logger.log project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3" - return callback(err) if err? - confirmFolder project, folder_id, (folder_id)=> - if !origonalFileRef? - logger.err project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "file trying to copy is null" - return callback() - fileRef = new File name : origonalFileRef.name - FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err)-> + copyFileFromExistingProject: (project_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> + logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3" + ProjectGetter.getProject project_id, {name:true}, (err, project) -> + if err? + logger.err project_id:project_id, err:err, "error getting project for copy file from existing project" + return callback(err) + ProjectEntityHandler.copyFileFromExistingProjectWithProject project, folder_id, originalProject_id, origonalFileRef, callback + + + copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3 with project" + return callback(err) if err? + confirmFolder project, folder_id, (folder_id)=> + if !origonalFileRef? + logger.err project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "file trying to copy is null" + return callback() + fileRef = new File name : origonalFileRef.name + FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err)-> + if err? + logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error coping file in s3" + return callback(err) + ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=> if err? - logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error coping file in s3" - Project.putElement project._id, folder_id, fileRef, "file", (err, result)=> - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result.path.fileSystem, rev:fileRef.rev, project_name:project.name}, (error) -> - callback(error, fileRef, folder_id) + logger.err err:err, project_id:project._id, folder_id:folder_id, "error putting element as part of copy" + return callback(err) + tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + if err? + logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error sending file to tpds worker" + callback(null, fileRef, folder_id) mkdirp: (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)-> self = @ @@ -204,7 +239,7 @@ module.exports = ProjectEntityHandler = folders = _.select folders, (folder)-> return folder.length != 0 - ProjectGetter.getProjectWithoutDocLines project_id, (err, project)=> + ProjectGetter.getProjectWithOnlyFolders project_id, (err, project)=> if path == '/' logger.log project_id: project._id, "mkdir is only trying to make path of / so sending back root folder" return callback(null, [], project.rootFolder[0]) @@ -217,7 +252,7 @@ module.exports = ProjectEntityHandler = if parentFolder? parentFolder_id = parentFolder._id builtUpPath = "#{builtUpPath}/#{folderName}" - projectLocator.findElementByPath project_id, builtUpPath, (err, foundFolder)=> + projectLocator.findElementByPath project, builtUpPath, (err, foundFolder)=> if !foundFolder? logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp" @addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)-> @@ -235,16 +270,23 @@ module.exports = ProjectEntityHandler = folders = _.select folders, (folder)-> !folder.filterOut callback(null, folders, lastFolder) - - addFolder: (project_or_id, parentFolder_id, folderName, callback) -> - folder = new Folder name: folderName - Project.getProject project_or_id, "", (err, project) -> - return callback(err) if err? - confirmFolder project, parentFolder_id, (parentFolder_id)=> - logger.log project: project_or_id, parentFolder_id:parentFolder_id, folderName:folderName, "new folder added" - Project.putElement project._id, parentFolder_id, folder, "folder", (err, result)=> - if callback? - callback(err, folder, parentFolder_id) + + addFolder: (project_id, parentFolder_id, folderName, callback) -> + ProjectGetter.getProjectWithOnlyFolders project_id, (err, project)=> + if err? + logger.err project_id:project_id, err:err, "error getting project for add folder" + return callback(err) + ProjectEntityHandler.addFolderWithProject project, parentFolder_id, folderName, callback + + addFolderWithProject: (project, parentFolder_id, folderName, callback = (err, folder, parentFolder_id)->) -> + confirmFolder project, parentFolder_id, (parentFolder_id)=> + folder = new Folder name: folderName + logger.log project: project._id, parentFolder_id:parentFolder_id, folderName:folderName, "adding new folder" + ProjectEntityHandler._putElement project, parentFolder_id, folder, "folder", (err, result)=> + if err? + logger.err err:err, project_id:project._id, "error adding folder to project" + return callback(err) + callback(err, folder, parentFolder_id) updateDocLines : (project_id, doc_id, lines, callback = (error) ->)-> ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> @@ -281,7 +323,7 @@ module.exports = ProjectEntityHandler = logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id return callback("No entityType set") entityType = entityType.toLowerCase() - Project.findById project_id, (err, project)=> + ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=> return callback(err) if err? projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path)-> return callback(err) if err? @@ -302,7 +344,7 @@ module.exports = ProjectEntityHandler = return callback(error) if error? self._removeElementFromMongoArray Project, project_id, path.mongo, (err)-> return callback(err) if err? - Project.putElement project_id, destinationFolder_id, entity, entityType, (err, result)-> + ProjectEntityHandler._putElement project, destinationFolder_id, entity, entityType, (err, result)-> return callback(err) if err? opts = project_id:project_id @@ -319,7 +361,7 @@ module.exports = ProjectEntityHandler = logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id return callback("No entityType set") entityType = entityType.toLowerCase() - Project.findById project_id, (err, project)=> + ProjectGetter.getProject project_id, {name:true, rootFolder:true}, (err, project)=> return callback(error) if error? projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=> return callback(error) if error? @@ -338,7 +380,7 @@ module.exports = ProjectEntityHandler = logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id return callback("No entityType set") entityType = entityType.toLowerCase() - Project.findById project_id, (err, project)=> + ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=> projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path, folder)=> if err? return callback err @@ -426,6 +468,72 @@ module.exports = ProjectEntityHandler = } }, {}, callback + + _countElements : (project, callback)-> + + countFolder = (folder, cb = (err, count)->)-> + + jobs = _.map folder?.folders, (folder)-> + (asyncCb)-> countFolder folder, asyncCb + + async.series jobs, (err, subfolderCounts)-> + total = 0 + + if subfolderCounts?.length > 0 + total = _.reduce subfolderCounts, (a, b)-> return a + b + if folder?.folders?.length? + total += folder?.folders?.length + if folder?.docs?.length? + total += folder?.docs?.length + if folder?.fileRefs?.length? + total += folder?.fileRefs?.length + cb(null, total) + + countFolder project.rootFolder[0], callback + + _putElement: (project, folder_id, element, type, callback = (err, path)->)-> + + sanitizeTypeOfElement = (elementType)-> + lastChar = elementType.slice -1 + if lastChar != "s" + elementType +="s" + if elementType == "files" + elementType = "fileRefs" + return elementType + + if !element? + e = new Error("no element passed to be inserted") + logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as it was null" + return callback(e) + type = sanitizeTypeOfElement type + + if !folder_id? + folder_id = project.rootFolder[0]._id + ProjectEntityHandler._countElements project, (err, count)-> + if count > settings.maxEntitiesPerProject + logger.warn project_id:project._id, "project too big, stopping insertions" + return callback("project_has_to_many_files") + projectLocator.findElement {project:project, element_id:folder_id, type:"folders"}, (err, folder, path)=> + if err? + logger.err err:err, project_id:project._id, folder_id:folder_id, type:type, element:element, "error finding folder for _putElement" + return callback(err) + newPath = + fileSystem: "#{path.fileSystem}/#{element.name}" + mongo: path.mongo + logger.log project_id: project._id, element_id: element._id, fileType: type, folder_id: folder_id, "adding element to project" + id = element._id+'' + element._id = require('mongoose').Types.ObjectId(id) + conditions = _id:project._id + mongopath = "#{path.mongo}.#{type}" + update = "$push":{} + update["$push"][mongopath] = element + Project.update conditions, update, {}, (err)-> + if err? + logger.err err: err, project_id: project._id, 'error saving in putElement project' + return callback(err) + callback(err, {path:newPath}) + + confirmFolder = (project, folder_id, callback)-> logger.log folder_id:folder_id, project_id:project._id, "confirming folder in project" if folder_id+'' == 'undefined' diff --git a/services/web/app/coffee/Features/Project/ProjectGetter.coffee b/services/web/app/coffee/Features/Project/ProjectGetter.coffee index 129ff831dc..d8bb1e457a 100644 --- a/services/web/app/coffee/Features/Project/ProjectGetter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectGetter.coffee @@ -2,31 +2,54 @@ mongojs = require("../../infrastructure/mongojs") db = mongojs.db ObjectId = mongojs.ObjectId async = require "async" +Errors = require("../../errors") +logger = require("logger-sharelatex") + + module.exports = ProjectGetter = EXCLUDE_DEPTH: 8 + getProjectWithoutDocLines: (project_id, callback=(error, project) ->) -> excludes = {} - for i in [1..@EXCLUDE_DEPTH] + for i in [1..ProjectGetter.EXCLUDE_DEPTH] excludes["rootFolder#{Array(i).join(".folder")}.docs.lines"] = 0 db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) -> callback error, projects[0] getProjectWithOnlyFolders: (project_id, callback=(error, project) ->) -> excludes = {} - for i in [1..@EXCLUDE_DEPTH] + for i in [1..ProjectGetter.EXCLUDE_DEPTH] excludes["rootFolder#{Array(i).join(".folder")}.docs"] = 0 excludes["rootFolder#{Array(i).join(".folder")}.fileRefs"] = 0 db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) -> callback error, projects[0] + getProject: (query, projection, callback = (error, project) ->) -> + if !query? + return callback("no query provided") + + if typeof(projection) == "function" + callback = projection + if typeof query == "string" query = _id: ObjectId(query) else if query instanceof ObjectId query = _id: query - db.projects.findOne query, projection, callback + else if query?.toString().length == 24 # sometimes mongoose ids are hard to identify, this will catch them + query = _id: ObjectId(query.toString()) + else + err = new Error("malformed get request") + logger.log query:query, err:err, type:typeof(query), "malformed get request" + return callback(err) + + db.projects.find query, projection, (err, project)-> + if err? + logger.err err:err, query:query, projection:projection, "error getting project" + return callback(err) + callback(null, project?[0]) populateProjectWithUsers: (project, callback=(error, project) ->) -> # eventually this should be in a UserGetter.getUser module diff --git a/services/web/app/coffee/Features/Project/ProjectLocator.coffee b/services/web/app/coffee/Features/Project/ProjectLocator.coffee index 998e2fc60b..7d1eb8f749 100644 --- a/services/web/app/coffee/Features/Project/ProjectLocator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectLocator.coffee @@ -1,11 +1,16 @@ Project = require('../../models/Project').Project +ProjectGetter = require("./ProjectGetter") Errors = require "../../errors" _ = require('underscore') logger = require('logger-sharelatex') async = require('async') -module.exports = - findElement: (options, callback = (err, element, path, parentFolder)->)-> +module.exports = ProjectLocator = + findElement: (options, _callback = (err, element, path, parentFolder)->)-> + callback = (args...) -> + _callback(args...) + _callback = () -> + {project, project_id, element_id, type} = options elementType = sanitizeTypeOfElement type @@ -46,7 +51,7 @@ module.exports = if project? startSearch(project) else - Project.findById project_id, (err, project)-> + ProjectGetter.getProject project_id, {rootFolder:true, rootDoc_id:true}, (err, project)-> return callback(err) if err? if !project? return callback(new Errors.NotFoundError("project not found")) @@ -62,8 +67,12 @@ module.exports = if project? getRootDoc project else - Project.findById project_id, (err, project)-> - getRootDoc project + ProjectGetter.getProject project_id, {rootFolder:true, rootDoc_id:true}, (err, project)-> + if err? + logger.err err:err, "error getting project" + return callback(err) + else + getRootDoc project findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)-> @@ -122,11 +131,11 @@ module.exports = async.waterfall jobs, callback findUsersProjectByName: (user_id, projectName, callback)-> - Project.findAllUsersProjects user_id, 'name', (err, projects, collabertions=[])-> + Project.findAllUsersProjects user_id, 'name archived', (err, projects, collabertions=[])-> projects = projects.concat(collabertions) projectName = projectName.toLowerCase() - project = _.find projects, (project)-> - project.name.toLowerCase() == projectName + project = _.find projects, (project)-> + project.name.toLowerCase() == projectName and project.archived != true logger.log user_id:user_id, projectName:projectName, totalProjects:projects.length, project:project, "looking for project by name" callback(null, project) diff --git a/services/web/app/coffee/Features/References/ReferencesController.coffee b/services/web/app/coffee/Features/References/ReferencesController.coffee new file mode 100644 index 0000000000..0536e3b680 --- /dev/null +++ b/services/web/app/coffee/Features/References/ReferencesController.coffee @@ -0,0 +1,37 @@ +logger = require('logger-sharelatex') +ReferencesHandler = require('./ReferencesHandler') +settings = require('settings-sharelatex') +EditorRealTimeController = require("../Editor/EditorRealTimeController") + +module.exports = ReferencesController = + + + index: (req, res) -> + projectId = req.params.Project_id + shouldBroadcast = req.body.shouldBroadcast + docIds = req.body.docIds + if (!docIds or !(docIds instanceof Array)) + logger.err {projectId, docIds}, "docIds is not valid, should be either Array or String 'ALL'" + return res.sendStatus 400 + logger.log {projectId, docIds: docIds}, "index references for project" + ReferencesHandler.index projectId, docIds, (err, data) -> + if err + logger.err {err, projectId}, "error indexing all references" + return res.sendStatus 500 + ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data) + + indexAll: (req, res) -> + projectId = req.params.Project_id + shouldBroadcast = req.body.shouldBroadcast + logger.log {projectId}, "index all references for project" + ReferencesHandler.indexAll projectId, (err, data) -> + if err + logger.err {err, projectId}, "error indexing all references" + return res.sendStatus 500 + ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data) + + _handleIndexResponse: (req, res, projectId, shouldBroadcast, data) -> + if shouldBroadcast + logger.log {projectId}, "emitting new references keys to connected clients" + EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys + return res.json data diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee new file mode 100644 index 0000000000..0b2ddb1e26 --- /dev/null +++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee @@ -0,0 +1,85 @@ +logger = require("logger-sharelatex") +request = require("request") +settings = require("settings-sharelatex") +Project = require("../../models/Project").Project +DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') +U = require('underscore') +Async = require('async') + +oneMinInMs = 60 * 1000 +fiveMinsInMs = oneMinInMs * 5 + + +module.exports = ReferencesHandler = + + _buildDocUrl: (projectId, docId) -> + "#{settings.apis.docstore.url}/project/#{projectId}/doc/#{docId}/raw" + + _findBibDocIds: (project) -> + ids = [] + + _process = (folder) -> + folder.docs.forEach (doc) -> + if doc?.name?.match(/^.*\.bib$/) + ids.push(doc._id) + folder.folders.forEach (folder) -> + _process(folder) + + project.rootFolder.forEach (rootFolder) -> + _process(rootFolder) + + return ids + + _isFullIndex: (project, callback = (err, result) ->) -> + owner = project.owner_ref + callback(null, owner.features.references == true) + + indexAll: (projectId, callback=(err, data)->) -> + Project.findPopulatedById projectId, (err, project) -> + if err + logger.err {err, projectId}, "error finding project" + return callback(err) + logger.log {projectId}, "indexing all bib files in project" + docIds = ReferencesHandler._findBibDocIds(project) + ReferencesHandler._doIndexOperation(projectId, project, docIds, callback) + + index: (projectId, docIds, callback=(err, data)->) -> + Project.findPopulatedById projectId, (err, project) -> + if err + logger.err {err, projectId}, "error finding project" + return callback(err) + ReferencesHandler._doIndexOperation(projectId, project, docIds, callback) + + _doIndexOperation: (projectId, project, docIds, callback) -> + ReferencesHandler._isFullIndex project, (err, isFullIndex) -> + if err + logger.err {err, projectId}, "error checking whether to do full index" + return callback(err) + logger.log {projectId, docIds}, 'flushing docs to mongo before calling references service' + Async.series( + docIds.map((docId) -> (cb) -> DocumentUpdaterHandler.flushDocToMongo(projectId, docId, cb)), + (err) -> + # continue + if err + logger.err {err, projectId, docIds}, "error flushing docs to mongo" + return callback(err) + bibDocUrls = docIds.map (docId) -> + ReferencesHandler._buildDocUrl projectId, docId + logger.log {projectId, isFullIndex, docIds, bibDocUrls}, "sending request to references service" + request.post { + url: "#{settings.apis.references.url}/project/#{projectId}/index" + json: + docUrls: bibDocUrls + fullIndex: isFullIndex + }, (err, res, data) -> + if err + logger.err {err, projectId}, "error communicating with references api" + return callback(err) + if 200 <= res.statusCode < 300 + logger.log {projectId}, "got keys from references api" + return callback(null, data) + else + err = new Error("references api responded with non-success code: #{res.statusCode}") + logger.log {err, projectId}, "error updating references" + return callback(err) + ) diff --git a/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee b/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee index 19b8c15648..dc71da09fc 100644 --- a/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee +++ b/services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee @@ -15,7 +15,7 @@ module.exports = RateLimiterMiddlewear = ### rateLimit: (opts) -> return (req, res, next) -> - if req.session.user? + if req.session?.user? user_id = req.session.user._id else user_id = req.ip diff --git a/services/web/app/coffee/Features/Spelling/SpellingController.coffee b/services/web/app/coffee/Features/Spelling/SpellingController.coffee index 81a697525f..86683e2341 100644 --- a/services/web/app/coffee/Features/Spelling/SpellingController.coffee +++ b/services/web/app/coffee/Features/Spelling/SpellingController.coffee @@ -12,5 +12,4 @@ module.exports = SpellingController = request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS) .on "error", (error) -> logger.error err: error, "Spelling API error" - res.sendStatus 500 .pipe(res) diff --git a/services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee b/services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee index f1079cc5b7..f1f55814c7 100644 --- a/services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee +++ b/services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee @@ -13,6 +13,7 @@ module.exports = webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy") webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance") webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide") + webRouter.get '/jobs', HomeController.externalPage("jobs", "Jobs") webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX") diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee index f0513fb590..d323c19a9d 100644 --- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee +++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee @@ -52,11 +52,17 @@ module.exports = return callback(err) if err? callback err, subscriptions.length > 0, subscriptions - hasGroupMembersLimitReached: (user_id, callback)-> + hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)-> SubscriptionLocator.getUsersSubscription user_id, (err, subscription)-> + if err? + logger.err err:err, user_id:user_id, "error getting users subscription" + return callback(err) + if !subscription? + logger.err user_id:user_id, "no subscription found for user" + return callback("no subscription found") limitReached = subscription.member_ids.length >= subscription.membersLimit 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) + callback(err, limitReached, subscription) getOwnerOfProject = (project_id, callback)-> Project.findById project_id, 'owner_ref', (error, project) -> diff --git a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee index ed7813d5c7..680905ca6c 100644 --- a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee +++ b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee @@ -247,6 +247,18 @@ module.exports = RecurlyWrapper = callback(error) ) + extendTrial: (subscriptionId, daysUntilExpire = 7, callback)-> + next_renewal_date = new Date() + next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire) + logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user" + @apiRequest({ + url : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false" + method : "put" + }, (error, response, responseBody) => + if error? + logger.err err:error, subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "error exending trial" + callback(error) + ) _parseSubscriptionXml: (xml, callback) -> @_parseXml xml, (error, data) -> diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index 4fbd84a6ac..e2e021bd97 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -1,7 +1,6 @@ SecurityManager = require '../../managers/SecurityManager' SubscriptionHandler = require './SubscriptionHandler' PlansLocator = require("./PlansLocator") -SubscriptionFormatters = require("./SubscriptionFormatters") SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') LimitationsManager = require("./LimitationsManager") RecurlyWrapper = require './RecurlyWrapper' @@ -9,7 +8,6 @@ Settings = require 'settings-sharelatex' logger = require('logger-sharelatex') GeoIpLookup = require("../../infrastructure/GeoIpLookup") SubscriptionDomainHandler = require("./SubscriptionDomainHandler") -require("../../infrastructure/Sixpack") module.exports = SubscriptionController = @@ -98,14 +96,14 @@ module.exports = SubscriptionController = else SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) -> return next(error) if error? - logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, "showing subscription dashboard" + logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groups:groups, "showing subscription dashboard" plans = SubscriptionViewModelBuilder.buildViewModel() res.render "subscriptions/dashboard", title: "your_subscription" recomendedCurrency: subscription?.currency taxRate:subscription?.taxRate plans: plans - subscription: subscription + subscription: subscription || {} groups: groups subscriptionTabActive: true @@ -226,6 +224,14 @@ module.exports = SubscriptionController = else res.sendStatus 200 + extendTrial: (req, res)-> + SecurityManager.getCurrentUser req, (error, user) -> + LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)-> + SubscriptionHandler.extendTrial subscription, 14, (err)-> + if err? + res.send 500 + else + res.send 200 recurlyNotificationParser: (req, res, next) -> xml = "" diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee index b57c3492f6..a71d4496e7 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee @@ -1,30 +1,17 @@ async = require("async") _ = require("underscore") settings = require("settings-sharelatex") -SubscriptionGroupHandler = require("./SubscriptionGroupHandler") -_s = require("underscore.string") module.exports = SubscriptionDomainHandler = - getLicenceUserCanJoin: (user, callback)-> + getLicenceUserCanJoin: (user)-> licence = SubscriptionDomainHandler._findDomainLicence(user.email) - if licence? - callback null, licence - else - callback() - - attemptToJoinGroup: (user, callback)-> - licence = SubscriptionDomainHandler._findDomainLicence(user.email) - if licence? and user.emailVerified - SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback - else - callback "user not verified" + return licence rejectInvitationToGroup: (user, subscription, callback)-> removeUserFromGroup(subscription.admin_id, user._id, callback) - getDomainLicencePage: (user)-> licence = SubscriptionDomainHandler._findDomainLicence(user.email) if licence?.verifyEmail @@ -32,20 +19,11 @@ module.exports = SubscriptionDomainHandler = else return undefined - - autoAllocate: (user, callback = ->)-> - licence = SubscriptionDomainHandler._findDomainLicence(user.email) - # - if licence? - SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback - else - callback() - - _findDomainLicence: (email)-> licence = _.find settings.domainLicences, (licence)-> _.find licence.domains, (domain)-> - _s.endsWith email, domain + regex = "[@\.]#{domain}" + return email.match(regex) return licence @@ -53,4 +31,3 @@ module.exports = SubscriptionDomainHandler = licence = _.find settings.domainLicences, (licence)-> licence?.subscription_id == subscription_id return licence - diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee index 3bf8d4a631..af8b2414f3 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee @@ -1,10 +1,7 @@ SubscriptionGroupHandler = require("./SubscriptionGroupHandler") logger = require("logger-sharelatex") SubscriptionLocator = require("./SubscriptionLocator") - ErrorsController = require("../Errors/ErrorController") -settings = require("settings-sharelatex") - SubscriptionDomainHandler = require("./SubscriptionDomainHandler") _ = require("underscore") @@ -12,9 +9,12 @@ module.exports = addUserToGroup: (req, res)-> adminUserId = req.session.user._id - newEmail = req.body.email + newEmail = req.body?.email?.toLowerCase()?.trim() logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription" SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)-> + if err? + logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group" + return res.sendStatus 500 result = user:user if err and err.limitReached @@ -25,7 +25,20 @@ module.exports = adminUserId = req.session.user._id userToRemove_id = req.params.user_id logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription" - SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, -> + SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)-> + if err? + logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group" + return res.sendStatus 500 + res.send() + + removeSelfFromGroup: (req, res)-> + adminUserId = req.query.admin_user_id + userToRemove_id = req.session.user._id + logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request" + SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)-> + if err? + logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group" + return res.sendStatus 500 res.send() renderSubscriptionGroupAdminPage: (req, res)-> @@ -70,13 +83,16 @@ module.exports = subscription_id = req.params.subscription_id if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)? return ErrorsController.notFound(req, res) - SubscriptionGroupHandler.processGroupVerification req.session.user.email, subscription_id, req.query.token, (err)-> + email = req?.session?.user?.email + logger.log subscription_id:subscription_id, user_id:req?.session?.user?._id, email:email, "starting the completion of joining group" + SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)-> if err? and err == "token_not_found" - res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true" + return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true" else if err? - res.sendStatus 500 + return res.sendStatus 500 else - res.redirect "/user/subscription/#{subscription_id}/group/successful-join" + logger.log subscription_id:subscription_id, email:email, "user successful completed join of group subscription" + return res.redirect "/user/subscription/#{subscription_id}/group/successful-join" renderSuccessfulJoinPage: (req, res)-> subscription_id = req.params.subscription_id diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index ffd436943a..1f078f6674 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -9,15 +9,32 @@ logger = require("logger-sharelatex") OneTimeTokenHandler = require("../Security/OneTimeTokenHandler") EmailHandler = require("../Email/EmailHandler") settings = require("settings-sharelatex") +NotificationsBuilder = require("../Notifications/NotificationsBuilder") module.exports = SubscriptionGroupHandler = - addUserToGroup: (adminUser_id, newEmail, callback)-> + addUserToGroup: (adminUserId, newEmail, callback)-> + logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group" UserCreator.getUserOrCreateHoldingAccount newEmail, (err, user)-> - LimitationsManager.hasGroupMembersLimitReached adminUser_id, (err, limitReached)-> + if err? + logger.err err:err, adminUserId:adminUserId, newEmail:newEmail, "error creating user for holding account" + return callback(err) + if !user? + msg = "no user returned whenc reating holidng account or getting user" + logger.err adminUserId:adminUserId, newEmail:newEmail, msg + return callback(msg) + LimitationsManager.hasGroupMembersLimitReached adminUserId, (err, limitReached, subscription)-> + if err? + logger.err err:err, adminUserId:adminUserId, newEmail:newEmail, "error checking if limit reached for group plan" + return callback(err) if limitReached + logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) - SubscriptionUpdater.addUserToGroup adminUser_id, user._id, (err)-> + SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> + if err? + logger.err err:err, "error adding user to group" + return callback(err) + NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read() userViewModel = buildUserViewModel(user) callback(err, userViewModel) @@ -60,13 +77,20 @@ module.exports = SubscriptionGroupHandler = EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback processGroupVerification: (userEmail, subscription_id, token, callback)-> + logger.log userEmail:userEmail, subscription_id:subscription_id, "processing group verification for user" OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)-> - if err? or subscription_id != token_subscription_id logger.err userEmail:userEmail, token:token, "token value not found for processing group verification" return callback("token_not_found") SubscriptionLocator.getSubscription subscription_id, (err, subscription)-> - SubscriptionGroupHandler.addUserToGroup subscription.admin_id, userEmail, callback + if err? + logger.err err:err, subscription:subscription, userEmail:userEmail, subscription_id:subscription_id, "error getting subscription" + return callback(err) + if !subscription? + logger.warn subscription_id:subscription_id, userEmail:userEmail, "no subscription found" + return callback() + SubscriptionGroupHandler.addUserToGroup subscription?.admin_id, userEmail, callback + buildUserViewModel = (user)-> diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee index 7fa43f4270..2396dca07b 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee @@ -74,5 +74,5 @@ module.exports = return callback("no user found") SubscriptionUpdater.syncSubscription recurlySubscription, user?._id, callback - - + extendTrial: (subscription, daysToExend, callback)-> + RecurlyWrapper.extendTrial subscription.recurlySubscription_id, daysToExend, callback diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee index 08f5507b7f..9285f8e575 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee @@ -22,4 +22,4 @@ module.exports = Subscription.findOne _id:subscription_id, callback getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)-> - Subscription.findOne member_ids: user_id, _id:subscription_id, callback + Subscription.findOne member_ids: user_id, _id:subscription_id, {_id:1}, callback diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee index 76dded3f4d..f2d66c30c5 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee @@ -24,6 +24,8 @@ module.exports = webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup + webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup + webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup @@ -39,6 +41,7 @@ module.exports = webRouter.post '/user/subscription/cancel', AuthenticationController.requireLogin(), SubscriptionController.cancelSubscription webRouter.post '/user/subscription/reactivate', AuthenticationController.requireLogin(), SubscriptionController.reactivateSubscription + webRouter.put '/user/subscription/extend', AuthenticationController.requireLogin(), SubscriptionController.extendTrial webRouter.get "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage webRouter.post "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index 7440657c7c..18f9058b67 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -32,6 +32,9 @@ module.exports = insertOperation = "$addToSet": {member_ids:user_id} Subscription.findAndModify searchOps, insertOperation, (err, subscription)-> + if err? + logger.err err:err, searchOps:searchOps, insertOperation:insertOperation, "error findy and modify add user to group" + return callback(err) UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback removeUserFromGroup: (adminUser_id, user_id, callback)-> @@ -39,10 +42,12 @@ module.exports = admin_id: adminUser_id removeOperation = "$pull": {member_ids:user_id} - Subscription.update searchOps, removeOperation, -> + Subscription.update searchOps, removeOperation, (err)-> + if err? + logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group" + return callback(err) UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, callback - _createNewSubscription: (adminUser_id, callback)-> logger.log adminUser_id:adminUser_id, "creating new subscription" subscription = new Subscription(admin_id:adminUser_id) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee index 714396b1f5..bdb9c41fde 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee @@ -27,6 +27,7 @@ module.exports = currency:recurlySubscription?.currency taxRate:parseFloat(recurlySubscription?.tax_rate?._) groupPlan: subscription.groupPlan + trial_ends_at:recurlySubscription?.trial_ends_at }, memberSubscriptions else callback null, null, memberSubscriptions diff --git a/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee index 2d84d69b10..c0b691e677 100644 --- a/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee @@ -1,4 +1,3 @@ -Settings = require "settings-sharelatex" logger = require("logger-sharelatex") User = require('../../models/User').User PlansLocator = require("./PlansLocator") @@ -9,7 +8,7 @@ module.exports = conditions = _id:user_id update = {} plan = PlansLocator.findLocalPlanInSettings(plan_code) - logger.log user_id:user_id, plan:plan, plan_code:plan_code, "updating users features" + logger.log user_id:user_id, features:plan.features, plan_code:plan_code, "updating users features" update["features.#{key}"] = value for key, value of plan.features User.update conditions, update, (err)-> callback err, plan.features diff --git a/services/web/app/coffee/Features/Tags/TagsController.coffee b/services/web/app/coffee/Features/Tags/TagsController.coffee index aeef36ed47..2e67be2fd4 100644 --- a/services/web/app/coffee/Features/Tags/TagsController.coffee +++ b/services/web/app/coffee/Features/Tags/TagsController.coffee @@ -2,20 +2,53 @@ TagsHandler = require("./TagsHandler") logger = require("logger-sharelatex") module.exports = - - processTagsUpdate: (req, res)-> + getAllTags: (req, res, next)-> user_id = req.session.user._id - project_id = req.params.project_id - if req.body.deletedTag? - tag = req.body.deletedTag - TagsHandler.deleteTag user_id, project_id, tag, -> - res.send() + logger.log {user_id}, "getting tags" + TagsHandler.getAllTags user_id, (error, allTags)-> + return next(error) if error? + res.json(allTags) + + createTag: (req, res, next) -> + user_id = req.session.user._id + name = req.body.name + logger.log {user_id, name}, "creating tag" + TagsHandler.createTag user_id, name, (error, tag) -> + return next(error) if error? + res.json(tag) + + addProjectToTag: (req, res, next) -> + user_id = req.session.user._id + {tag_id, project_id} = req.params + logger.log {user_id, tag_id, project_id}, "adding tag to project" + TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) -> + return next(error) if error? + res.status(204).end() + + removeProjectFromTag: (req, res, next) -> + user_id = req.session.user._id + {tag_id, project_id} = req.params + logger.log {user_id, tag_id, project_id}, "removing tag from project" + TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) -> + return next(error) if error? + res.status(204).end() + + deleteTag: (req, res, next) -> + user_id = req.session.user._id + tag_id = req.params.tag_id + logger.log {user_id, tag_id}, "deleting tag" + TagsHandler.deleteTag user_id, tag_id, (error) -> + return next(error) if error? + res.status(204).end() + + renameTag: (req, res, next) -> + user_id = req.session.user._id + tag_id = req.params.tag_id + name = req.body?.name + if !name? + return res.status(400).end() else - tag = req.body.tag - TagsHandler.addTag user_id, project_id, tag, -> - res.send() - logger.log user_id:user_id, project_id:project_id, body:req.body, "processing tag update" - - getAllTags: (req, res)-> - TagsHandler.getAllTags req.session.user._id, (err, allTags)-> - res.send(allTags) + logger.log {user_id, tag_id, name}, "renaming tag" + TagsHandler.renameTag user_id, tag_id, name, (error) -> + return next(error) if error? + res.status(204).end() diff --git a/services/web/app/coffee/Features/Tags/TagsHandler.coffee b/services/web/app/coffee/Features/Tags/TagsHandler.coffee index 18b9608152..2dc57608bd 100644 --- a/services/web/app/coffee/Features/Tags/TagsHandler.coffee +++ b/services/web/app/coffee/Features/Tags/TagsHandler.coffee @@ -3,61 +3,84 @@ settings = require("settings-sharelatex") request = require("request") logger = require("logger-sharelatex") -oneSecond = 1000 -module.exports = - - - deleteTag: (user_id, project_id, tag, callback)-> - uri = buildUri(user_id, project_id) - opts = - uri:uri - json: - name:tag - timeout:oneSecond - logger.log user_id:user_id, project_id:project_id, tag:tag, "send delete tag to tags api" - request.del opts, callback - - addTag: (user_id, project_id, tag, callback)-> - uri = buildUri(user_id, project_id) - opts = - uri:uri - json: - name:tag - timeout:oneSecond - logger.log user_id:user_id, project_id:project_id, tag:tag, "send add tag to tags api" - request.post opts, callback - - requestTags: (user_id, callback)-> - opts = - uri: "#{settings.apis.tags.url}/user/#{user_id}/tag" - json: true - timeout: 2000 - request.get opts, (err, res, body)-> - statusCode = if res? then res.statusCode else 500 - if err? or statusCode != 200 - e = new Error("something went wrong getting tags, #{err}, #{statusCode}") - logger.err err:err - callback(e, []) - else - callback(null, body) - +TIMEOUT = 1000 +module.exports = TagsHandler = getAllTags: (user_id, callback)-> - @requestTags user_id, (err, allTags)=> + @_requestTags user_id, (err, allTags)=> if !allTags? allTags = [] - @groupTagsByProject allTags, (err, groupedByProject)-> - logger.log allTags:allTags, user_id:user_id, groupedByProject:groupedByProject, "getting all tags from tags api" + @_groupTagsByProject allTags, (err, groupedByProject)-> + logger.log allTags:allTags, user_id:user_id, groupedByProject:groupedByProject, "got all tags from tags api" callback err, allTags, groupedByProject + + createTag: (user_id, name, callback = (error, tag) ->) -> + opts = + url: "#{settings.apis.tags.url}/user/#{user_id}/tag" + json: + name: name + timeout: TIMEOUT + request.post opts, (err, res, body)-> + TagsHandler._handleResponse err, res, {user_id}, (error) -> + return callback(error) if error? + callback(null, body or {}) + + renameTag: (user_id, tag_id, name, callback = (error) ->) -> + url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/rename" + request.post { + url: url + json: + name: name + timeout: TIMEOUT + }, (err, res, body) -> + TagsHandler._handleResponse err, res, {url, user_id, tag_id, name}, callback + + deleteTag: (user_id, tag_id, callback = (error) ->) -> + url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}" + request.del {url, timeout: TIMEOUT}, (err, res, body) -> + TagsHandler._handleResponse err, res, {url, user_id, tag_id}, callback + + removeProjectFromTag: (user_id, tag_id, project_id, callback)-> + url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/project/#{project_id}" + request.del {url, timeout: TIMEOUT}, (err, res, body) -> + TagsHandler._handleResponse err, res, {url, user_id, tag_id, project_id}, callback + + addProjectToTag: (user_id, tag_id, project_id, callback)-> + url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/project/#{project_id}" + request.post {url, timeout: TIMEOUT}, (err, res, body) -> + TagsHandler._handleResponse err, res, {url, user_id, tag_id, project_id}, callback removeProjectFromAllTags: (user_id, project_id, callback)-> - uri = buildUri(user_id, project_id) + url = "#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}" opts = - uri:"#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}" - timeout:oneSecond - logger.log user_id:user_id, project_id:project_id, "removing project_id from tags" - request.del opts, callback + url: url + timeout:TIMEOUT + request.del opts, (err, res, body) -> + TagsHandler._handleResponse err, res, {url, user_id, project_id}, callback - groupTagsByProject: (tags, callback)-> + _handleResponse: (err, res, params, callback) -> + if err? + params.err = err + logger.err params, "error in tag api" + return callback(err) + else if res? and res.statusCode >= 200 and res.statusCode < 300 + return callback(null) + else + err = new Error("tags api returned a failure status code: #{res?.statusCode}") + params.err = err + logger.err params, "tags api returned failure status code: #{res?.statusCode}" + return callback(err) + + _requestTags: (user_id, callback)-> + opts = + url: "#{settings.apis.tags.url}/user/#{user_id}/tag" + json: true + timeout: TIMEOUT + request.get opts, (err, res, body)-> + TagsHandler._handleResponse err, res, {user_id}, (error) -> + return callback(error, []) if error? + callback(null, body or []) + + _groupTagsByProject: (tags, callback)-> result = {} _.each tags, (tag)-> _.each tag.project_ids, (project_id)-> @@ -69,7 +92,3 @@ module.exports = delete clonedTag.project_ids result[project_id].push(clonedTag) callback null, result - - -buildUri = (user_id, project_id)-> - uri = "#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}/tag" diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee index f256760808..6801c6b0b3 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee @@ -45,7 +45,7 @@ module.exports = path = "/" + req.params[0] # UpdateMerger expects leading slash source = req.headers["x-sl-update-source"] or "unknown" logger.log project_id: project_id, path: path, source: source, "received project contents update" - UpdateMerger.mergeUpdate project_id, path, req, source, (error) -> + UpdateMerger.mergeUpdate null, project_id, path, req, source, (error) -> return next(error) if error? res.sendStatus(200) diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee index 7cd0e53284..7605f6911b 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee @@ -27,7 +27,7 @@ module.exports = FileTypeManager.shouldIgnore path, (err, shouldIgnore)-> if shouldIgnore return callback() - updateMerger.mergeUpdate project._id, path, updateRequest, source, callback + updateMerger.mergeUpdate user_id, project._id, path, updateRequest, source, callback deleteUpdate: (user_id, projectName, path, source, callback)-> diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee index f2d5bb94d1..521aabeb0c 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee @@ -42,6 +42,9 @@ module.exports = TpdsUpdateSender = _addEntity: (options, callback = (err)->)-> getProjectsUsersIds options.project_id, (err, user_id, allUserIds)-> + if err? + logger.err err:err, options:options, "error getting projects user ids" + return callback(err) logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store" postOptions = method : "post" @@ -53,6 +56,9 @@ module.exports = TpdsUpdateSender = title: "addFile" streamOrigin : options.streamOrigin TpdsUpdateSender._enqueue options.project_id, "pipeStreamFrom", postOptions, (err)-> + if err? + logger.err err:err, project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "error sending file to third party data store queued up for processing" + return callback(err) logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store queued up for processing" callback(err) diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index d18a8713ab..212c251029 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -8,7 +8,7 @@ uuid = require('node-uuid') fs = require('fs') module.exports = - mergeUpdate: (project_id, path, updateRequest, source, callback = (error) ->)-> + mergeUpdate: (user_id, project_id, path, updateRequest, source, callback = (error) ->)-> self = @ logger.log project_id:project_id, path:path, "merging update from tpds" projectLocator.findElementByPath project_id, path, (err, element)=> @@ -30,7 +30,7 @@ module.exports = if isFile self.p.processFile project_id, elementId, fsPath, path, source, callback else - self.p.processDoc project_id, elementId, fsPath, path, source, callback + self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback deleteUpdate: (project_id, path, source, callback)-> projectLocator.findElementByPath project_id, path, (err, element)-> @@ -49,14 +49,14 @@ module.exports = p: - processDoc: (project_id, doc_id, fsPath, path, source, callback)-> + processDoc: (project_id, doc_id, user_id, fsPath, path, source, callback)-> readFileIntoTextArray fsPath, (err, docLines)-> if err? logger.err project_id:project_id, doc_id:doc_id, fsPath:fsPath, "error reading file into text array for process doc update" return callback(err) logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds" if doc_id? - editorController.setDoc project_id, doc_id, docLines, source, (err)-> + editorController.setDoc project_id, doc_id, user_id, docLines, source, (err)-> callback() else setupNewEntity project_id, path, (err, folder, fileName)-> diff --git a/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee b/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee index e666b83f24..a615810fe6 100644 --- a/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee +++ b/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee @@ -1,21 +1,23 @@ child = require "child_process" logger = require "logger-sharelatex" metrics = require "../../infrastructure/Metrics" +fs = require "fs" +Path = require "path" +_ = require("underscore") + +ONE_MEG = 1024 * 1024 module.exports = ArchiveManager = - extractZipArchive: (source, destination, _callback = (err) ->) -> - callback = (args...) -> - _callback(args...) - _callback = () -> - timer = new metrics.Timer("unzipDirectory") - logger.log source: source, destination: destination, "unzipping file" - unzip = child.spawn("unzip", [source, "-d", destination]) + _isZipTooLarge: (source, callback = (err, isTooLarge)->)-> + callback = _.once callback - # don't remove this line, some zips need - # us to listen on this for some unknow reason + unzip = child.spawn("unzip", ["-l", source]) + + output = "" unzip.stdout.on "data", (d)-> + output += d error = null unzip.stderr.on "data", (chunk) -> @@ -29,9 +31,80 @@ module.exports = ArchiveManager = callback(err) unzip.on "exit", () -> - timer.done() if error? error = new Error(error) - logger.error err:error, source: source, destination: destination, "error unzipping file" - callback(error) + logger.error err:error, source: source, destination: destination, "error checking zip size" + + lines = output.split("\n") + lastLine = lines[lines.length - 2]?.trim() + totalSizeInBytes = lastLine?.split(" ")?[0] + + totalSizeInBytes = parseInt(totalSizeInBytes) + + if !totalSizeInBytes? or isNaN(totalSizeInBytes) + logger.err source:source, "error getting bytes of zip" + return callback(new Error("something went wrong")) + + isTooLarge = totalSizeInBytes > (ONE_MEG * 300) + + callback(error, isTooLarge) + + + + + + extractZipArchive: (source, destination, _callback = (err) ->) -> + callback = (args...) -> + _callback(args...) + _callback = () -> + + ArchiveManager._isZipTooLarge source, (err, isTooLarge)-> + if err? + logger.err err:err, "error checking size of zip file" + return callback(err) + + if isTooLarge + return callback(new Error("zip_too_large")) + + + timer = new metrics.Timer("unzipDirectory") + logger.log source: source, destination: destination, "unzipping file" + + unzip = child.spawn("unzip", [source, "-d", destination]) + + # don't remove this line, some zips need + # us to listen on this for some unknow reason + unzip.stdout.on "data", (d)-> + + error = null + unzip.stderr.on "data", (chunk) -> + error ||= "" + error += chunk + + unzip.on "error", (err) -> + logger.error {err, source, destination}, "unzip failed" + if err.code == "ENOENT" + logger.error "unzip command not found. Please check the unzip command is installed" + callback(err) + + unzip.on "exit", () -> + timer.done() + if error? + error = new Error(error) + logger.error err:error, source: source, destination: destination, "error unzipping file" + callback(error) + + findTopLevelDirectory: (directory, callback = (error, topLevelDir) ->) -> + fs.readdir directory, (error, files) -> + return callback(error) if error? + if files.length == 1 + childPath = Path.join(directory, files[0]) + fs.stat childPath, (error, stat) -> + return callback(error) if error? + if stat.isDirectory() + return callback(null, childPath) + else + return callback(null, directory) + else + return callback(null, directory) diff --git a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee index 4f0c4dcc0b..04c7c3bcc7 100644 --- a/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee +++ b/services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee @@ -4,62 +4,108 @@ _ = require "underscore" FileTypeManager = require "./FileTypeManager" EditorController = require "../Editor/EditorController" ProjectLocator = require "../Project/ProjectLocator" +logger = require("logger-sharelatex") module.exports = FileSystemImportManager = - addDoc: (project_id, folder_id, name, path, replace, callback = (error, doc)-> )-> - fs.readFile path, "utf8", (error, content = "") -> - return callback(error) if error? - content = content.replace(/\r/g, "") - lines = content.split("\n") - EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback - - addFile: (project_id, folder_id, name, path, replace, callback = (error, file)-> )-> - if replace - ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) -> + addDoc: (user_id, project_id, folder_id, name, path, replace, callback = (error, doc)-> )-> + FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> + if !isSafe + logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, name:name, path:path, "add doc is from symlink, stopping process" + return callback("path is symlink") + fs.readFile path, "utf8", (error, content = "") -> return callback(error) if error? - return callback(new Error("Couldn't find folder")) if !folder? - existingFile = null - for fileRef in folder.fileRefs - if fileRef.name == name - existingFile = fileRef - break - if existingFile? - EditorController.replaceFile project_id, existingFile._id, path, "upload", callback - else - EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback - else - EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback - - addFolder: (project_id, folder_id, name, path, replace, callback = (error)-> ) -> - EditorController.addFolderWithoutLock project_id, folder_id, name, "upload", (error, new_folder) => - return callback(error) if error? - @addFolderContents project_id, new_folder._id, path, replace, (error) -> - return callback(error) if error? - callback null, new_folder - - addFolderContents: (project_id, parent_folder_id, folderPath, replace, callback = (error)-> ) -> - fs.readdir folderPath, (error, entries = []) => - return callback(error) if error? - jobs = _.map entries, (entry) => - (callback) => - FileTypeManager.shouldIgnore entry, (error, ignore) => + content = content.replace(/\r/g, "") + lines = content.split("\n") + if replace + ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) -> return callback(error) if error? - if !ignore - @addEntity project_id, parent_folder_id, entry, "#{folderPath}/#{entry}", replace, callback + return callback(new Error("Couldn't find folder")) if !folder? + existingDoc = null + for doc in folder.docs + if doc.name == name + existingDoc = doc + break + if existingDoc? + EditorController.setDoc project_id, existingDoc._id, user_id, lines, "upload", callback else - callback() - async.parallelLimit jobs, 5, callback + EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback + else + EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback - addEntity: (project_id, folder_id, name, path, replace, callback = (error, entity)-> ) -> - FileTypeManager.isDirectory path, (error, isDirectory) => - return callback(error) if error? - if isDirectory - @addFolder project_id, folder_id, name, path, replace, callback + addFile: (user_id, project_id, folder_id, name, path, replace, callback = (error, file)-> )-> + FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> + if !isSafe + logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, name:name, path:path, "add file is from symlink, stopping insert" + return callback("path is symlink") + + if !replace + EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback else - FileTypeManager.isBinary name, path, (error, isBinary) => + ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) -> return callback(error) if error? - if isBinary - @addFile project_id, folder_id, name, path, replace, callback + return callback(new Error("Couldn't find folder")) if !folder? + existingFile = null + for fileRef in folder.fileRefs + if fileRef.name == name + existingFile = fileRef + break + if existingFile? + EditorController.replaceFile project_id, existingFile._id, path, "upload", callback else - @addDoc project_id, folder_id, name, path, replace, callback + EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback + + addFolder: (user_id, project_id, folder_id, name, path, replace, callback = (error)-> ) -> + FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> + if !isSafe + logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, path:path, "add folder is from symlink, stopping insert" + return callback("path is symlink") + EditorController.addFolderWithoutLock project_id, folder_id, name, "upload", (error, new_folder) => + return callback(error) if error? + FileSystemImportManager.addFolderContents user_id, project_id, new_folder._id, path, replace, (error) -> + return callback(error) if error? + callback null, new_folder + + addFolderContents: (user_id, project_id, parent_folder_id, folderPath, replace, callback = (error)-> ) -> + FileSystemImportManager._isSafeOnFileSystem folderPath, (err, isSafe)-> + if !isSafe + logger.log user_id:user_id, project_id:project_id, parent_folder_id:parent_folder_id, folderPath:folderPath, "add folder contents is from symlink, stopping insert" + return callback("path is symlink") + fs.readdir folderPath, (error, entries = []) => + return callback(error) if error? + jobs = _.map entries, (entry) => + (callback) => + FileTypeManager.shouldIgnore entry, (error, ignore) => + return callback(error) if error? + if !ignore + FileSystemImportManager.addEntity user_id, project_id, parent_folder_id, entry, "#{folderPath}/#{entry}", replace, callback + else + callback() + async.parallelLimit jobs, 5, callback + + addEntity: (user_id, project_id, folder_id, name, path, replace, callback = (error, entity)-> ) -> + FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)-> + if !isSafe + logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, path:path, "add entry is from symlink, stopping insert" + return callback("path is symlink") + + FileTypeManager.isDirectory path, (error, isDirectory) => + return callback(error) if error? + if isDirectory + FileSystemImportManager.addFolder user_id, project_id, folder_id, name, path, replace, callback + else + FileTypeManager.isBinary name, path, (error, isBinary) => + return callback(error) if error? + if isBinary + FileSystemImportManager.addFile user_id, project_id, folder_id, name, path, replace, callback + else + FileSystemImportManager.addDoc user_id, project_id, folder_id, name, path, replace, callback + + + _isSafeOnFileSystem: (path, callback = (err, isSafe)->)-> + fs.lstat path, (err, stat)-> + if err? + logger.err err:err, "error with path symlink check" + return callback(err) + isSafe = stat.isFile() or stat.isDirectory() + callback(err, isSafe) diff --git a/services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee b/services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee index 83a8db5c2d..b7cab7ac5c 100644 --- a/services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee +++ b/services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee @@ -34,7 +34,9 @@ module.exports = ProjectUploadController = if !name? or name.length == 0 or name.length > 150 logger.err project_id:project_id, name:name, "bad name when trying to upload file" return res.send success: false - FileSystemImportManager.addEntity project_id, folder_id, name, path, true, (error, entity) -> + logger.log folder_id:folder_id, project_id:project_id, "getting upload file request" + user_id = req.session.user._id + FileSystemImportManager.addEntity user_id, project_id, folder_id, name, path, true, (error, entity) -> fs.unlink path, -> timer.done() if error? diff --git a/services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee b/services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee index b5d108d21d..127c6bc94f 100644 --- a/services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee +++ b/services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee @@ -9,19 +9,21 @@ module.exports = ProjectUploadHandler = createProjectFromZipArchive: (owner_id, name, zipPath, callback = (error, project) ->) -> ProjectCreationHandler.createBlankProject owner_id, name, (error, project) => return callback(error) if error? - @insertZipArchiveIntoFolder project._id, project.rootFolder[0]._id, zipPath, (error) -> + @insertZipArchiveIntoFolder owner_id, project._id, project.rootFolder[0]._id, zipPath, (error) -> return callback(error) if error? ProjectRootDocManager.setRootDocAutomatically project._id, (error) -> return callback(error) if error? callback(error, project) - insertZipArchiveIntoFolder: (project_id, folder_id, path, callback = (error) ->) -> + insertZipArchiveIntoFolder: (owner_id, project_id, folder_id, path, callback = (error) ->) -> destination = @_getDestinationDirectory path ArchiveManager.extractZipArchive path, destination, (error) -> return callback(error) if error? - FileSystemImportManager.addFolderContents project_id, folder_id, destination, false, (error) -> + ArchiveManager.findTopLevelDirectory destination, (error, topLevelDestination) -> return callback(error) if error? - rimraf(destination, callback) + FileSystemImportManager.addFolderContents owner_id, project_id, folder_id, topLevelDestination, false, (error) -> + return callback(error) if error? + rimraf(destination, callback) _getDestinationDirectory: (source) -> return path.join(path.dirname(source), "#{path.basename(source, ".zip")}-#{Date.now()}") diff --git a/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee b/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee index 63ad6eb96d..c82144acc0 100644 --- a/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee +++ b/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee @@ -13,8 +13,8 @@ module.exports = RateLimiterMiddlewear.rateLimit({ endpointName: "file-upload" params: ["Project_id"] - maxRequests: 100 - timeInterval: 60 * 20 + maxRequests: 200 + timeInterval: 60 * 30 }), SecurityManager.requestCanModifyProject, ProjectUploadController.uploadFile diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee index 94dc811e99..451729d7e9 100644 --- a/services/web/app/coffee/Features/User/UserController.coffee +++ b/services/web/app/coffee/Features/User/UserController.coffee @@ -1,3 +1,4 @@ +UserHandler = require("./UserHandler") UserDeleter = require("./UserDeleter") UserLocator = require("./UserLocator") User = require("../../models/User").User @@ -8,12 +9,9 @@ metrics = require("../../infrastructure/Metrics") Url = require("url") AuthenticationManager = require("../Authentication/AuthenticationManager") UserUpdater = require("./UserUpdater") -EmailHandler = require("../Email/EmailHandler") -OneTimeTokenHandler = require "../Security/OneTimeTokenHandler" settings = require "settings-sharelatex" -crypto = require "crypto" -module.exports = +module.exports = UserController = deleteUser: (req, res)-> user_id = req.session.user._id @@ -66,11 +64,18 @@ module.exports = if err? logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address" if err.message == "alread_exists" - message = req.i18n.translate("alread_exists") + message = req.i18n.translate("email_already_registered") else message = req.i18n.translate("problem_changing_email_address") return res.send 500, {message:message} - res.sendStatus(200) + User.findById user_id, (err, user)-> + if err? + logger.err err:err, user_id:user_id, "error getting user for email update" + return res.send 500 + UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background + if err? + logger.err err:err, "error populateGroupLicenceInvite" + res.sendStatus(200) logout : (req, res)-> metrics.inc "user.logout" @@ -85,32 +90,12 @@ module.exports = if !email? or email == "" res.sendStatus 422 # Unprocessable Entity return - logger.log {email}, "registering new user" - UserRegistrationHandler.registerNewUser { - email: email - password: crypto.randomBytes(32).toString("hex") - }, (err, user)-> - if err? and err?.message != "EmailAlreadyRegistered" - return next(err) - - if err?.message == "EmailAlreadyRegistered" - logger.log {email}, "user already exists, resending welcome email" - - ONE_WEEK = 7 * 24 * 60 * 60 # seconds - OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)-> - return next(err) if err? - - setNewPasswordUrl = "#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}&email=#{encodeURIComponent(email)}" - - EmailHandler.sendEmail "registered", { - to: user.email - setNewPasswordUrl: setNewPasswordUrl - }, () -> - - res.json { - email: user.email - setNewPasswordUrl: setNewPasswordUrl - } + UserRegistrationHandler.registerNewUserAndSendActivationEmail email, (error, user, setNewPasswordUrl) -> + return next(error) if error? + res.json { + email: user.email + setNewPasswordUrl: setNewPasswordUrl + } changePassword : (req, res, next = (error) ->)-> metrics.inc "user.password-change" diff --git a/services/web/app/coffee/Features/User/UserHandler.coffee b/services/web/app/coffee/Features/User/UserHandler.coffee new file mode 100644 index 0000000000..8af78573d6 --- /dev/null +++ b/services/web/app/coffee/Features/User/UserHandler.coffee @@ -0,0 +1,26 @@ +SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler") +NotificationsBuilder = require("../Notifications/NotificationsBuilder") +SubscriptionGroupHandler = require("../Subscription/SubscriptionGroupHandler") +logger = require("logger-sharelatex") + + +module.exports = UserHandler = + + populateGroupLicenceInvite: (user, callback)-> + logger.log user_id:user._id, "populating any potential group licence invites" + licence = SubscriptionDomainHandler.getLicenceUserCanJoin user + if !licence? + return callback() + + SubscriptionGroupHandler.isUserPartOfGroup user._id, licence.subscription_id, (err, alreadyPartOfGroup)-> + if err? + return callback(err) + else if alreadyPartOfGroup + logger.log user_id:user._id, "user already part of group, not creating notifcation for them" + return callback() + else + NotificationsBuilder.groupPlan(user, licence).create(callback) + + setupLoginData: (user, callback = ->)-> + @populateGroupLicenceInvite user, callback + diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index 6ffb8ecdb8..5a967605fe 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -1,4 +1,6 @@ UserLocator = require("./UserLocator") +UserGetter = require("./UserGetter") +ErrorController = require("../Errors/ErrorController") logger = require("logger-sharelatex") Settings = require("settings-sharelatex") fs = require('fs') @@ -20,11 +22,33 @@ module.exports = sharedProjectData: sharedProjectData newTemplateData: newTemplateData new_email:req.query.new_email || "" + + activateAccountPage: (req, res) -> + # An 'activation' is actually just a password reset on an account that + # was set with a random password originally. + if !req.query?.user_id? or !req.query?.token? + return ErrorController.notFound(req, res) + + UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) -> + return next(error) if error? + if !user + return ErrorController.notFound(req, res) + if user.loginCount > 0 + # Already seen this user, so account must be activate + # This lets users keep clicking the 'activate' link in their email + # as a way to log in which, if I know our users, they will. + res.redirect "/login?email=#{encodeURIComponent(user.email)}" + else + res.render 'user/activate', + title: 'activate_account' + email: user.email, + token: req.query.token loginPage : (req, res)-> res.render 'user/login', title: 'login', - redir: req.query.redir + redir: req.query.redir, + email: req.query.email settingsPage : (req, res, next)-> logger.log user: req.session.user, "loading settings page" diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index 7dfde21c8a..f407091122 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -5,8 +5,12 @@ AuthenticationManager = require("../Authentication/AuthenticationManager") NewsLetterManager = require("../Newsletter/NewsletterManager") async = require("async") logger = require("logger-sharelatex") +crypto = require("crypto") +EmailHandler = require("../Email/EmailHandler") +OneTimeTokenHandler = require "../Security/OneTimeTokenHandler" +settings = require "settings-sharelatex" -module.exports = +module.exports = UserRegistrationHandler = validateEmail : (email) -> re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\ ".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA -Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ return re.test(email) @@ -58,6 +62,31 @@ module.exports = ], (err)-> logger.log user: user, "registered" callback(err, user) + + registerNewUserAndSendActivationEmail: (email, callback = (error, user, setNewPasswordUrl) ->) -> + logger.log {email}, "registering new user" + UserRegistrationHandler.registerNewUser { + email: email + password: crypto.randomBytes(32).toString("hex") + }, (err, user)-> + if err? and err?.message != "EmailAlreadyRegistered" + return callback(err) + + if err?.message == "EmailAlreadyRegistered" + logger.log {email}, "user already exists, resending welcome email" + + ONE_WEEK = 7 * 24 * 60 * 60 # seconds + OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)-> + return callback(err) if err? + + setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}" + + EmailHandler.sendEmail "registered", { + to: user.email + setNewPasswordUrl: setNewPasswordUrl + }, () -> + + callback null, user, setNewPasswordUrl diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 61eaf6ef6d..1fd1aa10a2 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -23,9 +23,9 @@ for path in [ "#{jsPath}main.js", "#{jsPath}libs.js", "#{jsPath}ace/ace.js", - "#{jsPath}libs/pdfjs-1.0.1040/pdf.js", - "#{jsPath}libs/pdfjs-1.0.1040/pdf.worker.js", - "#{jsPath}libs/pdfjs-1.0.1040/compatibility.js", + "#{jsPath}libs/pdfjs-1.3.91/pdf.js", + "#{jsPath}libs/pdfjs-1.3.91/pdf.worker.js", + "#{jsPath}libs/pdfjs-1.3.91/compatibility.js", "/stylesheets/style.css" ] filePath = Path.join __dirname, "../../../", "public#{path}" diff --git a/services/web/app/coffee/models/Project.coffee b/services/web/app/coffee/models/Project.coffee index 111ef40cfb..dc9f0927fd 100644 --- a/services/web/app/coffee/models/Project.coffee +++ b/services/web/app/coffee/models/Project.coffee @@ -31,6 +31,7 @@ ProjectSchema = new Schema description : {type:String, default:''} archived : { type: Boolean } deletedDocs : [DeletedDocSchema] + imageName : { type: String } ProjectSchema.statics.getProject = (project_or_id, fields, callback)-> if project_or_id._id? @@ -65,51 +66,7 @@ ProjectSchema.statics.findAllUsersProjects = (user_id, requiredFields, callback) this.find {readOnly_refs:user_id}, requiredFields, (err, readOnlyProjects)=> callback(err, projects, collabertions, readOnlyProjects) -sanitizeTypeOfElement = (elementType)-> - lastChar = elementType.slice -1 - if lastChar != "s" - elementType +="s" - if elementType == "files" - elementType = "fileRefs" - return elementType -ProjectSchema.statics.putElement = (project_id, folder_id, element, type, callback)-> - if !element? - e = new Error("no element passed to be inserted") - logger.err project_id:project_id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as it was null" - return callback(e) - type = sanitizeTypeOfElement type - this.findById project_id, (err, project)=> - if err? - callback(err) - if !folder_id? - folder_id = project.rootFolder[0]._id - require('../Features/Project/ProjectLocator').findElement {project:project, element_id:folder_id, type:"folders"}, (err, folder, path)=> - newPath = - fileSystem: "#{path.fileSystem}/#{element.name}" - mongo: path.mongo # TODO: This is not correct - if err? - callback(err) - logger.log project_id: project_id, element_id: element._id, fileType: type, folder_id: folder_id, "adding element to project" - id = element._id+'' - element._id = concreteObjectId(id) - conditions = _id:project_id - mongopath = "#{path.mongo}.#{type}" - update = "$push":{} - update["$push"][mongopath] = element - this.update conditions, update, {}, (err)-> - if(err) - logger.err err: err, project: project, 'error saving in putElement project' - if callback? - callback(err, {path:newPath}) - -getIndexOf = (searchEntity, id)-> - length = searchEntity.length - count = 0 - while(count < length) - if searchEntity[count]._id+"" == id+"" - return count - count++ diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index af83318b49..8fca181d0e 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -34,6 +34,8 @@ UserSchema = new Schema github: { type:Boolean, default: Settings.defaultFeatures.github } compileTimeout: { type:Number, default: Settings.defaultFeatures.compileTimeout } compileGroup: { type:String, default: Settings.defaultFeatures.compileGroup } + templates: { type:Boolean, default: Settings.defaultFeatures.templates } + references: { type:Boolean, default: Settings.defaultFeatures.references } } featureSwitches : { pdfng: { type: Boolean } diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index a0c1b50d21..46b1db2157 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -16,6 +16,7 @@ ReferalController = require('./Features/Referal/ReferalController') ReferalMiddleware = require('./Features/Referal/ReferalMiddleware') AuthenticationController = require('./Features/Authentication/AuthenticationController') TagsController = require("./Features/Tags/TagsController") +NotificationsController = require("./Features/Notifications/NotificationsController") CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter') UserInfoController = require('./Features/User/UserInfoController') UserController = require("./Features/User/UserController") @@ -37,6 +38,7 @@ RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear') RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter') InactiveProjectController = require("./Features/InactiveData/InactiveProjectController") ContactRouter = require("./Features/Contacts/ContactRouter") +ReferencesController = require('./Features/References/ReferencesController') logger = require("logger-sharelatex") _ = require("underscore") @@ -46,7 +48,7 @@ module.exports = class Router if !Settings.allowPublicAccess webRouter.all '*', AuthenticationController.requireGlobalLogin - + webRouter.get '/login', UserPagesController.loginPage AuthenticationController.addEndpointToLoginWhitelist '/login' @@ -67,16 +69,18 @@ module.exports = class Router StaticPagesRouter.apply(webRouter, apiRouter) RealTimeProxyRouter.apply(webRouter, apiRouter) ContactRouter.apply(webRouter, apiRouter) - + Modules.applyRouter(webRouter, apiRouter) if Settings.enableSubscriptions webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus - + webRouter.get '/blog', BlogController.getIndexPage webRouter.get '/blog/*', BlogController.getPage - + + webRouter.get '/user/activate', UserPagesController.activateAccountPage + webRouter.get '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword @@ -128,8 +132,15 @@ module.exports = class Router webRouter.get '/Project/:Project_id/download/zip', SecurityManager.requestCanAccessProject, ProjectDownloadsController.downloadProject webRouter.get '/project/download/zip', SecurityManager.requestCanAccessMultipleProjects, ProjectDownloadsController.downloadMultipleProjects - webRouter.get '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags - webRouter.post '/project/:project_id/tag', AuthenticationController.requireLogin(), TagsController.processTagsUpdate + webRouter.get '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags + webRouter.post '/tag', AuthenticationController.requireLogin(), TagsController.createTag + webRouter.post '/tag/:tag_id/rename', AuthenticationController.requireLogin(), TagsController.renameTag + webRouter.delete '/tag/:tag_id', AuthenticationController.requireLogin(), TagsController.deleteTag + webRouter.post '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.addProjectToTag + webRouter.delete '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.removeProjectFromTag + + webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications + webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead # Deprecated in favour of /internal/project/:project_id but still used by versioning apiRouter.get '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails @@ -156,7 +167,7 @@ module.exports = class Router apiRouter.post '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.mergeUpdate apiRouter.delete '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.deleteUpdate - + apiRouter.post '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.updateProjectContents apiRouter.delete '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.deleteProjectContents @@ -165,9 +176,12 @@ module.exports = class Router webRouter.get "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.getMessages webRouter.post "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.sendMessage - + webRouter.get /learn(\/.*)?/, WikiController.getPage + webRouter.post "/project/:Project_id/references/index", SecurityManager.requestCanAccessProject, ReferencesController.index + webRouter.post "/project/:Project_id/references/indexAll", SecurityManager.requestCanAccessProject, ReferencesController.indexAll + #Admin Stuff webRouter.get '/admin', SecurityManager.requestIsAdmin, AdminController.index webRouter.get '/admin/user', SecurityManager.requestIsAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon @@ -186,7 +200,7 @@ module.exports = class Router apiRouter.get '/status', (req,res)-> res.send("websharelatex is up") - + webRouter.get '/health_check', HealthCheckController.check webRouter.get '/health_check/redis', HealthCheckController.checkRedis @@ -222,4 +236,4 @@ module.exports = class Router logger.error err: req.body.error, meta: req.body.meta, "client side error" res.sendStatus(204) - webRouter.get '*', ErrorController.notFound + webRouter.get '*', ErrorController.notFound \ No newline at end of file diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.jade index 87fe3e7b8e..e666724b09 100644 --- a/services/web/app/views/project/editor.jade +++ b/services/web/app/views/project/editor.jade @@ -53,7 +53,13 @@ block content include ./editor/share - #ide-body(ng-cloak, layout="main", ng-hide="state.loading", resize-on="layout:chat:resize") + #ide-body( + ng-cloak, + layout="main", + ng-hide="state.loading", + resize-on="layout:chat:resize", + minimum-restore-size-west="130" + ) .ui-layout-west include ./editor/file-tree @@ -76,7 +82,7 @@ block content ng-click="done()" ) × h3 {{ title }} - .modal-body {{ message }} + .modal-body(ng-bind-html="message") .modal-footer button.btn.btn-info(ng-click="done()") #{translate("ok")} @@ -95,13 +101,13 @@ block content "paths" : { "mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML", "moment": "libs/moment-2.7.0", - "libs/pdf": "libs/pdfjs-1.0.1040/pdf" + "libs/pdf": "libs/pdfjs-1.3.91/pdf" }, "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}", "waitSeconds": 0, "shim": { "libs/pdf": { - deps: ["libs/pdfjs-1.0.1040/compatibility"] + deps: ["libs/pdfjs-1.3.91/compatibility"] }, "ace/ext-searchbox": { deps: ["ace/ace"] @@ -120,7 +126,7 @@ block content - locals.suppressDefaultJs = true - - var pdfPath = 'libs/pdfjs-1.0.1040/pdf.worker.js' + - var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js' - var fingerprintedPath = fingerprint(jsPath+pdfPath) - var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath script(type='text/javascript'). diff --git a/services/web/app/views/project/editor/editor.jade b/services/web/app/views/project/editor/editor.jade index 2241eb3cd2..f79f6e3c19 100644 --- a/services/web/app/views/project/editor/editor.jade +++ b/services/web/app/views/project/editor/editor.jade @@ -6,6 +6,7 @@ div.full-size( resize-on="layout:main:resize" resize-proportionally="true" initial-size-east="'50%'" + minimum-restore-size-east="300" ) .ui-layout-center .loading-panel(ng-show="!editor.sharejs_doc || editor.opening") @@ -19,6 +20,7 @@ div.full-size( keybindings="settings.mode", font-size="settings.fontSize", auto-complete="settings.autoComplete", + spell-check="true", spell-check-language="project.spellCheckLanguage", highlights="onlineUserCursorHighlights[editor.open_doc_id]" show-print-margin="false", diff --git a/services/web/app/views/project/editor/file-tree.jade b/services/web/app/views/project/editor/file-tree.jade index 31564b0db1..bb07ee9e88 100644 --- a/services/web/app/views/project/editor/file-tree.jade +++ b/services/web/app/views/project/editor/file-tree.jade @@ -1,4 +1,4 @@ -aside#file-tree(ng-controller="FileTreeController").full-size +aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }").full-size .toolbar.toolbar-small.toolbar-alt(ng-if="permissions.write") a( href, @@ -27,7 +27,8 @@ aside#file-tree(ng-controller="FileTreeController").full-size href, ng-click="startRenamingSelected()", tooltip="#{translate('rename')}", - tooltip-placement="bottom" + tooltip-placement="bottom", + ng-show="multiSelectedCount == 0" ) i.fa.fa-pencil a( @@ -45,26 +46,24 @@ aside#file-tree(ng-controller="FileTreeController").full-size ng-controller="FileTreeRootFolderController", ng-class="{ 'no-toolbar': !permissions.write }" ) - - div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')") - ul.list-unstyled.file-tree-list - li( - ng-class="{ 'selected': ui.view == 'pdf' }" - ng-controller="PdfViewToggleController" - ) - .entity - .entity-name( - ng-click="togglePdfView()" - ) - i.fa.fa-fw.toggle - i.fa.fa-fw.fa-file-pdf-o - | PDF - ul.list-unstyled.file-tree-list( droppable="permissions.write" accept=".entity-name" on-drop-callback="onDrop" ) + li( + ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')" + ng-class="{ 'selected': ui.view == 'pdf' }" + ng-controller="PdfViewToggleController" + ) + .entity + .entity-name( + ng-click="togglePdfView()" + ) + i.fa.fa-fw.toggle + i.fa.fa-fw.fa-file-pdf-o + | PDF + file-entity( entity="entity", permissions="permissions", @@ -81,27 +80,25 @@ aside#file-tree(ng-controller="FileTreeController").full-size ) .entity .entity-name( - ng-click="select()" + ng-click="select($event)" ) //- Just a spacer to align with folders i.fa.fa-fw.toggle i.fa.fa-fw.fa-file span {{ entity.name }} - - - script(type='text/ng-template', id='entityListItemTemplate') li( - ng-class="{ 'selected': entity.selected }", + ng-class="{ 'selected': entity.selected, 'multi-selected': entity.multiSelected }", ng-controller="FileTreeEntityController" ) .entity(ng-if="entity.type != 'folder'") .entity-name( - ng-click="select()" + ng-click="select($event)" ng-dblclick="permissions.write && startRenaming()" draggable="permissions.write" + draggable-helper="draggableHelper" context-menu data-target="context-menu-{{ entity.id }}" context-menu-container="body" @@ -112,7 +109,6 @@ script(type='text/ng-template', id='entityListItemTemplate') i.fa.fa-fw.fa-file(ng-if="entity.type == 'doc'") i.fa.fa-fw.fa-image(ng-if="entity.type == 'file'") - span( ng-hide="entity.renaming" ) {{ entity.name }} @@ -126,12 +122,11 @@ script(type='text/ng-template', id='entityListItemTemplate') on-enter="finishRenaming()" ) - span.dropdown( + span.dropdown.entity-menu-toggle( dropdown, - ng-show="entity.selected", ng-if="permissions.write" ) - a.dropdown-toggle(href, dropdown-toggle) + a.dropdown-toggle(href, dropdown-toggle, stop-propagation="click") i.fa.fa-chevron-down ul.dropdown-menu.dropdown-menu-right @@ -140,12 +135,14 @@ script(type='text/ng-template', id='entityListItemTemplate') href ng-click="startRenaming()" right-click="startRenaming()" + ng-show="!entity.multiSelected" ) #{translate("rename")} li a( href ng-click="openDeleteModal()" right-click="openDeleteModal()" + stop-propagation="click" ) #{translate("delete")} div.dropdown.context-menu( @@ -158,20 +155,23 @@ script(type='text/ng-template', id='entityListItemTemplate') href ng-click="startRenaming()" right-click="startRenaming()" + ng-show="!entity.multiSelected" ) #{translate("rename")} li a( href ng-click="openDeleteModal()" right-click="openDeleteModal()" + stop-propagation="click" ) #{translate("delete")} .entity(ng-if="entity.type == 'folder'", ng-controller="FileTreeFolderController") .entity-name( - ng-click="select()" + ng-click="select($event)" ng-dblclick="permissions.write && startRenaming()" draggable="permissions.write" + draggable-helper="draggableHelper" droppable="permissions.write" accept=".entity-name" on-drop-callback="onDrop" @@ -194,7 +194,7 @@ script(type='text/ng-template', id='entityListItemTemplate') 'fa-folder': !expanded, \ 'fa-folder-open': expanded \ }" - ng-click="select()" + ng-click="select($event)" ) span( @@ -210,12 +210,11 @@ script(type='text/ng-template', id='entityListItemTemplate') on-enter="finishRenaming()" ) - span.dropdown( + span.dropdown.entity-menu-toggle( dropdown, ng-if="permissions.write" - ng-show="entity.selected" ) - a.dropdown-toggle(href, dropdown-toggle) + a.dropdown-toggle(href, dropdown-toggle, stop-propagation="click") i.fa.fa-chevron-down ul.dropdown-menu.dropdown-menu-right @@ -224,12 +223,14 @@ script(type='text/ng-template', id='entityListItemTemplate') href ng-click="startRenaming()" right-click="startRenaming()" + ng-show="!entity.multiSelected" ) #{translate("rename")} li a( href ng-click="openDeleteModal()" right-click="openDeleteModal()" + stop-propagation="click" ) #{translate("delete")} li.divider li @@ -261,12 +262,14 @@ script(type='text/ng-template', id='entityListItemTemplate') href ng-click="startRenaming()" right-click="startRenaming()" + ng-show="!entity.multiSelected" ) #{translate("rename")} li a( href ng-click="openDeleteModal()" right-click="openDeleteModal()" + stop-propagation="click" ) #{translate("delete")} li.divider li @@ -306,6 +309,7 @@ script(type='text/ng-template', id='newDocModalTemplate') h3 #{translate("new_file")} .modal-body form(novalidate, name="newDocForm") + div.alert.alert-danger(ng-if="error") {{error}} input.form-control( type="text", placeholder="File Name", @@ -330,6 +334,7 @@ script(type='text/ng-template', id='newFolderModalTemplate') .modal-header h3 #{translate("new_folder")} .modal-body + div.alert.alert-danger(ng-if="error") {{error}} form(novalidate, name="newFolderForm") input.form-control( type="text", @@ -354,9 +359,20 @@ script(type='text/ng-template', id='newFolderModalTemplate') script(type="text/ng-template", id="uploadFileModalTemplate") .modal-header h3 #{translate("upload_files")} - span   - .alert.alert-warning.small(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})} - .alert.alert-warning.small(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")} + .alert.alert-warning.small.modal-alert(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})} + .alert.alert-warning.small.modal-alert(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")} + .alert.alert-warning.small.modal-alert(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})} + .alert.alert-warning.small.modal-alert(ng-if="conflicts.length > 0") + p.text-center + | The following files already exist in this project: + ul.text-center.list-unstyled.row-spaced-small + li(ng-repeat="conflict in conflicts"): strong {{ conflict }} + p.text-center.row-spaced-small + | Do you want to overwrite them? + p.text-center + a(href, ng-click="doUpload()").btn.btn-primary Overwrite + |   + a(href, ng-click="cancel()").btn.btn-default Cancel .modal-body( fine-upload @@ -367,10 +383,14 @@ script(type="text/ng-template", id="uploadFileModalTemplate") drag-area-text="{{drag_files}}" hint-text="{{hint_press_and_hold_control_key}}" multiple="true" + auto-upload="false" on-complete-callback="onComplete" on-upload-callback="onUpload" on-validate-batch="onValidateBatch" on-error-callback="onError" + on-submit-callback="onSubmit" + on-cancel-callback="onCancel" + control="control" params="{'folder_id': parent_folder_id}" ) span #{translate("upload_files")} @@ -382,6 +402,8 @@ script(type='text/ng-template', id='deleteEntityModalTemplate') h3 #{translate("delete")} {{ entity.name }} .modal-body p !{translate("sure_you_want_to_delete")} + ul + li(ng-repeat="entity in entities") {{entity.name}} .modal-footer button.btn.btn-default( ng-disabled="state.inflight" diff --git a/services/web/app/views/project/editor/hotkeys.jade b/services/web/app/views/project/editor/hotkeys.jade index c584e5eb28..c2c5e66759 100644 --- a/services/web/app/views/project/editor/hotkeys.jade +++ b/services/web/app/views/project/editor/hotkeys.jade @@ -50,16 +50,22 @@ script(type="text/ng-template", id="hotkeysModalTemplate") .hotkey span.combination {{ctrl}} + A span.description Select All - .col-xs-6 .hotkey span.combination Tab span.description Indent Selection + .col-xs-6 .hotkey - span.combination {{ctrl}} + U + span.combination Ctrl + U span.description To Uppercase .hotkey span.combination Ctrl + Shift + U span.description To Lowercase + .hotkey + span.combination {{ctrl}} + B + span.description Bold text + .hotkey + span.combination {{ctrl}} + I + span.description Italic Text .modal-footer button.btn.btn-default( ng-click="cancel()" diff --git a/services/web/app/views/project/editor/pdf.jade b/services/web/app/views/project/editor/pdf.jade index 28d78480fe..5d58f64e13 100644 --- a/services/web/app/views/project/editor/pdf.jade +++ b/services/web/app/views/project/editor/pdf.jade @@ -1,16 +1,34 @@ div.full-size.pdf(ng-controller="PdfController") .toolbar.toolbar-tall - a.btn.btn-info( - href, - ng-disabled="pdf.compiling", - ng-click="recompile()" - ) - i.fa.fa-refresh( - ng-class="{'fa-spin': pdf.compiling }" + .btn-group(dropdown) + a.btn.btn-info( + href, + ng-disabled="pdf.compiling", + ng-click="recompile()" ) - |    - span(ng-show="!pdf.compiling") #{translate("recompile")} - span(ng-show="pdf.compiling") #{translate("compiling")}... + i.fa.fa-refresh( + ng-class="{'fa-spin': pdf.compiling }" + ) + |    + span(ng-show="!pdf.compiling") #{translate("recompile")} + span(ng-show="pdf.compiling") #{translate("compiling")}... + a.btn.btn-info.dropdown-toggle( + href, + ng-disabled="pdf.compiling", + dropdown-toggle + ) + span.caret + ul.dropdown-menu.dropdown-menu-right + li.dropdown-header #{translate("compile_mode")} + li + a(href, ng-click="draft = false") + i.fa.fa-fw(ng-class="{'fa-check': !draft}") + |  #{translate("normal")} + li + a(href, ng-click="draft = true") + i.fa.fa-fw(ng-class="{'fa-check': draft}") + |  #{translate("fast")}  + span.subdued [draft] a.log-btn( href ng-click="toggleLogs()" @@ -94,41 +112,43 @@ div.full-size.pdf(ng-controller="PdfController") | #{translate("learn_how_to_make_documents_compile_quickly")} .alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile") - p - strong #{translate("upgrade_for_faster_compiles")} - h5 #{translate("free_accounts_have_timeout_upgrade_to_increase")} - h4 Plus: - h4 + p + strong #{translate("upgrade_for_faster_compiles")} + p #{translate("free_accounts_have_timeout_upgrade_to_increase")} + p Plus: + p + ul.list-unstyled + li i.fa.fa-check   | #{translate("unlimited_projects")} - h4 + li i.fa.fa-check   | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - h4 + li i.fa.fa-check   | #{translate("full_doc_history")} - h4 + li i.fa.fa-check   | #{translate("sync_to_dropbox")} - h4 + li i.fa.fa-check   | #{translate("sync_to_github")} - h4 + li i.fa.fa-check   |#{translate("compile_larger_projects")} - p(ng-controller="FreeTrialModalController") - a.btn.btn-success( - href - ng-class="buttonClass" - sixpack-convert="track_changes_feature_info" - ng-click="startFreeTrial('compile-timeout')" - ) #{translate("start_free_trial")} + p(ng-controller="FreeTrialModalController") + a.btn.btn-success.row-spaced-small( + href + ng-class="buttonClass" + sixpack-convert="track_changes_feature_info" + ng-click="startFreeTrial('compile-timeout')" + ) #{translate("start_free_trial")} .pdf-errors(ng-show="pdf.projectTooLarge") .alert.alert-danger diff --git a/services/web/app/views/project/editor/publish-template.jade b/services/web/app/views/project/editor/publish-template.jade index 7fd2c1a0f4..c89aeebe6d 100644 --- a/services/web/app/views/project/editor/publish-template.jade +++ b/services/web/app/views/project/editor/publish-template.jade @@ -6,46 +6,84 @@ script(type="text/ng-template", id="publishProjectAsTemplateModalTemplate") ng-click="cancel()" ) × h3 #{translate("publish_as_template")} - .modal-body.modal-body-share - span(ng-hide="problemTalkingToTemplateApi") - form() - label(for='Description') #{translate("template_description")} - .form-group - textarea.form-control( - rows=5, - name='Description', - ng-model="templateDetails.description", - value="" - ) - div(ng-show="templateDetails.exists").text-center.templateDetails - | #{translate("project_last_published_at")} - strong {{templateDetails.publishedDate}}. - a(ng-href="{{templateDetails.canonicalUrl}}") #{translate("view_in_template_gallery")}. + div(ng-if="project.features.templates") + .modal-body.modal-body-share + span(ng-hide="problemTalkingToTemplateApi") + form() + label(for='Description') #{translate("template_description")} + .form-group + textarea.form-control( + rows=5, + name='Description', + ng-model="templateDetails.description", + value="" + ) + div(ng-show="templateDetails.exists").text-center.templateDetails + | #{translate("project_last_published_at")} + strong {{templateDetails.publishedDate}}. + a(ng-href="{{templateDetails.canonicalUrl}}") #{translate("view_in_template_gallery")}. - span(ng-show="problemTalkingToTemplateApi") #{translate("problem_talking_to_publishing_service")}. + span(ng-show="problemTalkingToTemplateApi") #{translate("problem_talking_to_publishing_service")}. + + .modal-footer(ng-hide="problemTalkingToTemplateApi") + button.btn.btn-default( + ng-click="cancel()", + ng-disabled="state.publishInflight || state.unpublishInflight" + ) + span #{translate("cancel")} + button.btn.btn-info( + ng-click="unpublishTemplate()", + ng-disabled="state.publishInflight || state.unpublishInflight" + ng-show="templateDetails.exists" + ) + span(ng-show="!state.unpublishInflight") #{translate("unpublish")} + span(ng-show="state.unpublishInflight") #{translate("unpublishing")}... + button.btn.btn-primary( + ng-click="publishTemplate()", + ng-disabled="state.publishInflight || state.unpublishInflight" + ) + span(ng-show="!state.publishInflight && !templateDetails.exists") #{translate("publish")} + span(ng-show="!state.publishInflight && templateDetails.exists") #{translate("republish")} + span(ng-show="state.publishInflight") #{translate("publishing")}... + div(ng-hide="project.features.templates") + .modal-body.modal-body-share + p #{translate("upgrade_to_get_feature", {feature:"templates"})} - .modal-footer(ng-hide="problemTalkingToTemplateApi") - button.btn.btn-default( - ng-click="cancel()", - ng-disabled="state.publishInflight || state.unpublishInflight" - ) - span #{translate("cancel")} + ul.list-unstyled + li + i.fa.fa-check   + | #{translate("unlimited_projects")} + + li + i.fa.fa-check   + | #{translate("collabs_per_proj", {collabcount:'Multiple'})} + + li + i.fa.fa-check   + | #{translate("full_doc_history")} + + li + i.fa.fa-check   + | #{translate("sync_to_dropbox")} - button.btn.btn-info( - ng-click="unpublishTemplate()", - ng-disabled="state.publishInflight || state.unpublishInflight" - ng-show="templateDetails.exists" - ) - span(ng-show="!state.unpublishInflight") #{translate("unpublish")} - span(ng-show="state.unpublishInflight") #{translate("unpublishing")}... + li + i.fa.fa-check   + | #{translate("sync_to_github")} - button.btn.btn-primary( - ng-click="publishTemplate()", - ng-disabled="state.publishInflight || state.unpublishInflight" - ) - span(ng-show="!state.publishInflight && !templateDetails.exists") #{translate("publish")} - span(ng-show="!state.publishInflight && templateDetails.exists") #{translate("republish")} - span(ng-show="state.publishInflight") #{translate("publishing")}... \ No newline at end of file + li + i.fa.fa-check   + |#{translate("compile_larger_projects")} + + .modal-footer(ng-controller="FreeTrialModalController") + .text-center + a.btn.btn-success( + href + ng-class="buttonClass" + ng-click="startFreeTrial('templates')" + ) #{translate("start_free_trial")} + + .row-spaced-small.small(ng-show="startedFreeTrial") + | #{translate("refresh_page_after_starting_free_trial")} diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index 06aa611725..dad1d42108 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -79,33 +79,36 @@ script(type='text/ng-template', id='shareProjectModalTemplate') type="submit" ng-mousedown="addMembers()" ) #{translate("share")} - div.text-center(ng-hide="canAddCollaborators") - p #{translate("need_to_upgrade_for_more_collabs")}. - h4 - i.fa.fa-check   - | #{translate("unlimited_projects")} - - h4 - i.fa.fa-check   - | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - - h4 - i.fa.fa-check   - | #{translate("full_doc_history")} - - h4 - i.fa.fa-check   - | #{translate("sync_to_dropbox")} + div(ng-hide="canAddCollaborators") + p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also: + .row + .col-md-8.col-md-offset-2 + ul.list-unstyled + li + i.fa.fa-check   + | #{translate("unlimited_projects")} + + li + i.fa.fa-check   + | #{translate("collabs_per_proj", {collabcount:'Multiple'})} + + li + i.fa.fa-check   + | #{translate("full_doc_history")} + + li + i.fa.fa-check   + | #{translate("sync_to_dropbox")} - h4 - i.fa.fa-check   - | #{translate("sync_to_github")} + li + i.fa.fa-check   + | #{translate("sync_to_github")} - h4 - i.fa.fa-check   - |#{translate("compile_larger_projects")} + li + i.fa.fa-check   + |#{translate("compile_larger_projects")} - p(ng-controller="FreeTrialModalController") + p.text-center.row-spaced-thin(ng-controller="FreeTrialModalController") a.btn.btn-success( href ng-class="buttonClass" @@ -122,7 +125,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")} button.btn.btn-primary( ng-click="done()" - ) #{translate("done")} + ) #{translate("close")} script(type="text/ng-template", id="makePublicModalTemplate") .modal-header diff --git a/services/web/app/views/project/editor/track-changes.jade b/services/web/app/views/project/editor/track-changes.jade index 3f0bd539b0..dc7ce38708 100644 --- a/services/web/app/views/project/editor/track-changes.jade +++ b/services/web/app/views/project/editor/track-changes.jade @@ -1,83 +1,39 @@ div#trackChanges(ng-show="ui.view == 'track-changes'") span(ng-controller="TrackChangesPremiumPopup") - span(ng-if="versioningPopupType == 'default'") - .upgrade-prompt(ng-show="!project.features.versioning") - .message(ng-show="project.owner._id == user.id") - - p #{translate("upgrade_to_get_feature", {feature:"Entire Doc History"})} - p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")} - h4 + .upgrade-prompt(ng-show="!project.features.versioning") + .message(ng-show="project.owner._id == user.id") + p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})} + p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")} + ul.list-unstyled + li i.fa.fa-check   | #{translate("unlimited_projects")} - h4 + li i.fa.fa-check   | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - h4 + li i.fa.fa-check   | #{translate("full_doc_history")} - h4 + li i.fa.fa-check   | #{translate("sync_to_dropbox")} - h4 + li i.fa.fa-check   | #{translate("sync_to_github")} - h4 + li i.fa.fa-check   |#{translate("compile_larger_projects")} - p(ng-controller="FreeTrialModalController") - a.btn.btn-success( - href - ng-class="buttonClass" - ng-click="startFreeTrial('track-changes')" - ) #{translate("start_free_trial")} - - - - .message(ng-show="project.owner._id != user.id") - p #{translate("ask_proj_owner_to_upgrade_for_history")} - p - a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")} - - .upgrade-prompt(ng-show="!project.features.versioning", ng-if="versioningPopupType == 'discount'") - .message(ng-show="project.owner._id == user.id") - - p #{translate("upgrade_to_get_feature", {feature:"Entire Doc History"})} - h2(style="color:#a93529;") 20% Off First 6 Months! - p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")} - h4 - i.fa.fa-check   - | #{translate("unlimited_projects")} - - h4 - i.fa.fa-check   - | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - - h4 - i.fa.fa-check   - | #{translate("full_doc_history")} - - h4 - i.fa.fa-check   - | #{translate("sync_to_dropbox")} - - h4 - i.fa.fa-check   - | #{translate("sync_to_github")} - - h4 - i.fa.fa-check   - |#{translate("compile_larger_projects")} - p(ng-controller="FreeTrialModalController") + p.text-center(ng-controller="FreeTrialModalController") a.btn.btn-success( href ng-class="buttonClass" - ng-click="startFreeTrial('track-changes', 'cf3yutfzu7ztxz')" + ng-click="startFreeTrial('track-changes')" ) #{translate("start_free_trial")} diff --git a/services/web/app/views/project/list.jade b/services/web/app/views/project/list.jade index 54b2b2f42f..1c80b9a7a2 100644 --- a/services/web/app/views/project/list.jade +++ b/services/web/app/views/project/list.jade @@ -9,7 +9,8 @@ block content script(type="text/javascript"). window.data = { projects: !{JSON.stringify(projects).replace(/\//g, '\\/')}, - tags: !{JSON.stringify(tags).replace(/\//g, '\\/')} + tags: !{JSON.stringify(tags).replace(/\//g, '\\/')}, + notifications: !{JSON.stringify(notifications).replace(/\//g, '\\/')} }; window.algolia = { institutions: { @@ -20,12 +21,14 @@ block content .content.content-alt(ng-controller="ProjectPageController") .container + .row(ng-cloak) span(ng-show="first_sign_up == 'default' || projects.length > 0") aside.col-md-2.col-xs-3 include ./list/side-bar .col-md-10.col-xs-9 + include ./list/notifications include ./list/project-list span(ng-if="first_sign_up == 'minimial' && projects.length == 0") diff --git a/services/web/app/views/project/list/modals.jade b/services/web/app/views/project/list/modals.jade index ca609ae697..e0fe24ad14 100644 --- a/services/web/app/views/project/list/modals.jade +++ b/services/web/app/views/project/list/modals.jade @@ -18,6 +18,8 @@ script(type='text/ng-template', id='newTagModalTemplate') stop-propagation="click" ) .modal-footer + .modal-footer-left + span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")} //- We stop propagation to stop the clicks from closing the //- 'move to folder' menu. button.btn.btn-default( @@ -25,10 +27,67 @@ script(type='text/ng-template', id='newTagModalTemplate') stop-propagation="click" ) #{translate("cancel")} button.btn.btn-primary( - ng-disabled="newTagForm.$invalid" + ng-disabled="newTagForm.$invalid || state.inflight" ng-click="create()" stop-propagation="click" - ) #{translate("create")} + ) + span(ng-show="!state.inflight") #{translate("create")} + span(ng-show="state.inflight") #{translate("creating")}... + +script(type='text/ng-template', id='deleteTagModalTemplate') + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="cancel()" + ) × + h3 #{translate("delete_folder")} + .modal-body + p #{translate("about_to_delete_folder")} + ul + li + strong {{tag.name}} + .modal-footer + .modal-footer-left + span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")} + button.btn.btn-default( + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-danger( + ng-click="delete()", + ng-disabled="state.inflight" + ) + span(ng-show="state.inflight") #{translate("deleting")}... + span(ng-show="!state.inflight") #{translate("delete")} + +script(type='text/ng-template', id='renameTagModalTemplate') + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="cancel()" + ) × + h3 #{translate("rename_folder")} + .modal-body + form(name="renameTagForm", novalidate) + input.form-control( + type="text", + placeholder="Tag Name", + ng-model="inputs.tagName", + required, + on-enter="rename()", + focus-on="open" + ) + .modal-footer + .modal-footer-left + span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")} + button.btn.btn-default(ng-click="cancel()") #{translate("cancel")} + button.btn.btn-primary( + ng-click="rename()", + ng-disabled="renameTagForm.$invalid || state.inflight" + ) + span(ng-show="!state.inflight") #{translate("rename")} + span(ng-show="state.inflight") #{translate("renaming")}... script(type='text/ng-template', id='renameProjectModalTemplate') .modal-header diff --git a/services/web/app/views/project/list/notifications.jade b/services/web/app/views/project/list/notifications.jade new file mode 100644 index 0000000000..29f371b724 --- /dev/null +++ b/services/web/app/views/project/list/notifications.jade @@ -0,0 +1,15 @@ +span(ng-controller="NotificationsController").userNotifications + ul.list-unstyled.notifications-list( + ng-if="notifications.length > 0", + ng-cloak + ) + li.notification_entry( + ng-repeat="unreadNotification in notifications", + ) + .row(ng-hide="unreadNotification.hide") + .col-xs-12 + .alert.alert-info + span(ng-bind-html="unreadNotification.html") + button(ng-click="dismiss(unreadNotification)").close.pull-right + span(aria-hidden="true") × + span.sr-only #{translate("close")} diff --git a/services/web/app/views/project/list/project-list.jade b/services/web/app/views/project/list/project-list.jade index 41f9b03bcc..9e8c22a1e8 100644 --- a/services/web/app/views/project/list/project-list.jade +++ b/services/web/app/views/project/list/project-list.jade @@ -58,7 +58,7 @@ ) li.dropdown-header #{translate("add_to_folder")} li( - ng-repeat="tag in tags | filter:nonEmpty | orderBy:'name'", + ng-repeat="tag in tags | orderBy:'name'", ng-controller="TagDropdownItemController" ) a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click") @@ -79,7 +79,7 @@ href='#', data-toggle="dropdown", dropdown-toggle - ) #{translate("more")} + ) #{translate("more")} span.caret ul.dropdown-menu.dropdown-menu-right(role="menu") li(ng-show="getFirstSelectedProject().accessLevel == 'owner'") diff --git a/services/web/app/views/project/list/side-bar.jade b/services/web/app/views/project/list/side-bar.jade index c10914719f..a30127840a 100644 --- a/services/web/app/views/project/list/side-bar.jade +++ b/services/web/app/views/project/list/side-bar.jade @@ -37,22 +37,23 @@ ul.list-unstyled.folders-menu( ng-controller="TagListController" ) - li(ng-class="{active: (filter == 'all')}") - a(href, ng-click="filterProjects('all')") #{translate("all_projects")} - li(ng-class="{active: (filter == 'owned')}") - a(href, ng-click="filterProjects('owned')") #{translate("your_projects")} - li(ng-class="{active: (filter == 'shared')}") - a(href, ng-click="filterProjects('shared')") #{translate("shared_with_you")} - li(ng-class="{active: (filter == 'archived')}") - a(href, ng-click="filterProjects('archived')") #{translate("deleted_projects")} + li(ng-class="{active: (filter == 'all')}", ng-click="filterProjects('all')") + a(href) #{translate("all_projects")} + li(ng-class="{active: (filter == 'owned')}", ng-click="filterProjects('owned')") + a(href) #{translate("your_projects")} + li(ng-class="{active: (filter == 'shared')}", ng-click="filterProjects('shared')") + a(href) #{translate("shared_with_you")} + li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')") + a(href) #{translate("deleted_projects")} li h2 #{translate("folders")} - li( - ng-repeat="tag in tags | filter:nonEmpty", + li.tag( + ng-repeat="tag in tags | orderBy:name", ng-class="{active: tag.selected}", - ng-cloak + ng-cloak, + ng-click="selectTag(tag)" ) - a.tag(href, ng-click="selectTag(tag)") + a.tag-name(href) i.icon.fa.fa-fw( ng-class="{\ 'fa-folder-open-o': tag.selected,\ @@ -61,6 +62,23 @@ ) span.name {{tag.name}} span.subdued ({{tag.project_ids.length}}) + span.dropdown.tag-menu(dropdown) + a.dropdown-toggle( + href="#", + data-toggle="dropdown", + dropdown-toggle, + stop-propagation="click" + ) + span.caret + ul.dropdown-menu.dropdown-menu-right( + role="menu" + ) + li + a(href, ng-click="renameTag(tag)", stop-propagation="click") + | #{translate("rename")} + li + a(href, ng-click="deleteTag(tag)", stop-propagation="click") + | #{translate("delete")} li(ng-cloak) a.tag(href, ng-click="openNewTagModal()") i.fa.fa-fw.fa-plus @@ -102,41 +120,51 @@ ) #{translate("complete")} - .row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgrade-reason").text-centered + .row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered span(sixpack-default).text-centered hr p.small #{translate("on_free_sl")} p - a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")} + a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")} p.small.text-centered | #{translate("or_unlock_features_bonus")} a(href="/user/bonus") #{translate("sharing_sl")} . - span(sixpack-when="dropbox").text-centered - hr - .card.card-thin + span(sixpack-when="random").text-centered + span(ng-if="randomView == 'default'") + hr + p.small #{translate("on_free_sl")} p - span Get Dropbox Sync - p - img(src="/img/dropbox/simple_logo.png") - p - a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")} + a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")} p.small.text-centered | #{translate("or_unlock_features_bonus")} a(href="/user/bonus") #{translate("sharing_sl")} . - span(sixpack-when="github").text-centered - hr - .card.card-thin - p - span Get Github Sync - p - img(src="/img/github/octocat.jpg") - p - a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")} - p.small.text-centered - | #{translate("or_unlock_features_bonus")} - a(href="/user/bonus") #{translate("sharing_sl")} . + span(ng-if="randomView == 'dropbox'") + hr + .card.card-thin + p + span Get Dropbox Sync + p + img(src="/img/dropbox/simple_logo.png") + p + a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")} + p.small.text-centered + | #{translate("or_unlock_features_bonus")} + a(href="/user/bonus") #{translate("sharing_sl")} . + + span(ng-if="randomView == 'github'") + hr + .card.card-thin + p + span Get Github Sync + p + img(src="/img/github/octocat.jpg") + p + a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")} + p.small.text-centered + | #{translate("or_unlock_features_bonus")} + a(href="/user/bonus") #{translate("sharing_sl")} . script. window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription} diff --git a/services/web/app/views/sentry.jade b/services/web/app/views/sentry.jade index d1c6115030..9fe5dd3b7d 100644 --- a/services/web/app/views/sentry.jade +++ b/services/web/app/views/sentry.jade @@ -32,8 +32,8 @@ 'EBCallBackMessageReceived', // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx 'conduitPage', - 'NS_ERROR_NOT_CONNECTED:', - "TypeError: Cannot read property 'row' of undefined", + '/NS_ERROR_NOT_CONNECTED/i', + "/Cannot read property 'row' of undefined/i", 'TypeError: start is undefined' ], ignoreUrls: [ @@ -57,7 +57,7 @@ ], shouldSendCallback: function(data) { // only send a fraction of errors - var sampleRate = 1.00; + var sampleRate = 0.01; return (Math.random() <= sampleRate); }, dataCallback: function(data) { diff --git a/services/web/app/views/subscriptions/custom_account.jade b/services/web/app/views/subscriptions/custom_account.jade index 53cf86f3af..483517d873 100644 --- a/services/web/app/views/subscriptions/custom_account.jade +++ b/services/web/app/views/subscriptions/custom_account.jade @@ -8,7 +8,7 @@ block content .card .page-header h1 #{translate("your_subscription")} - div To make changes to your subscription please contact team@sharelatex.com + div To make changes to your subscription please contact accounts@sharelatex.com div   div -if(subscription.groupPlan) diff --git a/services/web/app/views/subscriptions/dashboard.jade b/services/web/app/views/subscriptions/dashboard.jade index 06f801a143..dd3e600aa1 100644 --- a/services/web/app/views/subscriptions/dashboard.jade +++ b/services/web/app/views/subscriptions/dashboard.jade @@ -6,7 +6,7 @@ block scripts window.recomendedCurrency = '#{recomendedCurrency}' window.recurlyApiKey = "!{settings.apis.recurly.publicKey}" window.taxRate = #{taxRate} - + window.subscription = !{JSON.stringify(subscription)} @@ -36,32 +36,32 @@ mixin printPlans(plans) mixin printPlan(plan) block content - .content.content-alt - .container + .content.content-alt(ng-cloak) + .container(ng-controller="UserSubscriptionController") .row .col-md-8.col-md-offset-2 - .card + .card(ng-if="view == 'overview'") .page-header h1 #{translate("your_subscription")} -if (groups.length != 0) each groupSubscription in groups 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")} - else if (subscription) case subscription.state when "free-trial" p !{translate("on_free_trial_expiring_at", {expiresAt:"" + subscription.expiresAt + ""})} - p !{translate("choose_a_plan_below")} + when "active" p !{translate("currently_subscribed_to_plan", {planName:"" + subscription.name + ""})} a(href, ng-click="changePlan = true") !{translate("change_plan")}. p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"" + subscription.price + "", collectionDate:"" + subscription.nextPaymentDueAt + ""})} p.pull-right - - p: form(action="/user/subscription/cancel",method="post") - input(type="hidden", name="_csrf", value=csrfToken) + p a(href="/user/subscription/billing-details/edit").btn.btn-info #{translate("update_your_billing_details")} |   - input(type="submit", value="Cancel your subscription").btn.btn-primary#cancelSubscription + a(href, ng-click="switchToCancelationView()").btn.btn-primary !{translate("cancel_your_subscription")} when "canceled" p !{translate("currently_subscribed_to_plan", {planName:"" + subscription.name + ""})} p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"" + subscription.nextPaymentDueAt + ""})} @@ -76,7 +76,7 @@ block content p !{translate("problem_with_subscription_contact_us")} -if(subscription.groupPlan) - a(href="/subscription/group").btn.btn-success !{translate("manage_group")} + a(href="/subscription/group").btn.btn-primary !{translate("manage_group")} @@ -105,17 +105,50 @@ block content mixin printPlans(plans.individualMonthlyPlans) mixin printPlans(plans.individualAnnualPlans) + .card(ng-if="view == 'cancelation'") + .page-header + h1 #{translate("Cancel Subscription")} + span(ng-if="sixpackOpt == 'downgrade-options'") + + div(ng-show="showExtendFreeTrial", style="text-align: center") + p !{translate("have_more_days_to_try", {days:14})} + button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")} + p + |   + p + a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")} + + + div(ng-show="showDowngradeToStudent", style="text-align: center") + span(ng-controller="ChangePlanFormController") + p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})} + button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")} + p + |   + p + a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")} + + div(ng-show="showBasicCancel") + p #{translate("sure_you_want_to_cancel")} + a(href="/project").btn.btn-info #{translate("i_want_to_stay")} + |   + a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")} + + span(ng-if="sixpackOpt == 'basic'") + p #{translate("sure_you_want_to_cancel")} + a(href="/project").btn.btn-info #{translate("i_want_to_stay")} + |   + a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")} script(type="text/javascript"). $('#cancelSubscription').on("click", function() { ga('send', 'event', 'subscription-funnel', 'cancelation') }) - script(type='text/ng-template', id='confirmChangePlanModalTemplate') .modal-header - h3 Change plan? + h3 #{translate("change_plan")} .modal-body p !{translate("sure_you_want_to_change_plan", {planName:"{{plan.name}}"})} .modal-footer @@ -131,4 +164,22 @@ block content span(ng-show="inflight") #{translate("processing")}... + script(type='text/ng-template', id='LeaveGroupModalTemplate') + .modal-header + h3 #{translate("leave_group")} + .modal-body + p #{translate("sure_you_want_to_leave_group")} + .modal-footer + button.btn.btn-default( + ng-disabled="inflight" + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-danger( + ng-disabled="state.inflight" + ng-click="confirmLeaveGroup()" + ) + span(ng-hide="inflight") #{translate("leave_now")} + span(ng-show="inflight") #{translate("processing")}... + + diff --git a/services/web/app/views/subscriptions/new.jade b/services/web/app/views/subscriptions/new.jade index 564d356b2b..b70aec29fc 100644 --- a/services/web/app/views/subscriptions/new.jade +++ b/services/web/app/views/subscriptions/new.jade @@ -112,7 +112,6 @@ block content .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''") select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year') option(value="", disabled, selected) Year - option(value="2015") 2015 option(value="2016") 2016 option(value="2017") 2017 option(value="2018") 2018 diff --git a/services/web/app/views/subscriptions/plans.jade b/services/web/app/views/subscriptions/plans.jade index f085c5b5c1..d451c94540 100644 --- a/services/web/app/views/subscriptions/plans.jade +++ b/services/web/app/views/subscriptions/plans.jade @@ -3,6 +3,12 @@ block scripts script(type='text/javascript'). window.recomendedCurrency = '#{recomendedCurrency}' window.abCurrencyFlag = '#{abCurrencyFlag}' + + script(type='text/javascript'). + (function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; + s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js'; + var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})(); + block content .content-alt .content.plans(ng-controller="PlansController") @@ -186,30 +192,30 @@ block content .modal-header h3 #{translate("group_plan_enquiry")} .modal-body - form(name='form1', autocomplete='off', enctype='multipart/form-data', method='post', novalidate='', action='https://sharelatex.wufoo.com/forms/z7x3p3/#public', _lpchecked='1') - .form-group - label(for='Field9') #{translate("name")} - input.form-control(name='Field9', type='text', value='', maxlength='255', tabindex='1', onkeyup='') - - .form-group - label(for='Field11') #{translate("email")} - input.form-control(name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') - - .form-group - label(for='Field12') #{translate("university")} - input.form-control(name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') - - .form-group - label(for='Field13') #{translate("position")} - input.form-control(name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') - - .form-group - input.btn.btn-primary.btn-large(name='saveForm', type='submit', value='Send') - div(style='display: none;') - label(for='comment') Do Not Fill This Out - textarea#comment(name='comment', rows='1', cols='1') - input#idstamp(type='hidden', name='idstamp', value='xkgLkZnS/AQW71jCS1d0XrrFjq26lJryIPVk2rx0YkU=') - + form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak) + span(ng-show="sent == false") + .form-group + label#title9(for='Field9') + | Name + input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='') + label#title11.desc(for='Field11') + | Email + .form-group + input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + label#title12.desc(for='Field12') + | University / Company + .form-group + input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') + label#title13.desc(for='Field13') + | Position + .form-group + input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') + .form-group + input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'ShareLaTeX for Universities';") + .form-group.text-center + input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') + span(ng-show="sent") + p Request Sent, Thank you. .row .col-md-12 diff --git a/services/web/app/views/subscriptions/successful_subscription.jade b/services/web/app/views/subscriptions/successful_subscription.jade index 8b66dec6c9..86b0263238 100644 --- a/services/web/app/views/subscriptions/successful_subscription.jade +++ b/services/web/app/views/subscriptions/successful_subscription.jade @@ -5,14 +5,18 @@ block content .container .row .col-md-8.col-md-offset-2 - .card + .card(ng-cloak) .page-header h2 #{translate("thanks_for_subscribing")} .alert.alert-success p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:""+subscription.price+"", collectionDate:""+subscription.nextPaymentDueAt+""})} - p #{translate("if_you_dont_want_to_be_charged")} - a(href="/user/subscription") #{translate("click_here_to_cancel")}. - + span(sixpack-switch="upgrade-success-message") + span(sixpack-default) + p #{translate("if_you_dont_want_to_be_charged")} + a(href="/user/subscription") #{translate("click_here_to_cancel")}. + span(sixpack-when="manage-subscription") + p #{translate("to_modify_your_subscription_go_to")} + a(href="/user/subscription") #{translate("manage_subscription")}. p - if (subscription.groupPlan == true) a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")} diff --git a/services/web/app/views/user/activate.jade b/services/web/app/views/user/activate.jade new file mode 100644 index 0000000000..7961876389 --- /dev/null +++ b/services/web/app/views/user/activate.jade @@ -0,0 +1,64 @@ +extends ../layout + +block content + .content.content-alt + .container + .row + .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .alert.alert-success #{translate("nearly_activated")} + .row + .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .card + .page-header + h1 #{translate("please_set_a_password")} + form( + async-form="activate", + name="activationForm", + action="/user/password/set", + method="POST", + ng-cloak + ) + input(name='_csrf', type='hidden', value=csrfToken) + input( + type="hidden", + name="passwordResetToken", + value=token + ) + input(name='login_after', type='hidden', value="true") + .alert.alert-danger(ng-show="activationForm.response.error") + | #{translate("activation_token_expired")} + + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + ng-model="email", + ng-init="email = #{JSON.stringify(email)}", + ng-model-options="{ updateOn: 'blur' }", + disabled + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password', + name='password', + placeholder="********", + required, + ng-model="password", + complex-password, + focus="true" + ) + span.small.text-primary(ng-show="activationForm.password.$error.complexPassword", ng-bind-html="complexPasswordErrorMessage") + .actions + button.btn-primary.btn( + type='submit' + ng-disabled="activationForm.inflight || activationForm.password.$error.required|| activationForm.password.$error.complexPassword" + ) + span(ng-show="!activationForm.inflight") #{translate("activate")} + span(ng-show="activationForm.inflight") #{translate("activating")}... + + script(type='text/javascript'). + window.passwordStrengthOptions = !{JSON.stringify(settings.passwordStrengthOptions || {})} diff --git a/services/web/app/views/user/login.jade b/services/web/app/views/user/login.jade index 4d8848d8a6..a6587782bf 100644 --- a/services/web/app/views/user/login.jade +++ b/services/web/app/views/user/login.jade @@ -20,6 +20,7 @@ block content placeholder='email@example.com', ng-model="email", ng-model-options="{ updateOn: 'blur' }", + 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/setPassword.jade b/services/web/app/views/user/setPassword.jade index 9da2beb065..3a6a588f34 100644 --- a/services/web/app/views/user/setPassword.jade +++ b/services/web/app/views/user/setPassword.jade @@ -16,10 +16,11 @@ block content ng-cloak ) input(type="hidden", name="_csrf", value=csrfToken) - form-messages(for="passwordResetForm") - .alert.alert-success(ng-show="passwordResetForm.response.success") - | #{translate("password_has_been_reset")}. - a(href='/login') #{translate("login_here")} + .alert.alert-success(ng-show="passwordResetForm.response.success") + | #{translate("password_has_been_reset")}. + a(href='/login') #{translate("login_here")} + .alert.alert-danger(ng-show="passwordResetForm.response.error") + | #{translate("password_reset_token_expired")} .form-group input.form-control#passwordField( diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 73677b2ecc..3f73439cd0 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -106,6 +106,10 @@ module.exports = url: "http://localhost:3036" sixpack: url: "" + references: + url: "http://localhost:3040" + notifications: + url: "http://localhost:3042" templates: user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2" @@ -125,6 +129,9 @@ module.exports = # Same, but with http auth credentials. httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000' + + maxEntitiesPerProject: 2000 + # Security # -------- security: @@ -143,6 +150,8 @@ module.exports = versioning: true compileTimeout: 60 compileGroup: "standard" + references: true + templates: true plans: plans = [{ planCode: "personal" @@ -291,7 +300,7 @@ module.exports = title: "ShareLaTeX Community Edition" left_footer: [{ - text: "Powered by ShareLaTeX © 2015" + text: "Powered by ShareLaTeX © 2016" }] right_footer: [{ diff --git a/services/web/package.json b/services/web/package.json index 8f31c75b7f..23ac125ef1 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -27,18 +27,23 @@ "http-proxy": "^1.8.1", "jade": "~1.3.1", "ldapjs": "^0.7.1", +<<<<<<< HEAD "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master", +======= + "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#v1.3.1", +>>>>>>> master "lynx": "0.1.1", "marked": "^0.3.3", "method-override": "^2.3.3", - "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.2.0", + "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0", "mimelib": "0.2.14", "mocha": "1.17.1", "mongojs": "0.18.2", "mongoose": "4.1.0", "multer": "^0.1.8", "node-uuid": "1.4.1", - "nodemailer": "0.6.1", + "nodemailer": "2.1.0", + "nodemailer-ses-transport": "^1.3.0", "optimist": "0.6.1", "redback": "0.4.0", "redis": "0.10.1", diff --git a/services/web/public/coffee/directives/fineUpload.coffee b/services/web/public/coffee/directives/fineUpload.coffee index 2fad1acf17..92cdf720f4 100644 --- a/services/web/public/coffee/directives/fineUpload.coffee +++ b/services/web/public/coffee/directives/fineUpload.coffee @@ -16,7 +16,11 @@ define [ onUploadCallback: "=" onValidateBatch: "=" onErrorCallback: "=" + onSubmitCallback: "=" + onCancelCallback: "=" + autoUpload: "=" params: "=" + control: "=" } link: (scope, element, attrs) -> multiple = scope.multiple or false @@ -32,19 +36,27 @@ define [ uploadButton: scope.uploadButtonText or "Upload" dragAreaText = scope.dragAreaText or "drag here" hintText = scope.hintText or "" - + maxConnections = scope.maxConnections or 1 onComplete = scope.onCompleteCallback or () -> onUpload = scope.onUploadCallback or () -> onError = scope.onErrorCallback or () -> onValidateBatch = scope.onValidateBatch or () -> + onSubmit = scope.onSubmitCallback or () -> + onCancel = scope.onCancelCallback or () -> + if !scope.autoUpload? + autoUpload = true + else + autoUpload = scope.autoUpload params = scope.params or {} params._csrf = window.csrfToken q = new qq.FineUploader element: element[0] multiple: multiple + autoUpload: autoUpload disabledCancelForFormUploads: true validation: validation + maxConnections: maxConnections request: endpoint: endpoint forceMultipart: true @@ -55,6 +67,8 @@ define [ onUpload: onUpload onValidateBatch: onValidateBatch onError: onError + onSubmit: onSubmit + onCancel: onCancel text: text template: """
@@ -69,5 +83,7 @@ define [
""" + window.q = q + scope.control?.q = q return q } \ No newline at end of file diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 0ea6165835..161289d055 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -8,6 +8,7 @@ define [ "ide/permissions/PermissionsManager" "ide/pdf/PdfManager" "ide/binary-files/BinaryFilesManager" + "ide/references/ReferencesManager" "ide/settings/index" "ide/share/index" "ide/chat/index" @@ -24,6 +25,7 @@ define [ "directives/onEnter" "directives/stopPropagation" "directives/rightClick" + "services/queued-http" "filters/formatDate" "main/event" "main/account-upgrade" @@ -37,6 +39,7 @@ define [ PermissionsManager PdfManager BinaryFilesManager + ReferencesManager ) -> App.controller "IdeController", ($scope, $timeout, ide, localStorage) -> @@ -66,12 +69,13 @@ define [ $scope.chat = {} - + window._ide = ide ide.project_id = $scope.project_id = window.project_id ide.$scope = $scope + ide.referencesSearchManager = new ReferencesManager(ide, $scope) ide.connectionManager = new ConnectionManager(ide, $scope) ide.fileTreeManager = new FileTreeManager(ide, $scope) ide.editorManager = new EditorManager(ide, $scope) @@ -80,7 +84,7 @@ define [ ide.pdfManager = new PdfManager(ide, $scope) ide.permissionsManager = new PermissionsManager(ide, $scope) ide.binaryFilesManager = new BinaryFilesManager(ide, $scope) - + inited = false $scope.$on "project:joined", () -> return if inited @@ -91,7 +95,7 @@ define [ We don't want to delete your data on ShareLaTeX, so this project still contains your history and collaborators. If the project has been renamed please look in your project list for a new project under the new name. """) - + DARK_THEMES = [ "ambiance", "chaos", "clouds_midnight", "cobalt", "idle_fingers", "merbivore", "merbivore_soft", "mono_industrial", "monokai", diff --git a/services/web/public/coffee/ide/directives/layout.coffee b/services/web/public/coffee/ide/directives/layout.coffee index 3421b0a613..146952dc23 100644 --- a/services/web/public/coffee/ide/directives/layout.coffee +++ b/services/web/public/coffee/ide/directives/layout.coffee @@ -37,8 +37,12 @@ define [ # Restore previously recorded state if (state = ide.localStorage("layout.#{name}"))? - options.west = state.west - options.east = state.east + if state.east? + if !attrs.minimumRestoreSizeEast? or (state.east.size >= attrs.minimumRestoreSizeEast and !state.east.initClosed) + options.east = state.east + if state.west? + if !attrs.minimumRestoreSizeWest? or (state.west.size >= attrs.minimumRestoreSizeWest and !state.west.initClosed) + options.west = state.west repositionControls = () -> state = element.layout().readState() diff --git a/services/web/public/coffee/ide/editor/Document.coffee b/services/web/public/coffee/ide/editor/Document.coffee index 1927b791da..e00573aef1 100644 --- a/services/web/public/coffee/ide/editor/Document.coffee +++ b/services/web/public/coffee/ide/editor/Document.coffee @@ -36,6 +36,7 @@ define [ @doc?.detachFromAce() editorDoc = @ace?.getSession().getDocument() editorDoc?.off "change", @_checkConsistency + @ide.$scope.$emit 'document:closed', @doc _checkConsistency: () -> # We've been seeing a lot of errors when I think there shouldn't be @@ -116,6 +117,26 @@ define [ flush: () -> @doc?.flushPendingOps() + chaosMonkey: (line = 0, char = "a") -> + orig = char + copy = null + pos = 0 + timer = () => + unless copy? and copy.length + copy = orig.slice() + ' ' + new Date() + '\n' + line += if Math.random() > 0.1 then 1 else -2 + line = 0 if line < 0 + pos = 0 + char = copy[0] + copy = copy.slice(1) + @ace.session.insert({row: line, column: pos}, char) + pos += 1 + @_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0 + @_cm = timer() + + clearChaosMonkey: () -> + clearTimeout @_cm + pollSavedStatus: () -> # returns false if doc has ops waiting to be acknowledged or # sent that haven't changed since the last time we checked. diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index cdc2531568..e31955ef46 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -86,6 +86,7 @@ define [ @_bindToDocumentEvents(doc, new_sharejs_doc) callback null, new_sharejs_doc + _bindToDocumentEvents: (doc, sharejs_doc) -> sharejs_doc.on "error", (error, meta) => if error?.message?.match "maxDocLength" @@ -98,7 +99,7 @@ define [ @ide.reportError(error, meta) @ide.showGenericMessageModal( "Out of sync" - "Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently." + "Sorry, this file has gone out of sync and we need to do a full refresh.
Please see this help guide for more information" ) @openDoc(doc, forceReopen: true) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 3b9b81fae4..596f350812 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -18,7 +18,7 @@ define [ url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}" return url - App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage) -> + App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory) -> monkeyPatchSearch($rootScope, $compile) return { @@ -29,6 +29,7 @@ define [ fontSize: "=" autoComplete: "=" sharejsDoc: "=" + spellCheck: "=" spellCheckLanguage: "=" highlights: "=" text: "=" @@ -55,7 +56,9 @@ define [ scope.name = attrs.aceEditor autoCompleteManager = new AutoCompleteManager(scope, editor, element) - spellCheckManager = new SpellCheckManager(scope, editor, element) + if scope.spellCheck # only enable spellcheck when explicitly required + spellCheckCache = $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000}) + spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache) undoManager = new UndoManager(scope, editor, element) highlightsManager = new HighlightsManager(scope, editor, element) cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage) @@ -69,6 +72,21 @@ define [ editor.commands.removeCommand "transposeletters" editor.commands.removeCommand "showSettingsMenu" editor.commands.removeCommand "foldall" + + # For European keyboards, the / is above 7 so needs Shift pressing. + # This comes through as Ctrl-Shift-/ which is mapped to toggleBlockComment. + # This doesn't do anything for LaTeX, so remap this to togglecomment to + # work for European keyboards as normal. + editor.commands.removeCommand "toggleBlockComment" + editor.commands.removeCommand "togglecomment" + + editor.commands.addCommand { + name: "togglecomment", + bindKey: { win: "Ctrl-/|Ctrl-Shift-/", mac: "Command-/|Command-Shift-/" }, + exec: (editor) -> editor.toggleCommentLines(), + multiSelectAction: "forEachLine", + scrollIntoView: "selectionPart" + } # Trigger search AND replace on CMD+F editor.commands.addCommand @@ -77,7 +95,6 @@ define [ exec: (editor) -> ace.require("ace/ext/searchbox").Search(editor, true) readOnly: true - editor.commands.removeCommand "replace" # Bold text on CMD+B editor.commands.addCommand diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee index 206c2323e0..7b4889a81e 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee @@ -16,7 +16,7 @@ define [ constructor: (@$scope, @editor) -> @suggestionManager = new SuggestionManager() - @monkeyPatchAutocomplete() + @monkeyPatchAutocomplete() @$scope.$watch "autoComplete", (autocomplete) => if autocomplete @@ -37,11 +37,47 @@ define [ enableSnippets: true, enableLiveAutocompletion: false }) - + SnippetCompleter = getCompletions: (editor, session, pos, prefix, callback) -> callback null, Snippets - @editor.completers = [@suggestionManager, SnippetCompleter] + + references = @$scope.$root._references + ReferencesCompleter = + getCompletions: (editor, session, pos, prefix, callback) -> + range = new Range(pos.row, 0, pos.row, pos.column) + lineUpToCursor = editor.getSession().getTextRange(range) + commandFragment = getLastCommandFragment(lineUpToCursor) + if commandFragment + citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]?){(.*,)?(\w*)/) + if citeMatch + commandName = citeMatch[1] + previousArgs = citeMatch[2] + currentArg = citeMatch[3] + if previousArgs == undefined + previousArgs = "" + previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs + result = [] + result.push { + caption: "\\#{commandName}{", + snippet: "\\#{commandName}{", + meta: "reference", + score: 11000 + } + if references.keys and references.keys.length > 0 + references.keys.forEach (key) -> + if !(key in [null, undefined]) + result.push({ + caption: "\\#{commandName}{#{previousArgsCaption}#{key}", + value: "\\#{commandName}{#{previousArgs}#{key}", + meta: "reference", + score: 10000 + }) + callback null, result + else + callback null, result + + @editor.completers = [@suggestionManager, SnippetCompleter, ReferencesCompleter] disable: () -> @editor.setOptions({ @@ -83,7 +119,7 @@ define [ # since it will be adding in with the autocomplete of \begin{item}... if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}" editor.session.remove(range) - + Autocomplete::_insertMatch.call this, data # Overwrite this to set autoInsert = false and set font size diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee index 53e0f57fa1..4bc3d16ef4 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee @@ -14,7 +14,7 @@ define () -> caption: "\\begin{#{env}}..." snippet: """ \\begin{#{env}} - $1 + \t$1 \\end{#{env}} """ meta: "env" @@ -24,8 +24,8 @@ define () -> caption: "\\begin{array}..." snippet: """ \\begin{array}{${1:cc}} - $2 & $3 \\\\\\\\ - $4 & $5 + \t$2 & $3 \\\\\\\\ + \t$4 & $5 \\end{array} """ meta: "env" @@ -33,10 +33,10 @@ define () -> caption: "\\begin{figure}..." snippet: """ \\begin{figure} - \\centering - \\includegraphics{$1} - \\caption{${2:Caption}} - \\label{${3:fig:my_label}} + \t\\centering + \t\\includegraphics{$1} + \t\\caption{${2:Caption}} + \t\\label{${3:fig:my_label}} \\end{figure} """ meta: "env" @@ -44,8 +44,8 @@ define () -> caption: "\\begin{tabular}..." snippet: """ \\begin{tabular}{${1:c|c}} - $2 & $3 \\\\\\\\ - $4 & $5 + \t$2 & $3 \\\\\\\\ + \t$4 & $5 \\end{tabular} """ meta: "env" @@ -53,13 +53,13 @@ define () -> caption: "\\begin{table}..." snippet: """ \\begin{table}[$1] - \\centering - \\begin{tabular}{${2:c|c}} - $3 & $4 \\\\\\\\ - $5 & $6 - \\end{tabular} - \\caption{${7:Caption}} - \\label{${8:tab:my_label}} + \t\\centering + \t\\begin{tabular}{${2:c|c}} + \t\t$3 & $4 \\\\\\\\ + \t\t$5 & $6 + \t\\end{tabular} + \t\\caption{${7:Caption}} + \t\\label{${8:tab:my_label}} \\end{table} """ meta: "env" @@ -67,7 +67,7 @@ define () -> caption: "\\begin{list}..." snippet: """ \\begin{list} - \\item $1 + \t\\item $1 \\end{list} """ meta: "env" @@ -75,7 +75,7 @@ define () -> caption: "\\begin{enumerate}..." snippet: """ \\begin{enumerate} - \\item $1 + \t\\item $1 \\end{enumerate} """ meta: "env" @@ -83,7 +83,7 @@ define () -> caption: "\\begin{itemize}..." snippet: """ \\begin{itemize} - \\item $1 + \t\\item $1 \\end{itemize} """ meta: "env" @@ -91,7 +91,7 @@ define () -> caption: "\\begin{frame}..." snippet: """ \\begin{frame}{${1:Frame Title}} - $2 + \t$2 \\end{frame} """ meta: "env" 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 68d45ac6e8..95a6519d59 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 @@ -5,7 +5,7 @@ define [ Range = ace.require("ace/range").Range class SpellCheckManager - constructor: (@$scope, @editor, @element) -> + constructor: (@$scope, @editor, @element, @cache) -> $(document.body).append @element.find(".spell-check-menu") @updatedLines = [] @@ -102,6 +102,8 @@ define [ learnWord: (highlight) -> @apiRequest "/learn", word: highlight.word @highlightedWordManager.removeWord highlight.word + language = @$scope.spellCheckLanguage + @cache?.put("#{language}:#{highlight.word}", true) getHighlightedWordAtCursor: () -> cursor = @editor.getCursorPosition() @@ -143,24 +145,67 @@ define [ runSpellCheck: (linesToProcess) -> {words, positions} = @getWords(linesToProcess) language = @$scope.spellCheckLanguage - @apiRequest "/check", {language: language, words: words}, (error, result) => - if error? or !result? or !result.misspellings? - return null + highlights = [] + seen = {} + newWords = [] + newPositions = [] + + # iterate through all words, building up a list of + # newWords/newPositions not in the cache + for word, i in words + key = "#{language}:#{word}" + seen[key] ?= @cache.get(key) # avoid hitting the cache unnecessarily + cached = seen[key] + if not cached? + newWords.push words[i] + newPositions.push positions[i] + else if cached is true + # word is correct + else + highlights.push + column: positions[i].column + row: positions[i].row + word: word + suggestions: cached + words = newWords + positions = newPositions + + displayResult = (highlights) => if linesToProcess? for shouldProcess, row in linesToProcess @highlightedWordManager.clearRows(row, row) if shouldProcess else @highlightedWordManager.clearRows() + for highlight in highlights + @highlightedWordManager.addHighlight highlight - for misspelling in result.misspellings - word = words[misspelling.index] - position = positions[misspelling.index] - @highlightedWordManager.addHighlight - column: position.column - row: position.row - word: word - suggestions: misspelling.suggestions + if not words.length + displayResult highlights + else + @apiRequest "/check", {language: language, words: words}, (error, result) => + if error? or !result? or !result.misspellings? + return null + mispelled = [] + for misspelling in result.misspellings + word = words[misspelling.index] + position = positions[misspelling.index] + mispelled[misspelling.index] = true + highlights.push + column: position.column + row: position.row + word: word + suggestions: misspelling.suggestions + key = "#{language}:#{word}" + if not seen[key] + @cache.put key, misspelling.suggestions + seen[key] = true + for word, i in words when not mispelled[i] + key = "#{language}:#{word}" + if not seen[key] + @cache.put(key, true) + seen[key] = true + displayResult highlights getWords: (linesToProcess) -> lines = @editor.getValue().split("\n") diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index 9ea27ae62c..8c49d54c23 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -19,6 +19,12 @@ define [ @recalculateDocList() @_bindToSocketEvents() + + @$scope.multiSelectedCount = 0 + + $(document).on "click", => + @clearMultiSelectedEntities() + $scope.$digest() _bindToSocketEvents: () -> @ide.socket.on "reciveNewDoc", (parent_folder_id, doc) => @@ -65,6 +71,7 @@ define [ @$scope.$apply () => @_deleteEntityFromScope entity @recalculateDocList() + @$scope.$emit "entity:deleted", entity @ide.socket.on "reciveEntityMove", (entity_id, folder_id) => entity = @findEntityById(entity_id) @@ -78,7 +85,62 @@ define [ @ide.fileTreeManager.forEachEntity (entity) -> entity.selected = false entity.selected = true + + toggleMultiSelectEntity: (entity) -> + entity.multiSelected = !entity.multiSelected + @$scope.multiSelectedCount = @multiSelectedCount() + + multiSelectedCount: () -> + count = 0 + @forEachEntity (entity) -> + if entity.multiSelected + count++ + return count + + getMultiSelectedEntities: () -> + entities = [] + @forEachEntity (e) -> + if e.multiSelected + entities.push e + return entities + + getMultiSelectedEntityChildNodes: () -> + entities = @getMultiSelectedEntities() + paths = {} + for entity in entities + paths[@getEntityPath(entity)] = entity + prefixes = {} + for path, entity of paths + parts = path.split("/") + if parts.length <= 1 + continue + else + # Record prefixes a/b/c.tex -> 'a' and 'a/b' + for i in [1..(parts.length - 1)] + prefixes[parts.slice(0,i).join("/")] = true + child_entities = [] + for path, entity of paths + # If the path is in the prefixes, then it's a parent folder and + # should be ignore + if !prefixes[path]? + child_entities.push entity + return child_entities + + clearMultiSelectedEntities: () -> + return if @$scope.multiSelectedCount == 0 # Be efficient, this is called a lot on 'click' + @forEachEntity (entity) -> + entity.multiSelected = false + @$scope.multiSelectedCount = 0 + + multiSelectSelectedEntity: () -> + @findSelectedEntity()?.multiSelected = true + existsInFolder: (folder_id, name) -> + folder = @findEntityById(folder_id) + return false if !folder? + entity = @_findEntityByPathInFolder(folder, name) + return entity? + findSelectedEntity: () -> selected = null @forEachEntity (entity) -> @@ -277,7 +339,7 @@ define [ deleteEntity: (entity, callback = (error) ->) -> # We'll wait for the socket.io notification to # delete from scope. - return @ide.$http { + return @ide.queuedHttp { method: "DELETE" url: "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}" headers: @@ -289,7 +351,7 @@ define [ # since that would break the tree structure. return if @_isChildFolder(entity, parent_folder) @_moveEntityInScope(entity, parent_folder) - return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", { + return @ide.queuedHttp.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", { folder_id: parent_folder.id _csrf: window.csrfToken } @@ -316,8 +378,6 @@ define [ entity.deleted = true @$scope.deletedDocs.push entity - @$scope.$emit "entity:deleted", entity - _moveEntityInScope: (entity, parent_folder) -> return if entity in parent_folder.children @_deleteEntityFromScope(entity, moveToDeleted: false) diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 489c8e200d..cdc261053f 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -61,6 +61,8 @@ define [ $scope.state.inflight = true ide.fileTreeManager .createDoc(name, parent_folder) + .error (e)-> + $scope.error = e .success () -> $scope.state.inflight = false $modalInstance.close() @@ -90,6 +92,8 @@ define [ $scope.state.inflight = true ide.fileTreeManager .createFolder(name, parent_folder) + .error (e)-> + $scope.error = e .success () -> $scope.state.inflight = false $modalInstance.close() @@ -99,17 +103,30 @@ define [ ] App.controller "UploadFileModalController", [ - "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", - ($scope, ide, $modalInstance, $timeout, parent_folder) -> + "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", "$window" + ($scope, ide, $modalInstance, $timeout, parent_folder, $window) -> $scope.parent_folder_id = parent_folder?.id $scope.tooManyFiles = false $scope.rateLimitHit = false + $scope.secondsToRedirect = 10 + $scope.notLoggedIn = false + $scope.conflicts = [] + $scope.control = {} - uploadCount = 0 - $scope.onUpload = () -> - uploadCount++ + needToLogBackIn = -> + $scope.notLoggedIn = true + decreseTimeout = -> + $timeout (() -> + if $scope.secondsToRedirect == 0 + $window.location.href = "/login?redir=/project/#{ide.project_id}" + else + decreseTimeout() + $scope.secondsToRedirect = $scope.secondsToRedirect - 1 + ), 1000 - $scope.max_files = 20 + decreseTimeout() + + $scope.max_files = 40 $scope.onComplete = (error, name, response) -> $timeout (() -> uploadCount-- @@ -127,8 +144,40 @@ define [ return true $scope.onError = (id, name, reason)-> + console.log(id, name, reason) if reason.indexOf("429") != -1 $scope.rateLimitHit = true + else if reason.indexOf("403") != -1 + needToLogBackIn() + + _uploadTimer = null + uploadIfNoConflicts = () -> + if $scope.conflicts.length == 0 + $scope.doUpload() + + uploadCount = 0 + $scope.onSubmit = (id, name) -> + uploadCount++ + if ide.fileTreeManager.existsInFolder($scope.parent_folder_id, name) + $scope.conflicts.push name + $scope.$apply() + if !_uploadTimer? + _uploadTimer = setTimeout () -> + _uploadTimer = null + uploadIfNoConflicts() + , 0 + return true + + $scope.onCancel = (id, name) -> + uploadCount-- + index = $scope.conflicts.indexOf(name) + if index > -1 + $scope.conflicts.splice(index, 1) + $scope.$apply() + uploadIfNoConflicts() + + $scope.doUpload = () -> + $scope.control?.q?.uploadStoredFiles() $scope.cancel = () -> $modalInstance.dismiss('cancel') diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee index 9c46afc967..b5c96408c7 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee @@ -2,9 +2,23 @@ define [ "base" ], (App) -> App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) -> - $scope.select = () -> - ide.fileTreeManager.selectEntity($scope.entity) - $scope.$emit "entity:selected", $scope.entity + $scope.select = (e) -> + if e.ctrlKey or e.metaKey + e.stopPropagation() + initialMultiSelectCount = ide.fileTreeManager.multiSelectedCount() + ide.fileTreeManager.toggleMultiSelectEntity($scope.entity) == 0 + if initialMultiSelectCount == 0 + # On first multi selection, also include the current active/open file. + ide.fileTreeManager.multiSelectSelectedEntity() + else + ide.fileTreeManager.selectEntity($scope.entity) + $scope.$emit "entity:selected", $scope.entity + + $scope.draggableHelper = () -> + if ide.fileTreeManager.multiSelectedCount() > 0 + return $("#{ide.fileTreeManager.multiSelectedCount()} Files") + else + return $("#{$scope.entity.name}") $scope.inputs = name: $scope.entity.name @@ -24,10 +38,15 @@ define [ $scope.startRenaming() if $scope.entity.selected $scope.openDeleteModal = () -> + if ide.fileTreeManager.multiSelectedCount() > 0 + entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes() + else + entities = [$scope.entity] $modal.open( templateUrl: "deleteEntityModalTemplate" controller: "DeleteEntityModalController" - scope: $scope + resolve: + entities: () -> entities ) $scope.$on "delete:selected", () -> @@ -35,18 +54,18 @@ define [ ] App.controller "DeleteEntityModalController", [ - "$scope", "ide", "$modalInstance", - ($scope, ide, $modalInstance) -> + "$scope", "ide", "$modalInstance", "entities" + ($scope, ide, $modalInstance, entities) -> $scope.state = inflight: false + + $scope.entities = entities $scope.delete = () -> $scope.state.inflight = true - ide.fileTreeManager - .deleteEntity($scope.entity) - .success () -> - $scope.state.inflight = false - $modalInstance.close() + for entity in $scope.entities + ide.fileTreeManager.deleteEntity(entity) + $modalInstance.close() $scope.cancel = () -> $modalInstance.dismiss('cancel') diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee index 943168090c..7e16e7852e 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee @@ -9,11 +9,15 @@ define [ localStorage("folder.#{$scope.entity.id}.expanded", $scope.expanded) $scope.onDrop = (events, ui) -> - source = $(ui.draggable).scope().entity - return if !source? - # clear highlight explicitely + if ide.fileTreeManager.multiSelectedCount() + entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes() + else + entities = [$(ui.draggable).scope().entity] + for dropped_entity in entities + ide.fileTreeManager.moveEntity(dropped_entity, $scope.entity) + $scope.$digest() + # clear highlight explicitly $('.file-tree-inner .droppable-hover').removeClass('droppable-hover') - ide.fileTreeManager.moveEntity(source, $scope.entity) $scope.orderByFoldersFirst = (entity) -> # We need this here as well as in FileTreeController diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee index be72db01cf..34576c205b 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee @@ -4,7 +4,13 @@ define [ App.controller "FileTreeRootFolderController", ["$scope", "ide", ($scope, ide) -> rootFolder = $scope.rootFolder $scope.onDrop = (events, ui) -> - source = $(ui.draggable).scope().entity - return if !source? - ide.fileTreeManager.moveEntity(source, rootFolder) + if ide.fileTreeManager.multiSelectedCount() + entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes() + else + entities = [$(ui.draggable).scope().entity] + for dropped_entity in entities + ide.fileTreeManager.moveEntity(dropped_entity, rootFolder) + $scope.$digest() + # clear highlight explicitly + $('.file-tree-inner .droppable-hover').removeClass('droppable-hover') ] diff --git a/services/web/public/coffee/ide/file-tree/directives/draggable.coffee b/services/web/public/coffee/ide/file-tree/directives/draggable.coffee index 4b08a0daf6..8b37c9957e 100644 --- a/services/web/public/coffee/ide/file-tree/directives/draggable.coffee +++ b/services/web/public/coffee/ide/file-tree/directives/draggable.coffee @@ -9,6 +9,6 @@ define [ element.draggable delay: 250 opacity: 0.7 - helper: "clone" scroll: true + helper: scope.$eval(attrs.draggableHelper) } \ No newline at end of file diff --git a/services/web/public/coffee/ide/file-tree/directives/droppable.coffee b/services/web/public/coffee/ide/file-tree/directives/droppable.coffee index 890e345c23..7815a60d64 100644 --- a/services/web/public/coffee/ide/file-tree/directives/droppable.coffee +++ b/services/web/public/coffee/ide/file-tree/directives/droppable.coffee @@ -9,6 +9,7 @@ define [ element.droppable greedy: true hoverClass: "droppable-hover" + tolerance: "pointer" accept: attrs.accept drop: scope.$eval(attrs.onDropCallback) } \ No newline at end of file diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index a87ef79060..8586d218a7 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -1,7 +1,8 @@ define [ "base" "libs/latex-log-parser" -], (App, LogParser) -> + "libs/bib-log-parser" +], (App, LogParser, BibLogParser) -> App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) -> autoCompile = true $scope.$on "project:joined", () -> @@ -13,12 +14,18 @@ define [ $scope.$on "pdf:error:display", () -> $scope.pdf.error = true + $scope.draft = localStorage("draft:#{$scope.project_id}") or false + $scope.$watch "draft", (new_value, old_value) -> + if new_value? and old_value != new_value + localStorage("draft:#{$scope.project_id}", new_value) + sendCompileRequest = (options = {}) -> url = "/project/#{$scope.project_id}/compile" if options.isAutoCompile url += "?auto_compile=true" return $http.post url, { rootDoc_id: options.rootDocOverride_id or null + draft: $scope.draft _csrf: window.csrfToken } @@ -76,25 +83,42 @@ define [ qs = if outputFile?.build? then "?build=#{outputFile.build}" else "" $http.get "/project/#{$scope.project_id}/output/output.log" + qs .success (log) -> + #console.log ">>", log $scope.pdf.rawLog = log logEntries = LogParser.parse(log, ignoreDuplicates: true) + #console.log ">>", logEntries $scope.pdf.logEntries = logEntries $scope.pdf.logEntries.all = logEntries.errors.concat(logEntries.warnings).concat(logEntries.typesetting) - - $scope.pdf.logEntryAnnotations = {} - for entry in logEntries.all - if entry.file? - entry.file = normalizeFilePath(entry.file) - - entity = ide.fileTreeManager.findEntityByPath(entry.file) - if entity? - $scope.pdf.logEntryAnnotations[entity.id] ||= [] - $scope.pdf.logEntryAnnotations[entity.id].push { - row: entry.line - 1 - type: if entry.level == "error" then "error" else "warning" - text: entry.message - } - + # # # # + proceed = () -> + $scope.pdf.logEntryAnnotations = {} + for entry in logEntries.all + if entry.file? + entry.file = normalizeFilePath(entry.file) + entity = ide.fileTreeManager.findEntityByPath(entry.file) + if entity? + $scope.pdf.logEntryAnnotations[entity.id] ||= [] + $scope.pdf.logEntryAnnotations[entity.id].push { + row: entry.line - 1 + type: if entry.level == "error" then "error" else "warning" + text: entry.message + } + # Get the biber log and parse it too + $http.get "/project/#{$scope.project_id}/output/output.blg" + qs + .success (log) -> + window._s = $scope + biberLogEntries = BibLogParser.parse(log, {}) + if $scope.pdf.logEntries + entries = $scope.pdf.logEntries + all = biberLogEntries.errors.concat(biberLogEntries.warnings) + entries.all = entries.all.concat(all) + entries.errors = entries.errors.concat(biberLogEntries.errors) + entries.warnings = entries.warnings.concat(biberLogEntries.warnings) + proceed() + .error (e) -> + console.error ">> error", e + proceed() + # # # # .error () -> $scope.pdf.logEntries = [] $scope.pdf.rawLog = "" @@ -103,8 +127,8 @@ define [ doc = ide.editorManager.getCurrentDocValue() return null if !doc? for line in doc.split("\n") - match = line.match /(.*)\\documentclass/ - if match and !match[1].match /%/ + match = line.match /^[^%]*\\documentclass/ + if match return ide.editorManager.getCurrentDocId() return null @@ -121,7 +145,7 @@ define [ $scope.recompile = (options = {}) -> return if $scope.pdf.compiling $scope.pdf.compiling = true - + ide.$scope.$broadcast("flush-changes") options.rootDocOverride_id = getRootDocOverride_id() @@ -134,7 +158,7 @@ define [ .error () -> $scope.pdf.compiling = false $scope.pdf.error = true - + # This needs to be public. ide.$scope.recompile = $scope.recompile @@ -171,17 +195,17 @@ define [ .then (data) -> {doc, line} = data ide.editorManager.openDoc(doc, gotoLine: line) - + $scope.switchToFlatLayout = () -> $scope.ui.pdfLayout = 'flat' $scope.ui.view = 'pdf' ide.localStorage "pdf.layout", "flat" - + $scope.switchToSideBySideLayout = () -> $scope.ui.pdfLayout = 'sideBySide' $scope.ui.view = 'editor' localStorage "pdf.layout", "split" - + if pdfLayout = localStorage("pdf.layout") $scope.switchToSideBySideLayout() if pdfLayout == "split" $scope.switchToFlatLayout() if pdfLayout == "flat" @@ -210,7 +234,7 @@ define [ if !path? deferred.reject() return deferred.promise - + # If the root file is folder/main.tex, then synctex sees the # path as folder/./main.tex rootDocDirname = ide.fileTreeManager.getRootDocDirname() @@ -220,7 +244,7 @@ define [ {row, column} = cursorPosition $http({ - url: "/project/#{ide.project_id}/sync/code", + url: "/project/#{ide.project_id}/sync/code", method: "GET", params: { file: path @@ -247,7 +271,7 @@ define [ position.offset.top = position.offset.top + 80 $http({ - url: "/project/#{ide.project_id}/sync/pdf", + url: "/project/#{ide.project_id}/sync/pdf", method: "GET", params: { page: position.page + 1 @@ -310,4 +334,4 @@ define [ $scope.cancel = () -> $modalInstance.dismiss('cancel') - ] \ No newline at end of file + ] diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee index aa0c7d5405..bd7883c156 100644 --- a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee +++ b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee @@ -16,7 +16,8 @@ define [ # PDFJS.disableStream # PDFJS.disableRange @scale = @options.scale || 1 - @pdfjs = PDFJS.getDocument @url, null, null, @options.progressCallback + @pdfjs = PDFJS.getDocument @url + @pdfjs.onProgress = @options.progressCallback @document = $q.when(@pdfjs) @navigateFn = @options.navigateFn @spinner = new pdfSpinner @@ -26,7 +27,7 @@ define [ @options.loadedCallback() @errorCallback = @options.errorCallback @pageSizeChangeCallback = @options.pageSizeChangeCallback - @pdfjs.catch (exception) => + @pdfjs.promise.catch (exception) => # console.log 'ERROR in get document', exception @errorCallback(exception) @@ -208,9 +209,6 @@ define [ element.container.width(newWidth + 'px') @pageSizeChangeCallback?(pagenum, newHeight - oldHeight) - if pixelRatio != 1 - ctx.scale(pixelRatio, pixelRatio) - textLayer = new pdfTextLayer({ textLayerDiv: element.text[0] viewport: viewport @@ -229,6 +227,7 @@ define [ result = page.render { canvasContext: ctx viewport: viewport + transform: [pixelRatio, 0, 0, pixelRatio, 0, 0] } timedOut = false diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee index 308a71be1e..1e6a2791d2 100644 --- a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee +++ b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee @@ -369,7 +369,8 @@ define [ scope.$on 'pdf:error', (event, error) -> return if error == 'cancelled' # check if too many retries or file is missing - if scope.loadCount > 3 || error?.match(/^Missing PDF/i) || error?.match(/^loading/i) + message = error?.message or error + if scope.loadCount > 3 || message.match(/^Missing PDF/i) || message.match(/^loading/i) scope.$emit 'pdf:error:display' return if scope.loadSuccess diff --git a/services/web/public/coffee/ide/references/ReferencesManager.coffee b/services/web/public/coffee/ide/references/ReferencesManager.coffee new file mode 100644 index 0000000000..b0f49f54f8 --- /dev/null +++ b/services/web/public/coffee/ide/references/ReferencesManager.coffee @@ -0,0 +1,57 @@ +define [ +], () -> + class ReferencesManager + constructor: (@ide, @$scope) -> + + @$scope.$root._references = @state = keys: [] + + @$scope.$on 'document:closed', (e, doc) => + if doc.doc_id + entity = @ide.fileTreeManager.findEntityById doc.doc_id + if entity?.name?.match /.*\.bib$/ + @indexReferences([doc.doc_id], true) + + # When we join the project: + # index all references files + # and don't broadcast to all clients + @$scope.$on 'project:joined', (e) => + @indexAllReferences(false) + + setTimeout( + (self) -> + self.ide.socket.on 'references:keys:updated', (keys) -> + # console.log '>> got keys from socket' + self._storeReferencesKeys(keys) + , 1000 + , this + ) + + _storeReferencesKeys: (newKeys) -> + # console.log '>> storing references keys' + oldKeys = @$scope.$root._references.keys + @$scope.$root._references.keys = _.union(oldKeys, newKeys) + + indexReferences: (docIds, shouldBroadcast) -> + opts = + docIds: docIds + shouldBroadcast: shouldBroadcast + _csrf: window.csrfToken + $.post( + "/project/#{@$scope.project_id}/references/index", + opts, + (data) => + # console.log ">> got keys ", data + @_storeReferencesKeys(data.keys) + ) + + indexAllReferences: (shouldBroadcast) -> + opts = + shouldBroadcast: shouldBroadcast + _csrf: window.csrfToken + $.post( + "/project/#{@$scope.project_id}/references/indexAll", + opts, + (data) => + # console.log ">> got keys ", data + @_storeReferencesKeys(data.keys) + ) diff --git a/services/web/public/coffee/ide/services/ide.coffee b/services/web/public/coffee/ide/services/ide.coffee index 1410e3db33..6741f041ba 100644 --- a/services/web/public/coffee/ide/services/ide.coffee +++ b/services/web/public/coffee/ide/services/ide.coffee @@ -3,9 +3,10 @@ define [ ], (App) -> # We create and provide this as service so that we can access the global ide # from within other parts of the angular app. - App.factory "ide", ["$http", "$modal", ($http, $modal) -> + App.factory "ide", ["$http", "queuedHttp", "$modal", ($http, queuedHttp, $modal) -> ide = {} ide.$http = $http + ide.queuedHttp = queuedHttp @recentEvents = [] ide.pushEvent = (type, meta = {}) => diff --git a/services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee b/services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee index 01f441b603..6195feff13 100644 --- a/services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee +++ b/services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee @@ -1,6 +1,7 @@ define [ "base" ], (App) -> + MAX_PROJECT_NAME_LENGTH = 150 App.controller "ProjectNameController", ["$scope", "settings", "ide", ($scope, settings, ide) -> $scope.state = renaming: false @@ -12,11 +13,14 @@ define [ $scope.$emit "project:rename:start" $scope.finishRenaming = () -> - newName = $scope.inputs.name - if newName.length < 150 - $scope.project.name = newName - settings.saveProjectSettings({name: $scope.project.name}) $scope.state.renaming = false + newName = $scope.inputs.name + if !newName? or newName.length == 0 or newName.length > MAX_PROJECT_NAME_LENGTH + return + if $scope.project.name == newName + return + $scope.project.name = newName + settings.saveProjectSettings({name: $scope.project.name}) ide.socket.on "projectNameUpdated", (name) -> $scope.$apply () -> diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index 3740b679c2..ad3e8d8a36 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -15,6 +15,7 @@ define [ "main/annual-upgrade" "main/register-users" "main/subscription/group-subscription-invite-controller" + "main/universties-site" "analytics/AbTestingManager" "directives/asyncForm" "directives/stopPropagation" @@ -24,6 +25,7 @@ define [ "directives/onEnter" "directives/selectAll" "directives/maxHeight" + "services/queued-http" "filters/formatDate" "__MAIN_CLIENTSIDE_INCLUDES__" ], () -> diff --git a/services/web/public/coffee/main/project-list/index.coffee b/services/web/public/coffee/main/project-list/index.coffee index 82367e1a63..05b61ae57f 100644 --- a/services/web/public/coffee/main/project-list/index.coffee +++ b/services/web/public/coffee/main/project-list/index.coffee @@ -2,6 +2,7 @@ define [ "main/project-list/project-list" "main/project-list/modal-controllers" "main/project-list/tag-controllers" - "main/project-list/queued-http" + "main/project-list/notifications-controller" "main/project-list/left-hand-menu-promo-controller" + "services/queued-http" ], () -> \ No newline at end of file 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 4bde9a2d3d..1811c2c64f 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,5 @@ define [ $scope.showDatajoy = Math.random() < 0.5 $scope.hasProjects = window.data.projects.length > 0 - $scope.userHasSubscription = window.userHasSubscription \ No newline at end of file + $scope.userHasSubscription = window.userHasSubscription + $scope.randomView = _.shuffle(["default", "dropbox", "github"])[0] \ No newline at end of file diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 1846928924..a841e369a8 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -1,23 +1,6 @@ define [ "base" ], (App) -> - - - App.controller 'NewTagModalController', ($scope, $modalInstance, $timeout) -> - $scope.inputs = - newTagName: "" - - $modalInstance.opened.then () -> - $timeout () -> - $scope.$broadcast "open" - , 200 - - $scope.create = () -> - $modalInstance.close($scope.inputs.newTagName) - - $scope.cancel = () -> - $modalInstance.dismiss('cancel') - App.controller 'RenameProjectModalController', ($scope, $modalInstance, $timeout, projectName) -> $scope.inputs = projectName: projectName @@ -101,4 +84,4 @@ define [ $scope.onComplete = (error, name, response) -> if response.project_id? - window.location = '/project/' + response.project_id \ No newline at end of file + window.location = '/project/' + response.project_id diff --git a/services/web/public/coffee/main/project-list/notifications-controller.coffee b/services/web/public/coffee/main/project-list/notifications-controller.coffee new file mode 100644 index 0000000000..36a725f778 --- /dev/null +++ b/services/web/public/coffee/main/project-list/notifications-controller.coffee @@ -0,0 +1,17 @@ +define [ + "base" +], (App) -> + + App.controller "NotificationsController", ($scope, $http) -> + for notification in $scope.notifications + notification.hide = false + + $scope.dismiss = (notification) -> + $http({ + url: "/notifications/#{notification._id}" + method: "DELETE" + headers: + "X-Csrf-Token": window.csrfToken + }) + .success (data) -> + notification.hide = true 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 4e7efafa78..a528c0b96f 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -5,6 +5,7 @@ define [ App.controller "ProjectPageController", ($scope, $modal, $q, $window, queuedHttp, event_tracking, $timeout, sixpack) -> $scope.projects = window.data.projects $scope.tags = window.data.tags + $scope.notifications = window.data.notifications $scope.allSelected = false $scope.selectedProjects = [] $scope.filter = "all" @@ -169,10 +170,12 @@ define [ project.tags.splice(index, 1) for project_id in removed_project_ids - queuedHttp.post "/project/#{project_id}/tag", { - deletedTag: tag.name - _csrf: window.csrfToken - } + queuedHttp({ + method: "DELETE" + url: "/tag/#{tag._id}/project/#{project_id}" + headers: + "X-CSRF-Token": window.csrfToken + }) # If we're filtering by this tag then we need to remove # the projects from view @@ -196,18 +199,11 @@ define [ project.tags.push tag for project_id in added_project_ids - queuedHttp.post "/project/#{project_id}/tag", { - tag: tag.name + queuedHttp.post "/tag/#{tag._id}/project/#{project_id}", { _csrf: window.csrfToken } $scope.createTag = (name) -> - event_tracking.send 'project-list-page-interaction', 'project action', 'createTag' - $scope.tags.push tag = { - name: name - project_ids: [] - showWhenEmpty: true - } return tag $scope.openNewTagModal = (e) -> @@ -217,8 +213,8 @@ define [ ) modalInstance.result.then( - (newTagName) -> - tag = $scope.createTag(newTagName) + (tag) -> + $scope.tags.push tag $scope.addSelectedProjectsToTag(tag) ) @@ -261,9 +257,11 @@ define [ modalInstance.result.then (project_id) -> window.location = "/project/#{project_id}" + MAX_PROJECT_NAME_LENGTH = 150 $scope.renameProject = (project, newName) -> - if newName.length < 150 - project.name = newName + if !newName? or newName.length == 0 or newName.length > MAX_PROJECT_NAME_LENGTH + return + project.name = newName queuedHttp.post "/project/#{project.id}/rename", { newProjectName: project.name _csrf: window.csrfToken @@ -342,6 +340,7 @@ define [ $scope._removeProjectIdsFromTagArray(tag, selected_project_ids) for project in selected_projects + project.tags = [] if project.accessLevel == "owner" project.archived = true queuedHttp { diff --git a/services/web/public/coffee/main/project-list/tag-controllers.coffee b/services/web/public/coffee/main/project-list/tag-controllers.coffee index c37691bf72..cc3415e32e 100644 --- a/services/web/public/coffee/main/project-list/tag-controllers.coffee +++ b/services/web/public/coffee/main/project-list/tag-controllers.coffee @@ -2,7 +2,7 @@ define [ "base" ], (App) -> - App.controller "TagListController", ($scope) -> + App.controller "TagListController", ($scope, $modal) -> $scope.filterProjects = (filter = "all") -> $scope._clearTags() $scope.setFilter(filter) @@ -10,17 +10,39 @@ define [ $scope._clearTags = () -> for tag in $scope.tags tag.selected = false - - $scope.nonEmpty = (tag) -> - # The showWhenEmpty property will be set on any tag which we have - # modified during this session. Otherwise, tags which are empty - # when loading the page are not shown. - tag.project_ids.length > 0 or !!tag.showWhenEmpty $scope.selectTag = (tag) -> $scope._clearTags() tag.selected = true $scope.setFilter("tag") + + $scope.deleteTag = (tag) -> + modalInstance = $modal.open( + templateUrl: "deleteTagModalTemplate" + controller: "DeleteTagModalController" + resolve: + tag: () -> tag + ) + modalInstance.result.then () -> + # Remove tag from projects + for project in $scope.projects + project.tags ||= [] + index = project.tags.indexOf tag + if index > -1 + project.tags.splice(index, 1) + # Remove tag + $scope.tags = $scope.tags.filter (t) -> t != tag + + $scope.renameTag = (tag) -> + modalInstance = $modal.open( + templateUrl: "renameTagModalTemplate" + controller: "RenameTagModalController" + resolve: + tag: () -> tag + existing_tags: () -> $scope.tags + ) + modalInstance.result.then (new_name) -> + tag.name = new_name App.controller "TagDropdownItemController", ($scope) -> $scope.recalculateProjectsInTag = () -> @@ -45,3 +67,92 @@ define [ $scope.$watch "selectedProjects", () -> $scope.recalculateProjectsInTag() $scope.recalculateProjectsInTag() + + App.controller 'NewTagModalController', ($scope, $modalInstance, $timeout, $http) -> + $scope.inputs = + newTagName: "" + + $scope.state = + inflight: false + error: false + + $modalInstance.opened.then () -> + $timeout () -> + $scope.$broadcast "open" + , 200 + + $scope.create = () -> + name = $scope.inputs.newTagName + $scope.state.inflight = true + $scope.state.error = false + $http + .post "/tag", { + _csrf: window.csrfToken, + name: name + } + .success (data, status, headers, config) -> + $scope.state.inflight = false + $modalInstance.close(data) + .error () -> + $scope.state.inflight = false + $scope.state.error = true + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + App.controller 'RenameTagModalController', ($scope, $modalInstance, $timeout, $http, tag, existing_tags) -> + $scope.inputs = + tagName: tag.name + + $scope.state = + inflight: false + error: false + + $modalInstance.opened.then () -> + $timeout () -> + $scope.$broadcast "open" + , 200 + + $scope.rename = () -> + name = $scope.inputs.tagName + $scope.state.inflight = true + $scope.state.error = false + $http + .post "/tag/#{tag._id}/rename", { + _csrf: window.csrfToken, + name: name + } + .success () -> + $scope.state.inflight = false + $modalInstance.close(name) + .error () -> + $scope.state.inflight = false + $scope.state.error = true + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + App.controller 'DeleteTagModalController', ($scope, $modalInstance, $http, tag) -> + $scope.tag = tag + $scope.state = + inflight: false + error: false + + $scope.delete = () -> + $scope.state.inflight = true + $scope.state.error = false + $http({ + method: "DELETE" + url: "/tag/#{tag._id}" + headers: + "X-CSRF-Token": window.csrfToken + }) + .success () -> + $scope.state.inflight = false + $modalInstance.close() + .error () -> + $scope.state.inflight = false + $scope.state.error = true + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') diff --git a/services/web/public/coffee/main/subscription-dashboard.coffee b/services/web/public/coffee/main/subscription-dashboard.coffee index 5cf436fa9d..63eec0d65a 100644 --- a/services/web/public/coffee/main/subscription-dashboard.coffee +++ b/services/web/public/coffee/main/subscription-dashboard.coffee @@ -5,7 +5,7 @@ define [ setupReturly = _.once -> recurly?.configure window.recurlyApiKey - + PRICES = {} App.controller "CurrenyDropdownController", ($scope, MultiCurrencyPricing, $q)-> @@ -18,7 +18,7 @@ define [ App.controller "ChangePlanFormController", ($scope, $modal, MultiCurrencyPricing)-> setupReturly() - + console.log("init") taxRate = window.taxRate $scope.changePlan = -> @@ -37,10 +37,11 @@ define [ $scope.currencyCode = MultiCurrencyPricing.currencyCode - $scope.prices = {} + $scope.prices = PRICES $scope.refreshPrice = (planCode)-> if $scope.prices[planCode]? return + $scope.prices[planCode] = "..." pricing = recurly.Pricing() pricing.plan(planCode, { quantity: 1 }).currency(MultiCurrencyPricing.currencyCode).done (price)-> totalPriceExTax = parseFloat(price.next.total) @@ -52,11 +53,9 @@ define [ price = "" - - App.controller "ConfirmChangePlanController", ($scope, $modalInstance, $http)-> + $scope.confirmChangePlan = -> - body = plan_code: $scope.plan.planCode _csrf : window.csrfToken @@ -71,4 +70,104 @@ define [ console.log "something went wrong changing plan" $scope.cancel = () -> - $modalInstance.dismiss('cancel') \ No newline at end of file + $modalInstance.dismiss('cancel') + + App.controller "LeaveGroupModalController", ($scope, $modalInstance, $http)-> + $scope.confirmLeaveGroup = -> + $scope.inflight = true + $http({ + url: "/subscription/group/user", + method: "DELETE", + params: {admin_user_id: $scope.admin_id, _csrf: window.csrfToken} + }).success -> + location.reload() + .error -> + console.log "something went wrong changing plan" + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + + App.controller "UserSubscriptionController", ($scope, MultiCurrencyPricing, $http, sixpack, $modal) -> + freeTrialEndDate = new Date(subscription?.trial_ends_at) + + sevenDaysTime = new Date() + sevenDaysTime.setDate(sevenDaysTime.getDate() + 7) + + freeTrialInFuture = freeTrialEndDate > new Date() + freeTrialExpiresUnderSevenDays = freeTrialEndDate < sevenDaysTime + + $scope.view = 'overview' + isMonthlyCollab = subscription?.planCode?.indexOf("collaborator") != -1 and subscription?.planCode?.indexOf("ann") == -1 + stillInFreeTrial = freeTrialInFuture and freeTrialExpiresUnderSevenDays + + if isMonthlyCollab and stillInFreeTrial + $scope.showExtendFreeTrial = true + else if isMonthlyCollab and !stillInFreeTrial + $scope.showDowngradeToStudent = true + else + $scope.showBasicCancel = true + + setupReturly() + + recurly.Pricing().plan('student', { quantity: 1 }).currency(MultiCurrencyPricing.currencyCode).done (price)-> + totalPriceExTax = parseFloat(price.next.total) + $scope.$evalAsync () -> + taxAmmount = totalPriceExTax * taxRate + if isNaN(taxAmmount) + taxAmmount = 0 + $scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol + $scope.studentPrice = $scope.currencySymbol + (totalPriceExTax + taxAmmount) + + $scope.downgradeToStudent = -> + body = + plan_code: 'student' + _csrf : window.csrfToken + $scope.inflight = true + $http.post(SUBSCRIPTION_URL, body) + .success -> + location.reload() + .error -> + console.log "something went wrong changing plan" + + $scope.cancelSubscription = -> + body = + _csrf : window.csrfToken + + $scope.inflight = true + $http.post("/user/subscription/cancel", body) + .success -> + sixpack.convert 'cancelation-options-view', -> + sixpack.convert 'upgrade-success-message', -> + location.reload() + .error -> + console.log "something went wrong changing plan" + + + $scope.removeSelfFromGroup = (admin_id)-> + $scope.admin_id = admin_id + $modal.open( + templateUrl: "LeaveGroupModalTemplate" + controller: "LeaveGroupModalController" + scope: $scope + ) + + $scope.switchToCancelationView = -> + sixpack.participate 'cancelation-options-view', ['basic', 'downgrade-options'], (view, rawResponse)-> + $scope.view = "cancelation" + $scope.sixpackOpt = view + + + + $scope.exendTrial = -> + body = + _csrf : window.csrfToken + $scope.inflight = true + $http.put("/user/subscription/extend", body) + .success -> + location.reload() + .error -> + console.log "something went wrong changing plan" + + + diff --git a/services/web/public/coffee/main/universties-site.coffee b/services/web/public/coffee/main/universties-site.coffee new file mode 100644 index 0000000000..c62f4adc50 --- /dev/null +++ b/services/web/public/coffee/main/universties-site.coffee @@ -0,0 +1,26 @@ +define [ + "base" +], (App) -> + + App.controller 'UniverstiesContactController', ($scope, $modal) -> + + $scope.form = {} + $scope.sent = false + $scope.sending = false + $scope.contactUs = -> + if !$scope.form.email? + console.log "email not set" + return + $scope.sending = true + ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) + params = + name: $scope.form.name || $scope.form.email + email: $scope.form.email + labels: $scope.form.source + message: "Please contact me with more details" + subject: $scope.form.subject + " - [#{ticketNumber}]" + about : "#{$scope.form.position || ''} #{$scope.form.university || ''}" + + Groove.createTicket params, (err, json)-> + $scope.sent = true + $scope.$apply() diff --git a/services/web/public/coffee/main/project-list/queued-http.coffee b/services/web/public/coffee/services/queued-http.coffee similarity index 99% rename from services/web/public/coffee/main/project-list/queued-http.coffee rename to services/web/public/coffee/services/queued-http.coffee index 0d7823b597..c4c6862fae 100644 --- a/services/web/public/coffee/main/project-list/queued-http.coffee +++ b/services/web/public/coffee/services/queued-http.coffee @@ -1,7 +1,6 @@ define [ "base" ], (App) -> - App.factory "queuedHttp", ($http, $q) -> pendingRequests = [] inflight = false diff --git a/services/web/public/img/references-search/search_example.gif b/services/web/public/img/references-search/search_example.gif new file mode 100644 index 0000000000..7543e71e3d Binary files /dev/null and b/services/web/public/img/references-search/search_example.gif differ diff --git a/services/web/public/js/libs/bib-log-parser.js b/services/web/public/js/libs/bib-log-parser.js new file mode 100644 index 0000000000..f726eb6c52 --- /dev/null +++ b/services/web/public/js/libs/bib-log-parser.js @@ -0,0 +1,190 @@ +// Generated by CoffeeScript 1.10.0 +define(function() { + var BAD_CROSS_REFERENCE_REGEX, BibLogParser, LINE_SPLITTER_REGEX, MESSAGE_LEVELS, MULTILINE_COMMAND_ERROR_REGEX, MULTILINE_ERROR_REGEX, MULTILINE_WARNING_REGEX, SINGLELINE_WARNING_REGEX, consume, errorParsers, warningParsers; + LINE_SPLITTER_REGEX = /^\[(\d+)].*>\s(INFO|WARN|ERROR)\s-\s(.*)$/; + MESSAGE_LEVELS = { + "INFO": "info", + "WARN": "warning", + "ERROR": "error" + }; + BibLogParser = function(text, options) { + if (typeof text !== 'string') { + throw new Error("BibLogParser Error: text parameter must be a string"); + } + this.text = text.replace(/(\r\n)|\r/g, '\n'); + this.options = options || {}; + this.lines = text.split('\n'); + }; + consume = function(logText, regex, process) { + var iterationCount, match, newEntry, re, result, text; + text = logText; + result = []; + re = regex; + iterationCount = 0; + while (match = re.exec(text)) { + iterationCount += 1; + if (iterationCount >= 10000) { + return result; + } + newEntry = process(match); + result.push(newEntry); + text = (match.input.slice(0, match.index)) + (match.input.slice(match.index + match[0].length + 1, match.input.length)); + } + return [result, text]; + }; + MULTILINE_WARNING_REGEX = /^Warning--(.+)\n--line (\d+) of file (.+)$/m; + SINGLELINE_WARNING_REGEX = /^Warning--(.+)$/m; + MULTILINE_ERROR_REGEX = /^(.*)---line (\d+) of file (.*)\n([^]+?)\nI'm skipping whatever remains of this entry$/m; + BAD_CROSS_REFERENCE_REGEX = /^(A bad cross reference---entry ".+?"\nrefers to entry.+?, which doesn't exist)$/m; + MULTILINE_COMMAND_ERROR_REGEX = /^(.*)\n---line (\d+) of file (.*)\n([^]+?)\nI'm skipping whatever remains of this command$/m; + warningParsers = [ + [ + MULTILINE_WARNING_REGEX, function(match) { + var fileName, fullMatch, lineNumber, message; + fullMatch = match[0], message = match[1], lineNumber = match[2], fileName = match[3]; + return { + file: fileName, + level: "warning", + message: message, + line: lineNumber, + raw: fullMatch + }; + } + ], [ + SINGLELINE_WARNING_REGEX, function(match) { + var fullMatch, message; + fullMatch = match[0], message = match[1]; + return { + file: '', + level: "warning", + message: message, + line: '', + raw: fullMatch + }; + } + ] + ]; + errorParsers = [ + [ + MULTILINE_ERROR_REGEX, function(match) { + var fileName, firstMessage, fullMatch, lineNumber, secondMessage; + fullMatch = match[0], firstMessage = match[1], lineNumber = match[2], fileName = match[3], secondMessage = match[4]; + return { + file: fileName, + level: "error", + message: firstMessage + '\n' + secondMessage, + line: lineNumber, + raw: fullMatch + }; + } + ], [ + BAD_CROSS_REFERENCE_REGEX, function(match) { + var fullMatch, message; + fullMatch = match[0], message = match[1]; + return { + file: '', + level: "error", + message: message, + line: '', + raw: fullMatch + }; + } + ], [ + MULTILINE_COMMAND_ERROR_REGEX, function(match) { + var fileName, firstMessage, fullMatch, lineNumber, secondMessage; + fullMatch = match[0], firstMessage = match[1], lineNumber = match[2], fileName = match[3], secondMessage = match[4]; + return { + file: fileName, + level: "error", + message: firstMessage + '\n' + secondMessage, + line: lineNumber, + raw: fullMatch + }; + } + ] + ]; + (function() { + this.parseBibtex = function() { + var allErrors, allWarnings, ref, ref1, remainingText, result; + result = { + all: [], + errors: [], + warnings: [], + files: [], + typesetting: [] + }; + ref = warningParsers.reduce(function(accumulator, parser) { + var _remainingText, currentWarnings, process, ref, regex, text, warnings; + currentWarnings = accumulator[0], text = accumulator[1]; + regex = parser[0], process = parser[1]; + ref = consume(text, regex, process), warnings = ref[0], _remainingText = ref[1]; + return [currentWarnings.concat(warnings), _remainingText]; + }, [[], this.text]), allWarnings = ref[0], remainingText = ref[1]; + ref1 = errorParsers.reduce(function(accumulator, parser) { + var _remainingText, currentErrors, errors, process, ref1, regex, text; + currentErrors = accumulator[0], text = accumulator[1]; + regex = parser[0], process = parser[1]; + ref1 = consume(text, regex, process), errors = ref1[0], _remainingText = ref1[1]; + return [currentErrors.concat(errors), _remainingText]; + }, [[], remainingText]), allErrors = ref1[0], remainingText = ref1[1]; + result.warnings = allWarnings; + result.errors = allErrors; + result.all = allWarnings.concat(allErrors); + return result; + }; + this.parseBiber = function() { + var result; + result = { + all: [], + errors: [], + warnings: [], + files: [], + typesetting: [] + }; + this.lines.forEach(function(line) { + var _, fileName, fullLine, lineMatch, lineNumber, match, message, messageType, newEntry, realMessage; + match = line.match(LINE_SPLITTER_REGEX); + if (match) { + fullLine = match[0], lineNumber = match[1], messageType = match[2], message = match[3]; + newEntry = { + file: '', + level: MESSAGE_LEVELS[messageType] || "INFO", + message: message, + line: '', + raw: fullLine + }; + lineMatch = newEntry.message.match(/^BibTeX subsystem: \/.+\/(\w+\.\w+)_.+, line (\d+), (.+)$/); + if (lineMatch && lineMatch.length === 4) { + _ = lineMatch[0], fileName = lineMatch[1], lineNumber = lineMatch[2], realMessage = lineMatch[3]; + newEntry.file = fileName; + newEntry.line = lineNumber; + newEntry.message = realMessage; + } + result.all.push(newEntry); + switch (newEntry.level) { + case 'error': + return result.errors.push(newEntry); + case 'warning': + return result.warnings.push(newEntry); + } + } + }); + return result; + }; + return this.parse = function() { + var firstLine; + firstLine = this.lines[0]; + if (firstLine.match(/^.*INFO - This is Biber.*$/)) { + return this.parseBiber(); + } else if (firstLine.match(/^This is BibTeX, Version.+$/)) { + return this.parseBibtex(); + } else { + throw new Error("BibLogParser Error: cannot determine whether text is biber or bibtex output"); + } + }; + }).call(BibLogParser.prototype); + BibLogParser.parse = function(text, options) { + return new BibLogParser(text, options).parse(); + }; + return BibLogParser; +}); diff --git a/services/web/public/js/libs/jquery-layout.js b/services/web/public/js/libs/jquery-layout.js index e5bec01cb8..43ecc886c1 100644 --- a/services/web/public/js/libs/jquery-layout.js +++ b/services/web/public/js/libs/jquery-layout.js @@ -1,285 +1,25 @@ - // (function(LST) { - - // LST.rethrow = false; - - // var currentTraceError = null; - - // var filename = new Error().stack.split("\n")[1].match(/^ at ((?:\w+:\/\/)?[^:]+)/)[1]; - // function filterInternalFrames(frames) { - // return frames.split("\n").filter(function(frame) { return frame.indexOf(filename) < 0; }).join("\n"); - // } - - // Error.prepareStackTrace = function(error, structuredStackTrace) { - // if (!error.__cachedTrace) { - // error.__cachedTrace = filterInternalFrames(FormatStackTrace(error, structuredStackTrace)); - // if (!has.call(error, "__previous")) { - // var previous = currentTraceError; - // while (previous) { - // var previousTrace = previous.stack; - // error.__cachedTrace += "\n----------------------------------------\n" + - // " at " + previous.__location + "\n" + - // previousTrace.substring(previousTrace.indexOf("\n") + 1); - // previous = previous.__previous; - // } - // } - // } - // return error.__cachedTrace; - // } - - // var slice = Array.prototype.slice; - // var has = Object.prototype.hasOwnProperty; - - // // Takes an object, a property name for the callback function to wrap, and an argument position - // // and overwrites the function with a wrapper that captures the stack at the time of callback registration - // function wrapRegistrationFunction(object, property, callbackArg) { - // if (typeof object[property] !== "function") { - // console.error("(long-stack-traces) Object", object, "does not contain function", property); - // return; - // } - // if (!has.call(object, property)) { - // console.warn("(long-stack-traces) Object", object, "does not directly contain function", property); - // } - - // // TODO: better source position detection - // var sourcePosition = (object.constructor.name || Object.prototype.toString.call(object)) + "." + property; - - // // capture the original registration function - // var fn = object[property]; - // // overwrite it with a wrapped registration function that modifies the supplied callback argument - // object[property] = function() { - // // replace the callback argument with a wrapped version that captured the current stack trace - // arguments[callbackArg] = makeWrappedCallback(arguments[callbackArg], sourcePosition); - // // call the original registration function with the modified arguments - // return fn.apply(this, arguments); - // } - - // // check that the registration function was indeed overwritten - // if (object[property] === fn) - // console.warn("(long-stack-traces) Couldn't replace ", property, "on", object); - // } - - // // Takes a callback function and name, and captures a stack trace, returning a new callback that restores the stack frame - // // This function adds a single function call overhead during callback registration vs. inlining it in wrapRegistationFunction - // function makeWrappedCallback(callback, frameLocation) { - // // add a fake stack frame. we can't get a real one since we aren't inside the original function - // var traceError = new Error(); - // traceError.__location = frameLocation; - // traceError.__previous = currentTraceError; - // return function() { - // // if (currentTraceError) { - // // FIXME: This shouldn't normally happen, but it often does. Do we actually need a stack instead? - // // console.warn("(long-stack-traces) Internal Error: currentTrace already set."); - // // } - // // restore the trace - // currentTraceError = traceError; - // try { - // return callback.apply(this, arguments); - // } catch (e) { - // console.error("Uncaught " + e.stack); - // if (LST.rethrow) - // throw ""; // TODO: throw the original error, or undefined? - // } finally { - // // clear the trace so we can check that none is set above. - // // TODO: could we remove this for slightly better performace? - // currentTraceError = null; - // } - // } - // } - - // // Chrome - // if (typeof window !== "undefined") { - // wrapRegistrationFunction(window.constructor.prototype, "setTimeout", 0); - // wrapRegistrationFunction(window.constructor.prototype, "setInterval", 0); - - // [ - // window.Node.prototype, - // window.MessagePort.prototype, - // window.SVGElementInstance.prototype, - // window.WebSocket.prototype, - // window.XMLHttpRequest.prototype, - // window.EventSource.prototype, - // window.XMLHttpRequestUpload.prototype, - // window.SharedWorker.prototype.__proto__, - // window.constructor.prototype, - // window.applicationCache.constructor.prototype - // ].forEach(function(object) { - // wrapRegistrationFunction(object, "addEventListener", 1); - // }); - - // // this actually captures the stack when "send" is called, which isn't ideal, - // // but it's the best we can do without hooking onreadystatechange assignments - // var _send = XMLHttpRequest.prototype.send; - // XMLHttpRequest.prototype.send = function() { - // this.onreadystatechange = makeWrappedCallback(this.onreadystatechange, "onreadystatechange"); - // return _send.apply(this, arguments); - // } - - // // FIXME: experimental XHR wrapper for hooking onreadystatechange - // // Based on https://gist.github.com/796032 - // // var _XMLHttpRequest = XMLHttpRequest; - // // XMLHttpRequest = function () { - // // Object.defineProperty(this, "onreadystatechange", { - // // get: function() { - // // return this.__onreadystatechange; - // // }, - // // set: function(onreadystatechange) { - // // if (this.__onreadystatechange && typeof this.__onreadystatechange.call === "function") - // // this.removeEventListener("readystatechange", this.__onreadystatechange); - // // this.__onreadystatechange = makeWrappedCallback(onreadystatechange, "onreadystatechange"); - // // if (this.__onreadystatechange && typeof this.__onreadystatechange.call === "function") - // // this.addEventListener("readystatechange", this.__onreadystatechange); - // // }, - // // enumerable: true - // // }); - // // Object.defineProperty(this, "__onreadystatechange", { - // // value: null, - // // writable: true, - // // enumerable: false - // // }); - // // } - // // XMLHttpRequest.prototype = new _XMLHttpRequest(); - // } - // // Node.js - // else if (typeof process !== "undefined") { - // LST.rethrow = true; - - // var global = (function() { return this; })(); - // wrapRegistrationFunction(global, "setTimeout", 0); - // wrapRegistrationFunction(global, "setInterval", 0); - // wrapRegistrationFunction(global, "setImmediate", 0); - - // var EventEmitter = require('events').EventEmitter; - // wrapRegistrationFunction(EventEmitter.prototype, "addListener", 1); - // wrapRegistrationFunction(EventEmitter.prototype, "on", 1); - - // wrapRegistrationFunction(process, "nextTick", 0); - // } - - // // Copyright 2006-2008 the V8 project authors. All rights reserved. - // // Redistribution and use in source and binary forms, with or without - // // modification, are permitted provided that the following conditions are - // // met: - // // - // // * Redistributions of source code must retain the above copyright - // // notice, this list of conditions and the following disclaimer. - // // * Redistributions in binary form must reproduce the above - // // copyright notice, this list of conditions and the following - // // disclaimer in the documentation and/or other materials provided - // // with the distribution. - // // * Neither the name of Google Inc. nor the names of its - // // contributors may be used to endorse or promote products derived - // // from this software without specific prior written permission. - // // - // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - // // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - // // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - // // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - // // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - // // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - // // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - // // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - // // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - // // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - // // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - // function FormatStackTrace(error, frames) { - // var lines = []; - // try { - // lines.push(error.toString()); - // } catch (e) { - // try { - // lines.push(""); - // } catch (ee) { - // lines.push(""); - // } - // } - // for (var i = 0; i < frames.length; i++) { - // var frame = frames[i]; - // var line; - // try { - // line = FormatSourcePosition(frame); - // } catch (e) { - // try { - // line = ""; - // } catch (ee) { - // // Any code that reaches this point is seriously nasty! - // line = ""; - // } - // } - // lines.push(" at " + line); - // } - // return lines.join("\n"); - // } - - // function FormatSourcePosition(frame) { - // var fileLocation = ""; - // if (frame.isNative()) { - // fileLocation = "native"; - // } else if (frame.isEval()) { - // fileLocation = "eval at " + frame.getEvalOrigin(); - // } else { - // var fileName = frame.getFileName(); - // if (fileName) { - // fileLocation += fileName; - // var lineNumber = frame.getLineNumber(); - // if (lineNumber != null) { - // fileLocation += ":" + lineNumber; - // var columnNumber = frame.getColumnNumber(); - // if (columnNumber) { - // fileLocation += ":" + columnNumber; - // } - // } - // } - // } - // if (!fileLocation) { - // fileLocation = "unknown source"; - // } - // var line = ""; - // var functionName = frame.getFunction().name; - // var addPrefix = true; - // var isConstructor = frame.isConstructor(); - // var isMethodCall = !(frame.isToplevel() || isConstructor); - // if (isMethodCall) { - // var methodName = frame.getMethodName(); - // line += frame.getTypeName() + "."; - // if (functionName) { - // line += functionName; - // if (methodName && (methodName != functionName)) { - // line += " [as " + methodName + "]"; - // } - // } else { - // line += methodName || ""; - // } - // } else if (isConstructor) { - // line += "new " + (functionName || ""); - // } else if (functionName) { - // line += functionName; - // } else { - // line += fileLocation; - // addPrefix = false; - // } - // if (addPrefix) { - // line += " (" + fileLocation + ")"; - // } - // return line; - // } - // })(typeof exports !== "undefined" ? exports : {}); - -/*! jQuery UI - v1.10.3 - 2013-10-24 +/*! jQuery UI - v1.11.4 - 2016-02-10 * http://jqueryui.com -* Includes: jquery.ui.core.js, jquery.ui.widget.js, jquery.ui.mouse.js, jquery.ui.draggable.js, jquery.ui.droppable.js, jquery.ui.resizable.js -* Copyright 2013 jQuery Foundation and other contributors; Licensed MIT */ +* Includes: core.js, widget.js, mouse.js, draggable.js, droppable.js +* Copyright jQuery Foundation and other contributors; Licensed MIT */ -(function( $, undefined ) { +/*! + * jQuery UI Core 1.11.4 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ -var uuid = 0, - runiqueId = /^ui-id-\d+$/; // $.ui might exist from components with no dependencies, e.g., $.ui.position $.ui = $.ui || {}; $.extend( $.ui, { - version: "1.10.3", + version: "1.11.4", keyCode: { BACKSPACE: 8, @@ -291,12 +31,6 @@ $.extend( $.ui, { ESCAPE: 27, HOME: 36, LEFT: 37, - NUMPAD_ADD: 107, - NUMPAD_DECIMAL: 110, - NUMPAD_DIVIDE: 111, - NUMPAD_ENTER: 108, - NUMPAD_MULTIPLY: 106, - NUMPAD_SUBTRACT: 109, PAGE_DOWN: 34, PAGE_UP: 33, PERIOD: 190, @@ -309,77 +43,36 @@ $.extend( $.ui, { // plugins $.fn.extend({ - focus: (function( orig ) { - return function( delay, fn ) { - return typeof delay === "number" ? - this.each(function() { - var elem = this; - setTimeout(function() { - $( elem ).focus(); - if ( fn ) { - fn.call( elem ); - } - }, delay ); - }) : - orig.apply( this, arguments ); - }; - })( $.fn.focus ), - - scrollParent: function() { - var scrollParent; - if (($.ui.ie && (/(static|relative)/).test(this.css("position"))) || (/absolute/).test(this.css("position"))) { - scrollParent = this.parents().filter(function() { - return (/(relative|absolute|fixed)/).test($.css(this,"position")) && (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); - }).eq(0); - } else { - scrollParent = this.parents().filter(function() { - return (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); - }).eq(0); - } - - return (/fixed/).test(this.css("position")) || !scrollParent.length ? $(document) : scrollParent; - }, - - zIndex: function( zIndex ) { - if ( zIndex !== undefined ) { - return this.css( "zIndex", zIndex ); - } - - if ( this.length ) { - var elem = $( this[ 0 ] ), position, value; - while ( elem.length && elem[ 0 ] !== document ) { - // Ignore z-index if position is set to a value where z-index is ignored by the browser - // This makes behavior of this function consistent across browsers - // WebKit always returns auto if the element is positioned - position = elem.css( "position" ); - if ( position === "absolute" || position === "relative" || position === "fixed" ) { - // IE returns 0 when zIndex is not specified - // other browsers return a string - // we ignore the case of nested elements with an explicit value of 0 - //
- value = parseInt( elem.css( "zIndex" ), 10 ); - if ( !isNaN( value ) && value !== 0 ) { - return value; - } + scrollParent: function( includeHidden ) { + var position = this.css( "position" ), + excludeStaticParent = position === "absolute", + overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/, + scrollParent = this.parents().filter( function() { + var parent = $( this ); + if ( excludeStaticParent && parent.css( "position" ) === "static" ) { + return false; } - elem = elem.parent(); - } - } + return overflowRegex.test( parent.css( "overflow" ) + parent.css( "overflow-y" ) + parent.css( "overflow-x" ) ); + }).eq( 0 ); - return 0; + return position === "fixed" || !scrollParent.length ? $( this[ 0 ].ownerDocument || document ) : scrollParent; }, - uniqueId: function() { - return this.each(function() { - if ( !this.id ) { - this.id = "ui-id-" + (++uuid); - } - }); - }, + uniqueId: (function() { + var uuid = 0; + + return function() { + return this.each(function() { + if ( !this.id ) { + this.id = "ui-id-" + ( ++uuid ); + } + }); + }; + })(), removeUniqueId: function() { return this.each(function() { - if ( runiqueId.test( this.id ) ) { + if ( /^ui-id-\d+$/.test( this.id ) ) { $( this ).removeAttr( "id" ); } }); @@ -396,10 +89,10 @@ function focusable( element, isTabIndexNotNaN ) { if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { return false; } - img = $( "img[usemap=#" + mapName + "]" )[0]; + img = $( "img[usemap='#" + mapName + "']" )[ 0 ]; return !!img && visible( img ); } - return ( /input|select|textarea|button|object/.test( nodeName ) ? + return ( /^(input|select|textarea|button|object)$/.test( nodeName ) ? !element.disabled : "a" === nodeName ? element.href || isTabIndexNotNaN : @@ -507,94 +200,137 @@ if ( $( "" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { })( $.fn.removeData ); } - - - - // deprecated $.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); -$.support.selectstart = "onselectstart" in document.createElement( "div" ); $.fn.extend({ - disableSelection: function() { - return this.bind( ( $.support.selectstart ? "selectstart" : "mousedown" ) + - ".ui-disableSelection", function( event ) { + focus: (function( orig ) { + return function( delay, fn ) { + return typeof delay === "number" ? + this.each(function() { + var elem = this; + setTimeout(function() { + $( elem ).focus(); + if ( fn ) { + fn.call( elem ); + } + }, delay ); + }) : + orig.apply( this, arguments ); + }; + })( $.fn.focus ), + + disableSelection: (function() { + var eventType = "onselectstart" in document.createElement( "div" ) ? + "selectstart" : + "mousedown"; + + return function() { + return this.bind( eventType + ".ui-disableSelection", function( event ) { event.preventDefault(); }); - }, + }; + })(), enableSelection: function() { return this.unbind( ".ui-disableSelection" ); - } -}); - -$.extend( $.ui, { - // $.ui.plugin is deprecated. Use $.widget() extensions instead. - plugin: { - add: function( module, option, set ) { - var i, - proto = $.ui[ module ].prototype; - for ( i in set ) { - proto.plugins[ i ] = proto.plugins[ i ] || []; - proto.plugins[ i ].push( [ option, set[ i ] ] ); - } - }, - call: function( instance, name, args ) { - var i, - set = instance.plugins[ name ]; - if ( !set || !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) { - return; - } - - for ( i = 0; i < set.length; i++ ) { - if ( instance.options[ set[ i ][ 0 ] ] ) { - set[ i ][ 1 ].apply( instance.element, args ); - } - } - } }, - // only used by resizable - hasScroll: function( el, a ) { - - //If overflow is hidden, the element might have extra content, but the user wants to hide it - if ( $( el ).css( "overflow" ) === "hidden") { - return false; + zIndex: function( zIndex ) { + if ( zIndex !== undefined ) { + return this.css( "zIndex", zIndex ); } - var scroll = ( a && a === "left" ) ? "scrollLeft" : "scrollTop", - has = false; - - if ( el[ scroll ] > 0 ) { - return true; + if ( this.length ) { + var elem = $( this[ 0 ] ), position, value; + while ( elem.length && elem[ 0 ] !== document ) { + // Ignore z-index if position is set to a value where z-index is ignored by the browser + // This makes behavior of this function consistent across browsers + // WebKit always returns auto if the element is positioned + position = elem.css( "position" ); + if ( position === "absolute" || position === "relative" || position === "fixed" ) { + // IE returns 0 when zIndex is not specified + // other browsers return a string + // we ignore the case of nested elements with an explicit value of 0 + //
+ value = parseInt( elem.css( "zIndex" ), 10 ); + if ( !isNaN( value ) && value !== 0 ) { + return value; + } + } + elem = elem.parent(); + } } - // TODO: determine which cases actually cause this to happen - // if the element doesn't have the scroll set, see if it's possible to - // set the scroll - el[ scroll ] = 1; - has = ( el[ scroll ] > 0 ); - el[ scroll ] = 0; - return has; + return 0; } }); -})( jQuery ); -(function( $, undefined ) { +// $.ui.plugin is deprecated. Use $.widget() extensions instead. +$.ui.plugin = { + add: function( module, option, set ) { + var i, + proto = $.ui[ module ].prototype; + for ( i in set ) { + proto.plugins[ i ] = proto.plugins[ i ] || []; + proto.plugins[ i ].push( [ option, set[ i ] ] ); + } + }, + call: function( instance, name, args, allowDisconnected ) { + var i, + set = instance.plugins[ name ]; -var uuid = 0, - slice = Array.prototype.slice, - _cleanData = $.cleanData; -$.cleanData = function( elems ) { - for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { - try { - $( elem ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} + if ( !set ) { + return; + } + + if ( !allowDisconnected && ( !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) ) { + return; + } + + for ( i = 0; i < set.length; i++ ) { + if ( instance.options[ set[ i ][ 0 ] ] ) { + set[ i ][ 1 ].apply( instance.element, args ); + } + } } - _cleanData( elems ); }; + +/*! + * jQuery UI Widget 1.11.4 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/jQuery.widget/ + */ + + +var widget_uuid = 0, + widget_slice = Array.prototype.slice; + +$.cleanData = (function( orig ) { + return function( elems ) { + var events, elem, i; + for ( i = 0; (elem = elems[i]) != null; i++ ) { + try { + + // Only trigger remove when necessary to save time + events = $._data( elem, "events" ); + if ( events && events.remove ) { + $( elem ).triggerHandler( "remove" ); + } + + // http://bugs.jquery.com/ticket/8235 + } catch ( e ) {} + } + orig( elems ); + }; +})( $.cleanData ); + $.widget = function( name, base, prototype ) { var fullName, existingConstructor, constructor, basePrototype, // proxiedPrototype allows the provided prototype to remain unmodified @@ -678,7 +414,7 @@ $.widget = function( name, base, prototype ) { // TODO: remove support for widgetEventPrefix // always use the name + a colon as the prefix, e.g., draggable:start // don't prefix for widgets that aren't DOM-based - widgetEventPrefix: existingConstructor ? basePrototype.widgetEventPrefix : name + widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name }, proxiedPrototype, { constructor: constructor, namespace: namespace, @@ -706,10 +442,12 @@ $.widget = function( name, base, prototype ) { } $.widget.bridge( name, constructor ); + + return constructor; }; $.widget.extend = function( target ) { - var input = slice.call( arguments, 1 ), + var input = widget_slice.call( arguments, 1 ), inputIndex = 0, inputLength = input.length, key, @@ -738,18 +476,17 @@ $.widget.bridge = function( name, object ) { var fullName = object.prototype.widgetFullName || name; $.fn[ name ] = function( options ) { var isMethodCall = typeof options === "string", - args = slice.call( arguments, 1 ), + args = widget_slice.call( arguments, 1 ), returnValue = this; - // allow multiple hashes to be passed on init - options = !isMethodCall && args.length ? - $.widget.extend.apply( null, [ options ].concat(args) ) : - options; - if ( isMethodCall ) { this.each(function() { var methodValue, instance = $.data( this, fullName ); + if ( options === "instance" ) { + returnValue = instance; + return false; + } if ( !instance ) { return $.error( "cannot call methods on " + name + " prior to initialization; " + "attempted to call method '" + options + "'" ); @@ -766,10 +503,19 @@ $.widget.bridge = function( name, object ) { } }); } else { + + // Allow multiple hashes to be passed on init + if ( args.length ) { + options = $.widget.extend.apply( null, [ options ].concat(args) ); + } + this.each(function() { var instance = $.data( this, fullName ); if ( instance ) { - instance.option( options || {} )._init(); + instance.option( options || {} ); + if ( instance._init ) { + instance._init(); + } } else { $.data( this, fullName, new object( options, this ) ); } @@ -796,12 +542,8 @@ $.Widget.prototype = { _createWidget: function( options, element ) { element = $( element || this.defaultElement || this )[ 0 ]; this.element = $( element ); - this.uuid = uuid++; + this.uuid = widget_uuid++; this.eventNamespace = "." + this.widgetName + this.uuid; - this.options = $.widget.extend( {}, - this.options, - this._getCreateOptions(), - options ); this.bindings = $(); this.hoverable = $(); @@ -824,6 +566,11 @@ $.Widget.prototype = { this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); } + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + this._create(); this._trigger( "create", null, this._getCreateEventData() ); this._init(); @@ -839,9 +586,6 @@ $.Widget.prototype = { // all event bindings should go through this._on() this.element .unbind( this.eventNamespace ) - // 1.9 BC for #7810 - // TODO remove dual storage - .removeData( this.widgetName ) .removeData( this.widgetFullName ) // support: jquery <1.6.3 // http://bugs.jquery.com/ticket/9413 @@ -887,12 +631,12 @@ $.Widget.prototype = { curOption = curOption[ parts[ i ] ]; } key = parts.pop(); - if ( value === undefined ) { + if ( arguments.length === 1 ) { return curOption[ key ] === undefined ? null : curOption[ key ]; } curOption[ key ] = value; } else { - if ( value === undefined ) { + if ( arguments.length === 1 ) { return this.options[ key ] === undefined ? null : this.options[ key ]; } options[ key ] = value; @@ -917,20 +661,23 @@ $.Widget.prototype = { if ( key === "disabled" ) { this.widget() - .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) - .attr( "aria-disabled", value ); - this.hoverable.removeClass( "ui-state-hover" ); - this.focusable.removeClass( "ui-state-focus" ); + .toggleClass( this.widgetFullName + "-disabled", !!value ); + + // If the widget is becoming disabled, then nothing is interactive + if ( value ) { + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + } } return this; }, enable: function() { - return this._setOption( "disabled", false ); + return this._setOptions({ disabled: false }); }, disable: function() { - return this._setOption( "disabled", true ); + return this._setOptions({ disabled: true }); }, _on: function( suppressDisabledCheck, element, handlers ) { @@ -950,7 +697,6 @@ $.Widget.prototype = { element = this.element; delegateElement = this.widget(); } else { - // accept selectors, DOM elements element = delegateElement = $( element ); this.bindings = this.bindings.add( element ); } @@ -975,7 +721,7 @@ $.Widget.prototype = { handler.guid || handlerProxy.guid || $.guid++; } - var match = event.match( /^(\w+)\s*(.*)$/ ), + var match = event.match( /^([\w:-]*)\s*(.*)$/ ), eventName = match[1] + instance.eventNamespace, selector = match[2]; if ( selector ) { @@ -987,8 +733,14 @@ $.Widget.prototype = { }, _off: function( element, eventName ) { - eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + + this.eventNamespace; element.unbind( eventName ).undelegate( eventName ); + + // Clear the stack to avoid memory leaks (#10056) + this.bindings = $( this.bindings.not( element ).get() ); + this.focusable = $( this.focusable.not( element ).get() ); + this.hoverable = $( this.hoverable.not( element ).get() ); }, _delay: function( handler, delay ) { @@ -1090,16 +842,28 @@ $.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { }; }); -})( jQuery ); -(function( $, undefined ) { +var widget = $.widget; + + +/*! + * jQuery UI Mouse 1.11.4 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/mouse/ + */ + var mouseHandled = false; $( document ).mouseup( function() { mouseHandled = false; }); -$.widget("ui.mouse", { - version: "1.10.3", +var mouse = $.widget("ui.mouse", { + version: "1.11.4", options: { cancel: "input,textarea,button,select,option", distance: 1, @@ -1109,10 +873,10 @@ $.widget("ui.mouse", { var that = this; this.element - .bind("mousedown."+this.widgetName, function(event) { + .bind("mousedown." + this.widgetName, function(event) { return that._mouseDown(event); }) - .bind("click."+this.widgetName, function(event) { + .bind("click." + this.widgetName, function(event) { if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { $.removeData(event.target, that.widgetName + ".preventClickEvent"); event.stopImmediatePropagation(); @@ -1126,17 +890,21 @@ $.widget("ui.mouse", { // TODO: make sure destroying one instance of mouse doesn't mess with // other instances of mouse _mouseDestroy: function() { - this.element.unbind("."+this.widgetName); + this.element.unbind("." + this.widgetName); if ( this._mouseMoveDelegate ) { - $(document) - .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + this.document + .unbind("mousemove." + this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup." + this.widgetName, this._mouseUpDelegate); } }, _mouseDown: function(event) { // don't let more than one widget handle mouseStart - if( mouseHandled ) { return; } + if ( mouseHandled ) { + return; + } + + this._mouseMoved = false; // we may have missed mouseup (out of window) (this._mouseStarted && this._mouseUp(event)); @@ -1179,9 +947,10 @@ $.widget("ui.mouse", { this._mouseUpDelegate = function(event) { return that._mouseUp(event); }; - $(document) - .bind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .bind("mouseup."+this.widgetName, this._mouseUpDelegate); + + this.document + .bind( "mousemove." + this.widgetName, this._mouseMoveDelegate ) + .bind( "mouseup." + this.widgetName, this._mouseUpDelegate ); event.preventDefault(); @@ -1190,9 +959,23 @@ $.widget("ui.mouse", { }, _mouseMove: function(event) { - // IE mouseup check - mouseup happened when mouse was out of window - if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { - return this._mouseUp(event); + // Only check for mouseups outside the document if you've moved inside the document + // at least once. This prevents the firing of mouseup in the case of IE<9, which will + // fire a mousemove event if content is placed under the cursor. See #7778 + // Support: IE <9 + if ( this._mouseMoved ) { + // IE mouseup check - mouseup happened when mouse was out of window + if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { + return this._mouseUp(event); + + // Iframe mouseup check - mouseup occurred in another document + } else if ( !event.which ) { + return this._mouseUp( event ); + } + } + + if ( event.which || event.button ) { + this._mouseMoved = true; } if (this._mouseStarted) { @@ -1210,9 +993,9 @@ $.widget("ui.mouse", { }, _mouseUp: function(event) { - $(document) - .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + this.document + .unbind( "mousemove." + this.widgetName, this._mouseMoveDelegate ) + .unbind( "mouseup." + this.widgetName, this._mouseUpDelegate ); if (this._mouseStarted) { this._mouseStarted = false; @@ -1224,6 +1007,7 @@ $.widget("ui.mouse", { this._mouseStop(event); } + mouseHandled = false; return false; }, @@ -1246,11 +1030,21 @@ $.widget("ui.mouse", { _mouseCapture: function(/* event */) { return true; } }); -})(jQuery); -(function( $, undefined ) { + +/*! + * jQuery UI Draggable 1.11.4 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/draggable/ + */ + $.widget("ui.draggable", $.ui.mouse, { - version: "1.10.3", + version: "1.11.4", widgetEventPrefix: "drag", options: { addClasses: true, @@ -1285,8 +1079,8 @@ $.widget("ui.draggable", $.ui.mouse, { }, _create: function() { - if (this.options.helper === "original" && !(/^(?:r|a|f)/).test(this.element.css("position"))) { - this.element[0].style.position = "relative"; + if ( this.options.helper === "original" ) { + this._setPositionRelative(); } if (this.options.addClasses){ this.element.addClass("ui-draggable"); @@ -1294,20 +1088,34 @@ $.widget("ui.draggable", $.ui.mouse, { if (this.options.disabled){ this.element.addClass("ui-draggable-disabled"); } + this._setHandleClassName(); this._mouseInit(); + }, + _setOption: function( key, value ) { + this._super( key, value ); + if ( key === "handle" ) { + this._removeHandleClassName(); + this._setHandleClassName(); + } }, _destroy: function() { + if ( ( this.helper || this.element ).is( ".ui-draggable-dragging" ) ) { + this.destroyOnClear = true; + return; + } this.element.removeClass( "ui-draggable ui-draggable-dragging ui-draggable-disabled" ); + this._removeHandleClassName(); this._mouseDestroy(); }, _mouseCapture: function(event) { - var o = this.options; + this._blurActiveElement( event ); + // among others, prevent a drag on a resizable-handle if (this.helper || o.disabled || $(event.target).closest(".ui-resizable-handle").length > 0) { return false; @@ -1319,20 +1127,54 @@ $.widget("ui.draggable", $.ui.mouse, { return false; } - $(o.iframeFix === true ? "iframe" : o.iframeFix).each(function() { - $("
") - .css({ - width: this.offsetWidth+"px", height: this.offsetHeight+"px", - position: "absolute", opacity: "0.001", zIndex: 1000 - }) - .css($(this).offset()) - .appendTo("body"); - }); + this._blockFrames( o.iframeFix === true ? "iframe" : o.iframeFix ); return true; }, + _blockFrames: function( selector ) { + this.iframeBlocks = this.document.find( selector ).map(function() { + var iframe = $( this ); + + return $( "
" ) + .css( "position", "absolute" ) + .appendTo( iframe.parent() ) + .outerWidth( iframe.outerWidth() ) + .outerHeight( iframe.outerHeight() ) + .offset( iframe.offset() )[ 0 ]; + }); + }, + + _unblockFrames: function() { + if ( this.iframeBlocks ) { + this.iframeBlocks.remove(); + delete this.iframeBlocks; + } + }, + + _blurActiveElement: function( event ) { + var document = this.document[ 0 ]; + + // Only need to blur if the event occurred on the draggable itself, see #10527 + if ( !this.handleElement.is( event.target ) ) { + return; + } + + // support: IE9 + // IE9 throws an "Unspecified error" accessing document.activeElement from an