Browse Source

Merge branch 'master' into i18n

Henry Oswald 12 năm trước cách đây
mục cha
commit
aa4f748608
28 tập tin đã thay đổi với 406 bổ sung và 104 xóa
  1. 7 5
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  2. 15 0
      services/web/app/coffee/Features/Dropbox/DropboxRouter.coffee
  3. 19 0
      services/web/app/coffee/Features/Dropbox/DropboxWebhookController.coffee
  4. 49 0
      services/web/app/coffee/Features/Dropbox/DropboxWebhookHandler.coffee
  5. 4 3
      services/web/app/coffee/Features/ServerAdmin/AdminController.coffee
  6. 11 1
      services/web/app/coffee/Features/Templates/TemplatesWebController.coffee
  7. 10 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee
  8. 4 7
      services/web/app/coffee/router.coffee
  9. 9 4
      services/web/app/views/admin.jade
  10. 0 4
      services/web/app/views/project/editor/dropbox.jade
  11. 21 18
      services/web/app/views/project/editor/file-tree.jade
  12. 11 0
      services/web/app/views/project/editor/header.jade
  13. 2 2
      services/web/app/views/project/editor/pdf.jade
  14. 3 3
      services/web/app/views/referal/bonus.jade
  15. 3 0
      services/web/app/views/templates/index.jade
  16. 3 3
      services/web/app/views/templates/template.jade
  17. 1 11
      services/web/public/coffee/ide/dropbox/controllers/DropboxController.coffee
  18. 1 0
      services/web/public/coffee/ide/pdf/PdfManager.coffee
  19. 17 0
      services/web/public/coffee/ide/pdf/controllers/PdfViewToggleController.coffee
  20. 10 2
      services/web/public/js/ace/ace.js
  21. 13 1
      services/web/public/stylesheets/app/editor/file-tree.less
  22. 0 14
      services/web/public/stylesheets/app/editor/pdf.less
  23. 14 2
      services/web/public/stylesheets/app/editor/toolbar.less
  24. 6 0
      services/web/public/stylesheets/app/templates.less
  25. 9 23
      services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  26. 48 0
      services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookControllerTests.coffee
  27. 99 0
      services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookHandlerTests.coffee
  28. 17 0
      services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee

+ 7 - 5
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -18,12 +18,14 @@ module.exports = DocumentUpdaterHandler =
 	queueChange : (project_id, doc_id, change, sl_req_id, callback = ()->)->
 	queueChange : (project_id, doc_id, change, sl_req_id, callback = ()->)->
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)
 		jsonChange = JSON.stringify change
 		jsonChange = JSON.stringify change
-		rclient.rpush keys.pendingUpdates(doc_id:doc_id), jsonChange, (error)->
+		doc_key = keys.combineProjectIdAndDocId(project_id, doc_id)
+		multi = rclient.multi()
+		multi.rpush keys.pendingUpdates(doc_id:doc_id), jsonChange
+		multi.sadd  keys.docsWithPendingUpdates, doc_key
+		multi.rpush "pending-updates-list", doc_key
+		multi.exec (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			doc_key = keys.combineProjectIdAndDocId(project_id, doc_id)
-			rclient.sadd keys.docsWithPendingUpdates, doc_key, (error) ->
-				return callback(error) if error?
-				rclient.publish "pending-updates", doc_key, callback
+			callback()
 
 
 	flushProjectToMongo: (project_id, sl_req_id, callback = (error) ->)->
 	flushProjectToMongo: (project_id, sl_req_id, callback = (error) ->)->
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)

+ 15 - 0
services/web/app/coffee/Features/Dropbox/DropboxRouter.coffee

@@ -0,0 +1,15 @@
+DropboxUserController = require './DropboxUserController'
+DropboxWebhookController = require './DropboxWebhookController'
+
+module.exports =
+	apply: (app) ->
+		app.get  '/dropbox/beginAuth', DropboxUserController.redirectUserToDropboxAuth
+		app.get  '/dropbox/completeRegistration', DropboxUserController.completeDropboxRegistration
+		app.get  '/dropbox/unlink', DropboxUserController.unlinkDropbox
+		
+		app.get  '/dropbox/webhook', DropboxWebhookController.verify
+		app.post '/dropbox/webhook', DropboxWebhookController.webhook
+		app.ignoreCsrf('post', '/dropbox/webhook')
+
+
+

+ 19 - 0
services/web/app/coffee/Features/Dropbox/DropboxWebhookController.coffee

@@ -0,0 +1,19 @@
+logger = require("logger-sharelatex")
+DropboxWebhookHandler = require("./DropboxWebhookHandler")
+
+module.exports = DropboxWebhookController =
+	verify: (req, res, next = (error) ->) ->
+		res.send(req.query.challenge)
+		
+	webhook: (req, res, next = (error) ->) ->
+		dropbox_uids = req.body?.delta?.users
+		logger.log dropbox_uids: dropbox_uids, "received webhook request from Dropbox"
+		if !dropbox_uids?
+			return res.send(400) # Bad Request
+			
+		# Do this in the background so as not to keep Dropbox waiting
+		DropboxWebhookHandler.pollDropboxUids dropbox_uids, (error) ->
+			if error?
+				logger.error err: error, dropbox_uids: dropbox_uids, "error in webhook"
+		
+		res.send(200)

+ 49 - 0
services/web/app/coffee/Features/Dropbox/DropboxWebhookHandler.coffee

@@ -0,0 +1,49 @@
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+async = require "async"
+User = require("../../models/User").User
+TpdsUpdateSender = require "../ThirdPartyDataStore/TpdsUpdateSender"
+
+redis = require('redis')
+rclient = redis.createClient(settings.redis.web.port, settings.redis.web.host)
+rclient.auth(settings.redis.web.password)
+
+module.exports = DropboxWebhookHandler =
+	pollDropboxUids: (dropbox_uids, callback = (error) ->) ->
+		jobs = []
+		for uid in dropbox_uids
+			do (uid) ->
+				jobs.push (callback) ->
+					DropboxWebhookHandler.pollDropboxUid uid, callback
+		async.series jobs, callback
+		
+	pollDropboxUid: (dropbox_uid, callback = (error) ->) ->
+		DropboxWebhookHandler._delayAndBatchPoll dropbox_uid, (error, shouldPoll) ->
+			return callback(error) if error?
+			return callback() if !shouldPoll
+			User.find {
+				"dropbox.access_token.uid": dropbox_uid.toString()
+				"features.dropbox": true
+			}, (error, users = []) ->
+				return callback(error) if error?
+				user = users[0]
+				if !user?
+					logger.log dropbox_uid: dropbox_uid, "no sharelatex user found"
+					return callback()
+				TpdsUpdateSender.pollDropboxForUser user._id, callback
+		
+	POLL_DELAY_IN_MS: 5000 # 5 seconds
+	_delayAndBatchPoll: (dropbox_uid, callback = (error, shouldPoll) ->) ->
+		rclient.set(
+			"dropbox-poll-lock:#{dropbox_uid}", "LOCK",
+			"PX", DropboxWebhookHandler.POLL_DELAY_IN_MS,
+			"NX",
+		(error, gotLock) ->
+			return callback(error) if error?
+			if gotLock
+				setTimeout () ->
+					callback(null, true)
+				, DropboxWebhookHandler.POLL_DELAY_IN_MS
+			else
+				callback(null, false)
+		)

+ 4 - 3
services/web/app/coffee/Features/ServerAdmin/AdminController.coffee

@@ -12,7 +12,7 @@ rclient.auth(Settings.redis.web.password)
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 projectEntityHandler = require('../Project/ProjectEntityHandler')
 projectEntityHandler = require('../Project/ProjectEntityHandler')
-TpdsPollingBackgroundTasks = require("../ThirdPartyDataStore/TpdsPollingBackgroundTasks")
+TpdsUpdateSender = require("../ThirdPartyDataStore/TpdsUpdateSender")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 SystemMessageManager = require("../SystemMessages/SystemMessageManager")
 SystemMessageManager = require("../SystemMessages/SystemMessageManager")
 
 
@@ -74,8 +74,9 @@ module.exports = AdminController =
 		projectEntityHandler.flushProjectToThirdPartyDataStore req.body.project_id, (err)->
 		projectEntityHandler.flushProjectToThirdPartyDataStore req.body.project_id, (err)->
 			res.send 200
 			res.send 200
 
 
-	pollUsersWithDropbox: (req, res)->
-		TpdsPollingBackgroundTasks.pollUsersWithDropbox ->
+	pollDropboxForUser: (req, res)->
+		user_id = req.body.user_id
+		TpdsUpdateSender.pollDropboxForUser user_id, () ->
 			res.send 200
 			res.send 200
 			
 			
 	createMessage: (req, res, next) ->
 	createMessage: (req, res, next) ->

+ 11 - 1
services/web/app/coffee/Features/Templates/TemplatesWebController.coffee

@@ -36,7 +36,17 @@ module.exports = TemplatesWebController =
 
 
 	proxyToTemplatesApi: (req, res)->
 	proxyToTemplatesApi: (req, res)->
 		url = req.url
 		url = req.url
-		logger.log url:url, "proxying request to templates api"
+		
+		name = req.query.name or "Template"
+		if req.query.inline?
+			disposition = "inline"
+		else
+			disposition = "attachment"
+		console.log "HEADER", "#{disposition}; filename=#{name};"
+		res.header({"content-disposition": "#{disposition}; filename=#{name}.#{req.params.file_type};"})
+		
+		logger.log url:url, template_name: name, disposition: disposition, "proxying request to templates api"
+		
 		getReq = request.get("#{settings.apis.templates_api.url}#{url}")
 		getReq = request.get("#{settings.apis.templates_api.url}#{url}")
 		getReq.pipe(res)
 		getReq.pipe(res)
 		getReq.on "error", (error) ->
 		getReq.on "error", (error) ->

+ 10 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee

@@ -87,7 +87,16 @@ module.exports =
 				title:"deleteEntity"
 				title:"deleteEntity"
 				sl_all_user_ids:JSON.stringify(allUserIds)
 				sl_all_user_ids:JSON.stringify(allUserIds)
 			queue.enqueue options.project_id, "standardHttpRequest", deleteOptions, callback
 			queue.enqueue options.project_id, "standardHttpRequest", deleteOptions, callback
-
+			
+	pollDropboxForUser: (user_id, callback = (err) ->) ->
+		metrics.inc("tpds.poll-dropbox")
+		logger.log user_id: user_id, "polling dropbox for user"
+		options =
+			method: "POST"
+			uri:"#{settings.apis.thirdPartyDataStore.url}/user/poll"
+			json:
+				user_ids: [user_id]
+		queue.enqueue "poll-dropbox:#{user_id}", "standardHttpRequest", options, callback
 
 
 getProjectsUsersIds = (project_id, callback = (err, owner_id, allUserIds)->)->
 getProjectsUsersIds = (project_id, callback = (err, owner_id, allUserIds)->)->
 	Project.findById project_id, "_id owner_ref readOnly_refs collaberator_refs", (err, project)->
 	Project.findById project_id, "_id owner_ref readOnly_refs collaberator_refs", (err, project)->

+ 4 - 7
services/web/app/coffee/router.coffee

@@ -10,7 +10,6 @@ EditorHttpController = require("./Features/Editor/EditorHttpController")
 EditorUpdatesController = require("./Features/Editor/EditorUpdatesController")
 EditorUpdatesController = require("./Features/Editor/EditorUpdatesController")
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 TpdsController = require('./Features/ThirdPartyDataStore/TpdsController')
 TpdsController = require('./Features/ThirdPartyDataStore/TpdsController')
-dropboxHandler = require('./Features/Dropbox/DropboxHandler')
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 metrics = require('./infrastructure/Metrics')
 metrics = require('./infrastructure/Metrics')
@@ -32,13 +31,14 @@ HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 FileStoreController = require("./Features/FileStore/FileStoreController")
 TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
 TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
-DropboxUserController = require("./Features/Dropbox/DropboxUserController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 ChatController = require("./Features/Chat/ChatController")
 BlogController = require("./Features/Blog/BlogController")
 BlogController = require("./Features/Blog/BlogController")
 WikiController = require("./Features/Wiki/WikiController")
 WikiController = require("./Features/Wiki/WikiController")
 ConnectedUsersController = require("./Features/ConnectedUsers/ConnectedUsersController")
 ConnectedUsersController = require("./Features/ConnectedUsers/ConnectedUsersController")
+DropboxRouter = require "./Features/Dropbox/DropboxRouter"
+dropboxHandler = require "./Features/Dropbox/DropboxHandler"
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -66,6 +66,7 @@ module.exports = class Router
 		PasswordResetRouter.apply(app)
 		PasswordResetRouter.apply(app)
 		StaticPagesRouter.apply(app)
 		StaticPagesRouter.apply(app)
 		TemplatesRouter.apply(app)
 		TemplatesRouter.apply(app)
+		DropboxRouter.apply(app)
 
 
 		app.get '/blog', BlogController.getIndexPage
 		app.get '/blog', BlogController.getIndexPage
 		app.get '/blog/*', BlogController.getPage
 		app.get '/blog/*', BlogController.getPage
@@ -80,10 +81,6 @@ module.exports = class Router
 		app.del  '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		app.del  '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		app.del  '/user', AuthenticationController.requireLogin(), UserController.deleteUser
 		app.del  '/user', AuthenticationController.requireLogin(), UserController.deleteUser
 
 
-		app.get  '/dropbox/beginAuth', DropboxUserController.redirectUserToDropboxAuth
-		app.get  '/dropbox/completeRegistration', DropboxUserController.completeDropboxRegistration
-		app.get  '/dropbox/unlink', DropboxUserController.unlinkDropbox
-
 		app.get  '/user/auth_token', AuthenticationController.requireLogin(), AuthenticationController.getAuthToken
 		app.get  '/user/auth_token', AuthenticationController.requireLogin(), AuthenticationController.getAuthToken
 		app.get  '/user/personal_info', AuthenticationController.requireLogin(allow_auth_token: true), UserInfoController.getLoggedInUsersPersonalInfo
 		app.get  '/user/personal_info', AuthenticationController.requireLogin(allow_auth_token: true), UserInfoController.getLoggedInUsersPersonalInfo
 		app.get  '/user/:user_id/personal_info', httpAuth, UserInfoController.getPersonalInfo
 		app.get  '/user/:user_id/personal_info', httpAuth, UserInfoController.getPersonalInfo
@@ -172,7 +169,7 @@ module.exports = class Router
 		app.post '/admin/dissconectAllUsers', SecurityManager.requestIsAdmin, AdminController.dissconectAllUsers
 		app.post '/admin/dissconectAllUsers', SecurityManager.requestIsAdmin, AdminController.dissconectAllUsers
 		app.post '/admin/syncUserToSubscription', SecurityManager.requestIsAdmin, AdminController.syncUserToSubscription
 		app.post '/admin/syncUserToSubscription', SecurityManager.requestIsAdmin, AdminController.syncUserToSubscription
 		app.post '/admin/flushProjectToTpds', SecurityManager.requestIsAdmin, AdminController.flushProjectToTpds
 		app.post '/admin/flushProjectToTpds', SecurityManager.requestIsAdmin, AdminController.flushProjectToTpds
-		app.post '/admin/pollUsersWithDropbox', SecurityManager.requestIsAdmin, AdminController.pollUsersWithDropbox
+		app.post '/admin/pollDropboxForUser', SecurityManager.requestIsAdmin, AdminController.pollDropboxForUser
 		app.post '/admin/messages', SecurityManager.requestIsAdmin, AdminController.createMessage
 		app.post '/admin/messages', SecurityManager.requestIsAdmin, AdminController.createMessage
 		app.post '/admin/messages/clear', SecurityManager.requestIsAdmin, AdminController.clearMessages
 		app.post '/admin/messages/clear', SecurityManager.requestIsAdmin, AdminController.clearMessages
 
 

+ 9 - 4
services/web/app/views/admin.jade

@@ -45,7 +45,7 @@ block content
 										.form-group
 										.form-group
 											button.btn-primary.btn(type='submit') Link
 											button.btn-primary.btn(type='submit') Link
 											
 											
-							tab(heading="TPDS Management")
+							tab(heading="TPDS/Dropbox Management")
 								h3 Flush project to TPDS
 								h3 Flush project to TPDS
 								.row
 								.row
 									form.col-xs-6(enctype='multipart/form-data', method='post',action='/admin/flushProjectToTpds')
 									form.col-xs-6(enctype='multipart/form-data', method='post',action='/admin/flushProjectToTpds')
@@ -56,10 +56,15 @@ block content
 										.form-group
 										.form-group
 											button.btn-primary.btn(type='submit') Flush
 											button.btn-primary.btn(type='submit') Flush
 								hr
 								hr
-								.row-spaced
-									form(enctype='multipart/form-data', method='post',action='/admin/pollUsersWithDropbox')
+								h3 Poll Dropbox for user
+								.row
+									form.col-xs-6(enctype='multipart/form-data', method='post',action='/admin/pollDropboxForUser')
 										input(name="_csrf", type="hidden", value=csrfToken)
 										input(name="_csrf", type="hidden", value=csrfToken)
-										button.btn.btn-primary(type="submit") Poll users with dropbox
+										.form-group
+											label(for='user_id') user_id 
+											input.form-control(type='text',  name='user_id', placeholder='user_id', required)
+										.form-group
+											button.btn-primary.btn(type='submit') Poll
 										
 										
 							tab(heading="System Messages")
 							tab(heading="System Messages")
 								each message in systemMessages 
 								each message in systemMessages 

+ 0 - 4
services/web/app/views/project/editor/dropbox.jade

@@ -20,10 +20,6 @@ script(type="text/ng-template", id="dropboxModalTemplate")
 
 
 
 
 			div(ng-show="dbState.hasDropboxFeature && dbState.userIsLinkedToDropbox")
 			div(ng-show="dbState.hasDropboxFeature && dbState.userIsLinkedToDropbox")
-				progressbar.progress-striped.active(value='dbState.percentageLeftTillNextPoll', type="info")
-				p
-					strong {{dbState.minsTillNextPoll}} #{translate("minutes")} 
-					span #{translate("until_db_checked_for_changes")}
 				p.small
 				p.small
 					| #{translate("this_project_will_appear_in_your_dropbox_folder_at")}
 					| #{translate("this_project_will_appear_in_your_dropbox_folder_at")}
 					strong Dropbox/sharelatex/{{ project.name }}
 					strong Dropbox/sharelatex/{{ project.name }}

+ 21 - 18
services/web/app/views/project/editor/file-tree.jade

@@ -46,14 +46,15 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 		ng-class="{ 'no-toolbar': !permissions.write }"
 		ng-class="{ 'no-toolbar': !permissions.write }"
 	)
 	)
 
 
-		div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf')")
+		div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')")
 			ul.list-unstyled.file-tree-list
 			ul.list-unstyled.file-tree-list
 				li(
 				li(
 					ng-class="{ 'selected': ui.view == 'pdf' }"
 					ng-class="{ 'selected': ui.view == 'pdf' }"
+					ng-controller="PdfViewToggleController"
 				)
 				)
 					.entity
 					.entity
 						.entity-name(
 						.entity-name(
-							ng-click="ui.view = 'pdf'"
+							ng-click="togglePdfView()"
 						)
 						)
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.fa-file-pdf-o
 							i.fa.fa-fw.fa-file-pdf-o
@@ -115,14 +116,15 @@ script(type='text/ng-template', id='entityListItemTemplate')
 				span(
 				span(
 					ng-hide="entity.renaming"
 					ng-hide="entity.renaming"
 				)  {{ entity.name }}
 				)  {{ entity.name }}
-				input(
-					ng-if="permissions.write",
-					ng-show="entity.renaming",
-					ng-model="inputs.name",
-					ng-blur="finishRenaming()",
-					select-name-when="entity.renaming",
-					on-enter="finishRenaming()"
-				)
+				span.rename-input
+					input(
+						ng-if="permissions.write",
+						ng-show="entity.renaming",
+						ng-model="inputs.name",
+						ng-blur="finishRenaming()",
+						select-name-when="entity.renaming",
+						on-enter="finishRenaming()"
+					)
 
 
 				span.dropdown(
 				span.dropdown(
 					ng-show="entity.selected",
 					ng-show="entity.selected",
@@ -197,14 +199,15 @@ script(type='text/ng-template', id='entityListItemTemplate')
 					span(
 					span(
 						ng-hide="entity.renaming"
 						ng-hide="entity.renaming"
 					)  {{ entity.name }}
 					)  {{ entity.name }}
-					input(
-						ng-if="permissions.write",
-						ng-show="entity.renaming",
-						ng-model="inputs.name",
-						ng-blur="finishRenaming()",
-						select-name-when="entity.renaming",
-						on-enter="finishRenaming()"
-					)
+					span.rename-input
+						input(
+							ng-if="permissions.write",
+							ng-show="entity.renaming",
+							ng-model="inputs.name",
+							ng-blur="finishRenaming()",
+							select-name-when="entity.renaming",
+							on-enter="finishRenaming()"
+						)
 
 
 					span.dropdown(
 					span.dropdown(
 						ng-if="permissions.write"
 						ng-if="permissions.write"

+ 11 - 0
services/web/app/views/project/editor/header.jade

@@ -14,6 +14,17 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 		tooltip-append-to-body="true"
 		tooltip-append-to-body="true"
 	)
 	)
 		i.fa.fa-fw.fa-level-up
 		i.fa.fa-fw.fa-level-up
+	span(ng-controller="PdfViewToggleController")
+		a(
+			href,
+			ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+			tooltip="PDF",
+			tooltip-placement="bottom",
+			tooltip-append-to-body="true",
+			ng-click="togglePdfView()",
+			ng-class="{ 'active': ui.view == 'pdf' }"
+		)
+			i.fa.fa-file-pdf-o
 
 
 	.toolbar-center.project-name(ng-controller="ProjectNameController")
 	.toolbar-center.project-name(ng-controller="ProjectNameController")
 		span.name(
 		span.name(

+ 2 - 2
services/web/app/views/project/editor/pdf.jade

@@ -80,12 +80,12 @@ div.full-size.pdf(ng-controller="PdfController")
 
 
 	.pdf-errors(ng-show="pdf.timedout || pdf.error")
 	.pdf-errors(ng-show="pdf.timedout || pdf.error")
 		.alert.alert-danger(ng-show="pdf.error")
 		.alert.alert-danger(ng-show="pdf.error")
-			strong #{translate("server_error")}
+			strong #{translate("server_error")} 
 			span #{translate("somthing_went_wrong_compiling")}
 			span #{translate("somthing_went_wrong_compiling")}
 
 
 		.alert.alert-danger(ng-show="pdf.timedout")
 		.alert.alert-danger(ng-show="pdf.timedout")
 			p
 			p
-				strong #{translate("timedout")}.
+				strong #{translate("timedout")}. 
 				span #{translate("proj_timed_out_reason")}
 				span #{translate("proj_timed_out_reason")}
 			p
 			p
 				a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")
 				a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")

+ 3 - 3
services/web/app/views/referal/bonus.jade

@@ -80,11 +80,11 @@ block content
 							.row.ab-bonus
 							.row.ab-bonus
 								.col-md-10.col-md-offset-1.bonus-banner
 								.col-md-10.col-md-offset-1.bonus-banner
 									- if (refered_user_count == 0)
 									- if (refered_user_count == 0)
-										p.thanks #{translate("you_not_introed_anyone_to_sl")}
+										p.thanks !{translate("you_not_introed_anyone_to_sl")}
 									- else if (refered_user_count == 1)
 									- else if (refered_user_count == 1)
-										p.thanks #{translate("you_introed_small_number", {numberOfPeople:"<strong>#{refered_user_count}</strong>"})}
+										p.thanks !{translate("you_introed_small_number", {numberOfPeople:"<strong>" + refered_user_count + "</strong>"})}
 									- else
 									- else
-										p.thanks #{translate("you_introed_high_number", {numberOfPeople:"<strong>#{refered_user_count}</strong>"})}
+										p.thanks !{translate("you_introed_high_number", {numberOfPeople:"<strong>" + refered_user_count + "</strong>"})}
 						
 						
 						script(type="text/ng-template", id="BonusLinkToUsModal")
 						script(type="text/ng-template", id="BonusLinkToUsModal")
 							.modal-header
 							.modal-header

+ 3 - 0
services/web/app/views/templates/index.jade

@@ -1,5 +1,8 @@
 extends ../layout
 extends ../layout
 
 
+block vars
+	- var meta = "Over 400 LaTeX templates for journal articles, theses, CV and resumes, posters, presentations, and much more"
+
 block content
 block content
 	.content.content-alt
 	.content.content-alt
 		.container
 		.container

+ 3 - 3
services/web/app/views/templates/template.jade

@@ -20,8 +20,8 @@ block content
 					.col-md-6
 					.col-md-6
 						.entry
 						.entry
 							.row
 							.row
-								.col-md-12
-									a(href=template.pdfUrl)
+								.col-md-12.template-large-pdf-preview
+									a(href="#{template.pdfUrl}?inline=true&name=#{template.name}")
 										img(src="#{template.previewUrl}")
 										img(src="#{template.previewUrl}")
 
 
 					.col-md-6
 					.col-md-6
@@ -33,7 +33,7 @@ block content
 								| &nbsp;
 								| &nbsp;
 
 
 								a.btn.btn-default(
 								a.btn.btn-default(
-									href=template.zipUrl,
+									href="#{template.zipUrl}?name=#{template.name}",
 									rel='nofollow', 
 									rel='nofollow', 
 									ng-click='downloadZip()',
 									ng-click='downloadZip()',
 									tooltip-placement="bottom",
 									tooltip-placement="bottom",

+ 1 - 11
services/web/public/coffee/ide/dropbox/controllers/DropboxController.coffee

@@ -27,20 +27,10 @@ define [
 
 
 		$scope.dbState = cachedState
 		$scope.dbState = cachedState
 		$scope.dbState.hasDropboxFeature = $scope.project.features.dropbox
 		$scope.dbState.hasDropboxFeature = $scope.project.features.dropbox
-		
-		calculatePollTime = ->
-			ide.socket.emit "getLastTimePollHappned", (err, lastTimePollHappened)=>
-				milisecondsSinceLastPoll = new Date().getTime() - lastTimePollHappened
-				roundedMinsSinceLastPoll = Math.round(milisecondsSinceLastPoll / ONE_MIN_MILI)
-
-				$scope.dbState.minsTillNextPoll = POLLING_INTERVAL - roundedMinsSinceLastPoll
-				$scope.dbState.percentageLeftTillNextPoll = ((roundedMinsSinceLastPoll / POLLING_INTERVAL) * 100)
-				$timeout calculatePollTime, 60 * 1000
 
 
 		ide.socket.emit "getUserDropboxLinkStatus", user_id, (err, status)=>
 		ide.socket.emit "getUserDropboxLinkStatus", user_id, (err, status)=>
 			$scope.dbState.gotLinkStatus = true
 			$scope.dbState.gotLinkStatus = true
-			if status.registered 
-				calculatePollTime()
+			if status.registered
 				$scope.dbState.userIsLinkedToDropbox = true
 				$scope.dbState.userIsLinkedToDropbox = true
 				cachedState = $scope.dbState
 				cachedState = $scope.dbState
 		
 		

+ 1 - 0
services/web/public/coffee/ide/pdf/PdfManager.coffee

@@ -1,5 +1,6 @@
 define [
 define [
 	"ide/pdf/controllers/PdfController"
 	"ide/pdf/controllers/PdfController"
+	"ide/pdf/controllers/PdfViewToggleController"
 	"ide/pdf/directives/pdfJs"
 	"ide/pdf/directives/pdfJs"
 ], () ->
 ], () ->
 	class PdfManager
 	class PdfManager

+ 17 - 0
services/web/public/coffee/ide/pdf/controllers/PdfViewToggleController.coffee

@@ -0,0 +1,17 @@
+define [
+	"base"
+], (App) ->
+	App.controller "PdfViewToggleController", ($scope) ->
+		$scope.togglePdfView = () ->
+			if $scope.ui.view == "pdf"
+				$scope.ui.view = "editor"
+			else
+				$scope.ui.view = "pdf"
+		
+		$scope.fileTreeClosed = false		
+		$scope.$on "layout:main:resize", (e, state) ->
+			if state.west.initClosed
+				$scope.fileTreeClosed = true
+			else
+				$scope.fileTreeClosed = false
+			$scope.$apply()

+ 10 - 2
services/web/public/js/ace/ace.js

@@ -11428,6 +11428,7 @@ var Editor = function(renderer, session) {
 
 
         this._signal("change", e);
         this._signal("change", e);
         this.$cursorChange();
         this.$cursorChange();
+        this.$updateHighlightActiveLine();
     };
     };
 
 
     this.onTokenizerUpdate = function(e) {
     this.onTokenizerUpdate = function(e) {
@@ -14853,8 +14854,10 @@ var VirtualRenderer = function(container, theme) {
                 this.$changedLines.lastRow = lastRow;
                 this.$changedLines.lastRow = lastRow;
         }
         }
 
 
-        if (this.$changedLines.firstRow > this.layerConfig.lastRow ||
-            this.$changedLines.lastRow < this.layerConfig.firstRow)
+        if (this.$changedLines.lastRow < this.layerConfig.firstRow)
+            this.$changedLines.lastRow = this.layerConfig.lastRow
+
+        if (this.$changedLines.firstRow > this.layerConfig.lastRow)
             return;
             return;
         this.$loop.schedule(this.CHANGE_LINES);
         this.$loop.schedule(this.CHANGE_LINES);
     };
     };
@@ -15218,6 +15221,11 @@ var VirtualRenderer = function(container, theme) {
             changes & this.CHANGE_H_SCROLL
             changes & this.CHANGE_H_SCROLL
         ) {
         ) {
             changes |= this.$computeLayerConfig();
             changes |= this.$computeLayerConfig();
+            if (config.firstRow != this.layerConfig.firstRow && config.firstRowScreen == this.layerConfig.firstRowScreen) {
+                this.scrollTop = this.scrollTop + (config.firstRow - this.layerConfig.firstRow) * this.lineHeight;
+                changes = changes | this.CHANGE_SCROLL;
+                changes |= this.$computeLayerConfig();
+            }
             config = this.layerConfig;
             config = this.layerConfig;
             this.$updateScrollBarV();
             this.$updateScrollBarV();
             if (changes & this.CHANGE_H_SCROLL)
             if (changes & this.CHANGE_H_SCROLL)

+ 13 - 1
services/web/public/stylesheets/app/editor/file-tree.less

@@ -31,6 +31,7 @@ aside#file-tree {
 
 
 		li {
 		li {
 			line-height: 2.6;
 			line-height: 2.6;
+			position: relative;
 
 
 			.entity-name {
 			.entity-name {
 				color: @gray-darker;
 				color: @gray-darker;
@@ -71,7 +72,7 @@ aside#file-tree {
 					color: @link-color;
 					color: @link-color;
 					border-right: 4px solid @link-color;
 					border-right: 4px solid @link-color;
 					font-weight: bold;
 					font-weight: bold;
-					i.fa-folder-open, i.fa-folder, i.fa-file, i.fa-image {
+					i.fa-folder-open, i.fa-folder, i.fa-file, i.fa-image, i.fa-file-pdf-o {
 						color: @link-color;
 						color: @link-color;
 					}
 					}
 					padding-right: 32px;
 					padding-right: 32px;
@@ -85,6 +86,17 @@ aside#file-tree {
 					padding: 0 12px;
 					padding: 0 12px;
 				}
 				}
 			}
 			}
+			
+			.rename-input {
+				display: block;
+				position: absolute;
+				top: 1px;
+				left: 44px;
+				right: 32px;
+				input {
+					width: 100%;
+				}
+			}
 		}
 		}
 	}
 	}
 
 

+ 0 - 14
services/web/public/stylesheets/app/editor/pdf.less

@@ -74,20 +74,6 @@
 }
 }
 
 
 .pdf .toolbar {
 .pdf .toolbar {
-	.log-btn {
-		&.active, &:active {
-			.label {
-				display: none;
-			}
-			color: white;
-			background-color: @link-color;
-			.box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225));
-			&:hover {
-				color: white;
-			}
-		}
-	}
-	
 	.toolbar-right {
 	.toolbar-right {
 		margin-right: @line-height-computed / 2;
 		margin-right: @line-height-computed / 2;
 		a {
 		a {

+ 14 - 2
services/web/public/stylesheets/app/editor/toolbar.less

@@ -16,14 +16,25 @@
 	a:not(.btn) {
 	a:not(.btn) {
 		display: inline-block;
 		display: inline-block;
 		color: @gray-light;
 		color: @gray-light;
-		padding: 5px 12px 6px;
-		margin: 0;
+		padding: 4px 10px 5px;
+		margin: 1px 2px;
 		border-radius: @border-radius-small;
 		border-radius: @border-radius-small;
 		&:hover {
 		&:hover {
 			text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
 			text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
 			color: @gray-dark;
 			color: @gray-dark;
 			text-decoration: none;
 			text-decoration: none;
 		}
 		}
+		&.active, &:active {
+			.label {
+				display: none;
+			}
+			color: white;
+			background-color: @link-color;
+			.box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225));
+			&:hover {
+				color: white;
+			}
+		}
 	}
 	}
 
 
 	.btn-full-height {
 	.btn-full-height {
@@ -81,6 +92,7 @@
 		height: 32px;
 		height: 32px;
 		a {
 		a {
 			padding: 4px 2px 2px;
 			padding: 4px 2px 2px;
+			margin: 0;
 			margin-left: 6px;
 			margin-left: 6px;
 		}
 		}
 		.toolbar-right {
 		.toolbar-right {

+ 6 - 0
services/web/public/stylesheets/app/templates.less

@@ -56,4 +56,10 @@
 		max-width: 100%;    
 		max-width: 100%;    
 		height: auto;
 		height: auto;
 	}
 	}
+}
+
+.template-large-pdf-preview {
+	img {
+		max-width: 100%;
+	}
 }
 }

+ 9 - 23
services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee

@@ -40,9 +40,10 @@ describe 'Flushing documents :', ->
 				"range":{"start":{"row":2,"column":2},"end":{"row":2,"column":3}},
 				"range":{"start":{"row":2,"column":2},"end":{"row":2,"column":3}},
 				"text":"e"
 				"text":"e"
 			}
 			}
-			@rclient.rpush = sinon.stub().callsArg(2)
-			@rclient.publish = sinon.stub().callsArg(2)
-			@rclient.sadd = sinon.stub().callsArg(2)
+			@rclient.multi = sinon.stub().returns @rclient
+			@rclient.exec = sinon.stub().callsArg(0)
+			@rclient.rpush = sinon.stub()
+			@rclient.sadd = sinon.stub()
 			@callback = sinon.stub()
 			@callback = sinon.stub()
 
 
 		describe "successfully", ->
 		describe "successfully", ->
@@ -54,9 +55,9 @@ describe 'Flushing documents :', ->
 					.calledWith("PendingUpdates:#{@doc_id}", JSON.stringify(@change))
 					.calledWith("PendingUpdates:#{@doc_id}", JSON.stringify(@change))
 					.should.equal true
 					.should.equal true
 
 
-			it "should notify the doc updater of the change via pub/sub", ->
-				@rclient.publish
-					.calledWith("pending-updates", "#{@project_id}:#{@doc_id}")
+			it "should notify the doc updater of the change via the pending-updates-list queue", ->
+				@rclient.rpush
+					.calledWith("pending-updates-list", "#{@project_id}:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
 			it "should push the doc id into the pending updates set", ->
 			it "should push the doc id into the pending updates set", ->
@@ -64,29 +65,14 @@ describe 'Flushing documents :', ->
 					.calledWith("DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}")
 					.calledWith("DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
-		describe "with error connecting to redis during push", ->
-			beforeEach ->
-				@rclient.rpush = sinon.stub().callsArgWith(2, new Error("something went wrong"))
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should return an error", ->
-				@callback.calledWithExactly(sinon.match(Error)).should.equal true
-
-		describe "with error connecting to redis during publish", ->
+		describe "with error connecting to redis during exec", ->
 			beforeEach ->
 			beforeEach ->
-				@rclient.publish = sinon.stub().callsArgWith(2, new Error("something went wrong"))
+				@rclient.exec = sinon.stub().callsArgWith(0, new Error("something went wrong"))
 				@handler.queueChange(@project_id, @doc_id, @change, @callback)
 				@handler.queueChange(@project_id, @doc_id, @change, @callback)
 
 
 			it "should return an error", ->
 			it "should return an error", ->
 				@callback.calledWithExactly(sinon.match(Error)).should.equal true
 				@callback.calledWithExactly(sinon.match(Error)).should.equal true
 
 
-		describe "with error connecting to redis during sadd", ->
-			beforeEach ->
-				@rclient.sadd = sinon.stub().callsArgWith(2, new Error("something went wrong"))
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should return an error", ->
-				@callback.calledWithExactly(sinon.match(Error)).should.equal true
 
 
 	describe 'flushProjectToMongo', ->
 	describe 'flushProjectToMongo', ->
 		beforeEach ->
 		beforeEach ->

+ 48 - 0
services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookControllerTests.coffee

@@ -0,0 +1,48 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Dropbox/DropboxWebhookController.js'
+
+describe 'DropboxWebhookController', ->
+	beforeEach ->
+
+		@DropboxWebhookController = SandboxedModule.require modulePath, requires:
+			"./DropboxWebhookHandler": @DropboxWebhookHandler = {}
+			'logger-sharelatex':
+				log:->
+				err:->
+
+	describe "verify", ->
+		beforeEach ->
+			@res =
+				send: sinon.stub()
+			@req.query =
+				challenge: @challenge = "foo"
+			@DropboxWebhookController.verify(@req, @res)
+			
+		it "should echo the challenge parameter back", ->
+			@res.send.calledWith(@challenge).should.equal true
+			
+	describe "webhook", ->
+		beforeEach ->
+			@req.body =
+				delta:
+					users: @dropbox_uids = [
+						"123456",
+						"789123"
+					]
+			@res.send = sinon.stub()
+			@DropboxWebhookHandler.pollDropboxUids = sinon.stub().callsArg(1)
+			@DropboxWebhookController.webhook(@req, @res)
+			
+		it "should poll the Dropbox uids", ->
+			@DropboxWebhookHandler.pollDropboxUids
+				.calledWith(@dropbox_uids)
+				.should.equal true
+				
+		it "should return success", ->
+			@res.send
+				.calledWith(200)
+				.should.equal true
+

+ 99 - 0
services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookHandlerTests.coffee

@@ -0,0 +1,99 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+expect = require("chai").expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Dropbox/DropboxWebhookHandler.js'
+
+describe 'DropboxWebhookHandler', ->
+	beforeEach ->
+		@DropboxWebhookHandler = SandboxedModule.require modulePath, requires:
+			"../../models/User": User: @User = {}
+			"../ThirdPartyDataStore/TpdsUpdateSender": @TpdsUpdateSender = {}
+			"redis":
+				createClient: () => @rclient =
+					auth: sinon.stub()
+			'settings-sharelatex': redis: web: {}
+			'logger-sharelatex':
+				log:->
+				err:->
+		@callback = sinon.stub()
+
+	describe "pollDropboxUids", ->
+		beforeEach (done) ->
+			@dropbox_uids = [
+				"123456",
+				"789123"
+			]
+			@DropboxWebhookHandler.pollDropboxUid = sinon.stub().callsArg(1)
+			@DropboxWebhookHandler.pollDropboxUids @dropbox_uids, done
+			
+		it "should call pollDropboxUid for each uid", ->
+			for uid in @dropbox_uids
+				@DropboxWebhookHandler.pollDropboxUid
+					.calledWith(uid)
+					.should.equal true
+					
+	describe "pollDropboxUid", ->
+		beforeEach ->
+			@dropbox_uid = "dropbox-123456"
+			@user_id = "sharelatex-user-id"
+			@User.find = sinon.stub().callsArgWith(1, null, [ _id: @user_id ])
+			@TpdsUpdateSender.pollDropboxForUser = sinon.stub().callsArg(1)
+			
+		describe "when there is already a poll in progress", () ->
+			beforeEach ->
+				@DropboxWebhookHandler._delayAndBatchPoll = sinon.stub().callsArgWith(1, null, false)
+				@DropboxWebhookHandler.pollDropboxUid @dropbox_uid, @callback
+				
+			it "should not go ahead with the poll", ->
+				@TpdsUpdateSender.pollDropboxForUser.called.should.equal false
+				
+		describe "when we are the one to do the delayed poll", () ->
+			beforeEach ->
+				@DropboxWebhookHandler._delayAndBatchPoll = sinon.stub().callsArgWith(1, null, true)
+				@DropboxWebhookHandler.pollDropboxUid @dropbox_uid, @callback
+				
+			it "should look up the user", ->
+				@User.find
+					.calledWith({ "dropbox.access_token.uid": @dropbox_uid, "features.dropbox": true })
+					.should.equal true
+					
+			it "should poll the user's Dropbox", ->
+				@TpdsUpdateSender.pollDropboxForUser
+					.calledWith(@user_id)
+					.should.equal true
+					
+			it "should call the callback", ->
+				@callback.called.should.equal true
+				
+	describe "_delayAndBatchPoll", () ->
+		beforeEach ->
+			@dropbox_uid = "dropbox-uid-123"
+			@DropboxWebhookHandler.POLL_DELAY_IN_MS = 100
+			
+		describe "when no one else is polling yet", ->
+			beforeEach (done) ->
+				@rclient.set = sinon.stub().callsArgWith(5, null, "OK")
+				@start = Date.now()
+				@DropboxWebhookHandler._delayAndBatchPoll @dropbox_uid, (error, @shouldPoll) =>
+					@end = Date.now()
+					done()
+					
+			it "should set the lock", ->
+				@rclient.set
+					.calledWith("dropbox-poll-lock:#{@dropbox_uid}", "LOCK", "PX", @DropboxWebhookHandler.POLL_DELAY_IN_MS, "NX")
+					.should.equal true
+			
+			it "should return the callback after the delay with shouldPoll=true", ->
+				@shouldPoll.should.equal true
+				expect(@end - @start).to.be.at.least(@DropboxWebhookHandler.POLL_DELAY_IN_MS)
+			
+		describe "when someone else is already polling", ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(5, null, null)
+				@DropboxWebhookHandler._delayAndBatchPoll @dropbox_uid, @callback
+			
+			it "should return the callback immediately with shouldPoll=false", ->
+				@callback.calledWith(null, false).should.equal true
+				

+ 17 - 0
services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee

@@ -105,3 +105,20 @@ describe 'TpdsUpdateSender', ->
 				job.headers.sl_all_user_ids.should.eql(JSON.stringify([collaberator_ref_1, read_only_ref_1, user_id]))
 				job.headers.sl_all_user_ids.should.eql(JSON.stringify([collaberator_ref_1, read_only_ref_1, user_id]))
 				done()
 				done()
 			@updateSender.moveEntity {project_id:project_id, project_name:oldProjectName, newProjectName:newProjectName}
 			@updateSender.moveEntity {project_id:project_id, project_name:oldProjectName, newProjectName:newProjectName}
+			
+		it "pollDropboxForUser", (done) ->
+			@requestQueuer.enqueue = sinon.stub().callsArg(3)
+			@updateSender.pollDropboxForUser user_id, (error) =>
+				@requestQueuer.enqueue
+					.calledWith(
+						"poll-dropbox:#{user_id}",
+						"standardHttpRequest",
+						{
+							method: "POST"
+							uri: "#{thirdPartyDataStoreApiUrl}/user/poll"
+							json:
+								user_ids: [user_id]
+						}
+					)
+					.should.equal true
+				done()