Просмотр исходного кода

Merge branch 'master' into pr-v2-history-ui

Paulo Reis 8 лет назад
Родитель
Сommit
637c492e6e
100 измененных файлов с 2951 добавлено и 1236 удалено
  1. 1 1
      services/web/Jenkinsfile
  2. 4 1
      services/web/Makefile
  3. 8 19
      services/web/README.md
  4. 2 2
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  5. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  6. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  7. 10 9
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  8. 8 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  9. 19 0
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  10. 93 0
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  11. 1 1
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  12. 13 1
      services/web/app/coffee/Features/Project/ProjectController.coffee
  13. 16 12
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  14. 34 18
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  15. 2 46
      services/web/app/coffee/Features/Referal/ReferalAllocator.coffee
  16. 44 0
      services/web/app/coffee/Features/Referal/ReferalFeatures.coffee
  17. 2 1
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  18. 83 0
      services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
  19. 7 0
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  20. 3 3
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  21. 2 2
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  22. 2 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  23. 8 49
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  24. 4 7
      services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee
  25. 50 0
      services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
  26. 3 6
      services/web/app/coffee/Features/Uploads/FileTypeManager.coffee
  27. 7 2
      services/web/app/coffee/Features/User/UserController.coffee
  28. 0 9
      services/web/app/coffee/Features/User/UserCreator.coffee
  29. 10 0
      services/web/app/coffee/Features/User/UserGetter.coffee
  30. 0 21
      services/web/app/coffee/Features/User/UserLocator.coffee
  31. 1 2
      services/web/app/coffee/Features/User/UserPagesController.coffee
  32. 2 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  33. 2 2
      services/web/app/coffee/Features/User/UserUpdater.coffee
  34. 5 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  35. 4 2
      services/web/app/coffee/infrastructure/Features.coffee
  36. 40 11
      services/web/app/coffee/infrastructure/LockManager.coffee
  37. 2 1
      services/web/app/coffee/infrastructure/Modules.coffee
  38. 10 8
      services/web/app/coffee/models/User.coffee
  39. 2 0
      services/web/app/coffee/router.coffee
  40. 0 67
      services/web/app/views/contact-us-modal.pug
  41. 3 2
      services/web/app/views/layout.pug
  42. 4 1
      services/web/app/views/project/editor.pug
  43. 3 0
      services/web/app/views/project/editor/editor.pug
  44. 20 0
      services/web/app/views/project/editor/left-menu.pug
  45. 34 34
      services/web/app/views/project/editor/pdf.pug
  46. 3 3
      services/web/app/views/project/list/front-chat.pug
  47. 4 1
      services/web/app/views/project/list/item.pug
  48. 1 1
      services/web/app/views/project/list/notifications.pug
  49. 4 2
      services/web/app/views/project/list/project-list.pug
  50. 1 1
      services/web/app/views/project/list/side-bar.pug
  51. 1 1
      services/web/app/views/project/list/v1-item.pug
  52. 22 19
      services/web/app/views/subscriptions/group_admin.pug
  53. 6 0
      services/web/bin/unit_test_app
  54. 5 1
      services/web/config/settings.defaults.coffee
  55. 1 0
      services/web/docker-compose.yml
  56. 2 1
      services/web/nodemon.frontend.json
  57. 293 339
      services/web/npm-shrinkwrap.json
  58. 8 0
      services/web/package.json
  59. 4 0
      services/web/public/coffee/filters/formatDate.coffee
  60. 2 0
      services/web/public/coffee/ide.coffee
  61. 1 0
      services/web/public/coffee/ide/editor/EditorManager.coffee
  62. 34 0
      services/web/public/coffee/ide/editor/components/spellMenu.coffee
  63. 75 24
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  64. 66 117
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee
  65. 56 0
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee
  66. 85 117
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
  67. 14 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  68. 2 2
      services/web/public/coffee/main/account-upgrade.coffee
  69. 0 72
      services/web/public/coffee/main/contact-us.coffee
  70. 0 28
      services/web/public/coffee/main/learn.coffee
  71. 3 0
      services/web/public/coffee/main/project-list/project-list.coffee
  72. 2 2
      services/web/public/coffee/services/algolia-search.coffee
  73. 71 0
      services/web/public/js/ace-1.2.5/theme-overleaf.js
  74. 2 1
      services/web/public/stylesheets/_ol_style_includes.less
  75. 2 0
      services/web/public/stylesheets/_style_includes.less
  76. 21 7
      services/web/public/stylesheets/app/editor.less
  77. 89 0
      services/web/public/stylesheets/app/editor/publish-modal.less
  78. 6 0
      services/web/public/stylesheets/app/editor/review-panel.less
  79. 7 0
      services/web/public/stylesheets/app/editor/rich-text.less
  80. 177 0
      services/web/public/stylesheets/app/metrics.less
  81. 6 0
      services/web/public/stylesheets/app/ol-chat.less
  82. 610 0
      services/web/public/stylesheets/components/daterange-picker.less
  83. 56 0
      services/web/test/acceptance/coffee/ExportsTests.coffee
  84. 4 3
      services/web/test/acceptance/coffee/ProjectInviteTests.coffee
  85. 5 2
      services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee
  86. 151 0
      services/web/test/acceptance/coffee/SubscriptionTests.coffee
  87. 12 0
      services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee
  88. 45 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  89. 6 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  90. 95 0
      services/web/test/acceptance/config/settings.test.coffee
  91. 5 5
      services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee
  92. 9 6
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee
  93. 9 9
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee
  94. 39 0
      services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee
  95. 202 0
      services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
  96. 4 4
      services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
  97. 28 0
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  98. 5 1
      services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
  99. 13 6
      services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
  100. 7 118
      services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee

+ 1 - 1
services/web/Jenkinsfile

@@ -101,7 +101,7 @@ pipeline {
         }
       }
       steps {
-        sh 'make minify'
+        sh 'WEBPACK_ENV=production make minify'
       }
     }
     

+ 4 - 1
services/web/Makefile

@@ -9,7 +9,7 @@ COFFEE := node_modules/.bin/coffee $(COFFEE_OPTIONS)
 GRUNT := node_modules/.bin/grunt
 APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee')
 FRONT_END_COFFEE_FILES := $(shell find public/coffee -name '*.coffee')
-TEST_COFFEE_FILES := $(shell find test -name '*.coffee')
+TEST_COFFEE_FILES := $(shell find test/*/coffee -name '*.coffee')
 MODULE_MAIN_COFFEE_FILES := $(shell find modules -type f -wholename '*main/index.coffee')
 MODULE_IDE_COFFEE_FILES := $(shell find modules -type f -wholename '*ide/index.coffee')
 COFFEE_FILES := app.coffee $(APP_COFFEE_FILES) $(FRONT_END_COFFEE_FILES) $(TEST_COFFEE_FILES)
@@ -187,6 +187,9 @@ test: test_unit test_frontend test_acceptance
 test_unit:
 	npm -q run test:unit -- ${MOCHA_ARGS}
 
+test_unit_app:
+	npm -q run test:unit:app -- ${MOCHA_ARGS}
+
 test_frontend: test_clean # stop service
 	$(MAKE) compile
 	docker-compose ${DOCKER_COMPOSE_FLAGS} up test_frontend

+ 8 - 19
services/web/README.md

@@ -3,11 +3,11 @@ web-sharelatex
 
 web-sharelatex is the front-end web service of the open-source web-based collaborative LaTeX editor,
 [ShareLaTeX](https://www.sharelatex.com).
-It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
+It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains
 a lot of logic around creating and editing projects, and account management.
 
 
-The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
+The rest of the ShareLaTeX stack, along with information about contributing can be found in the
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 
 Build process
@@ -20,7 +20,7 @@ Image processing tasks are commented out in the gruntfile and the needed package
 New Docker-based build process
 ------------------------------
 
-Note that the Grunt workflow from above should still work, but we are transitioning to a 
+Note that the Grunt workflow from above should still work, but we are transitioning to a
 Docker based testing workflow, which is documented below:
 
 ### Running the app
@@ -43,7 +43,6 @@ Unit tests can be run in the `test_unit` container defined in `docker-compose.te
 The makefile contains a short cut to run these:
 
 ```
-make install # Only needs running once, or when npm packages are updated
 make unit_test
 ```
 
@@ -60,19 +59,18 @@ Acceptance tests are run against a live service, which runs in the `acceptance_t
 To run the tests out-of-the-box, the makefile defines:
 
 ```
-make install # Only needs running once, or when npm packages are updated
-make acceptance_test
+make test_acceptance
 ```
 
 However, during development it is often useful to leave the service running for rapid iteration on the acceptance tests. This can be done with:
 
 ```
-make acceptance_test_start_service
-make acceptance_test_run # Run as many times as needed during development
-make acceptance_test_stop_service
+make test_acceptance_app_start_service
+make test_acceptance_app_run # Run as many times as needed during development
+make test_acceptance_app_stop_service
 ```
 
-`make acceptance_test` just runs these three commands in sequence.
+`make test_acceptance` just runs these three commands in sequence and then runs `make test_acceptance_modules` which performs the tests for each module in the `modules` directory. (Note that there is not currently an equivalent to the `-start` / `-run` x _n_ / `-stop` series for modules.)
 
 During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI:
 
@@ -112,12 +110,3 @@ We gratefully acknowledge [IconShock](http://www.iconshock.com) for use of the i
 in the `public/img/iconshock` directory found via
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 
-
-## Acceptance Tests
-
-To run the Acceptance tests:
-
-- set `allowPublicAccess` to true, either in the configuration file,
-  or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
-- start the server (`grunt`)
-- in a separate terminal, run `grunt test:acceptance`

+ 2 - 2
services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee

@@ -1,5 +1,5 @@
 BetaProgramHandler = require './BetaProgramHandler'
-UserLocator = require "../User/UserLocator"
+UserGetter = require "../User/UserGetter"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
 AuthenticationController = require '../Authentication/AuthenticationController'
@@ -30,7 +30,7 @@ module.exports = BetaProgramController =
 	optInPage: (req, res, next)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "showing beta participation page for user"
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			if err
 				logger.err {err, user_id}, "error fetching user"
 				return next(err)

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
 	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
 		if Settings.restrictInvitesToExistingAccounts == true
 			logger.log {email}, "checking if user exists with this email"
-			UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
+			UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
 				return callback(err) if err?
 				userExists = user? and user?._id?
 				callback(null, userExists)

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
 
 	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
 		email = invite.email
-		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+		UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
 			if err?
 				logger.err {projectId, email}, "error checking if user exists"
 				return callback(err)

+ 10 - 9
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -18,15 +18,16 @@ module.exports = CompileManager =
 			timer.done()
 			_callback(args...)
 
-		@_checkIfAutoCompileLimitHasBeenHit options.isAutoCompile, "everyone", (err, canCompile)->
-			if !canCompile
-				return callback null, "autocompile-backoff", []
-			logger.log project_id: project_id, user_id: user_id, "compiling project"
-			CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
-				return callback(error) if error?
-				if recentlyCompiled
-					logger.warn {project_id, user_id}, "project was recently compiled so not continuing"
-					return callback null, "too-recently-compiled", []
+		logger.log project_id: project_id, user_id: user_id, "compiling project"
+		CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
+			return callback(error) if error?
+			if recentlyCompiled
+				logger.warn {project_id, user_id}, "project was recently compiled so not continuing"
+				return callback null, "too-recently-compiled", []
+
+			CompileManager._checkIfAutoCompileLimitHasBeenHit options.isAutoCompile, "everyone", (err, canCompile)->
+				if !canCompile
+					return callback null, "autocompile-backoff", []
 				
 				CompileManager._ensureRootDocumentIsSet project_id, (error) ->
 					return callback(error) if error?

+ 8 - 0
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -47,6 +47,13 @@ UnsupportedExportRecordsError = (message) ->
 	return error
 UnsupportedExportRecordsError.prototype.__proto___ = Error.prototype
 
+V1HistoryNotSyncedError = (message) ->
+	error = new Error(message)
+	error.name = "V1HistoryNotSyncedError"
+	error.__proto__ = V1HistoryNotSyncedError.prototype
+	return error
+V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
+
 ProjectHistoryDisabledError = (message) ->
 	error = new Error(message)
 	error.name = "ProjectHistoryDisabledError "
@@ -62,4 +69,5 @@ module.exports = Errors =
 	UnsupportedFileTypeError: UnsupportedFileTypeError
 	UnsupportedBrandError: UnsupportedBrandError
 	UnsupportedExportRecordsError: UnsupportedExportRecordsError
+	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError

+ 19 - 0
services/web/app/coffee/Features/Exports/ExportsController.coffee

@@ -0,0 +1,19 @@
+ExportsHandler = require("./ExportsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
+logger = require("logger-sharelatex")
+
+module.exports =
+
+	exportProject: (req, res) ->
+		{project_id, brand_variation_id} = req.params
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
+			return next(err) if err?
+			logger.log 
+				user_id:user_id
+				project_id: project_id
+				brand_variation_id:brand_variation_id
+				export_v1_id:export_data.v1_id
+				"exported project"
+			res.send export_v1_id: export_data.v1_id
+

+ 93 - 0
services/web/app/coffee/Features/Exports/ExportsHandler.coffee

@@ -0,0 +1,93 @@
+ProjectGetter = require('../Project/ProjectGetter')
+ProjectLocator = require('../Project/ProjectLocator')
+UserGetter = require('../User/UserGetter')
+logger = require('logger-sharelatex')
+settings = require 'settings-sharelatex'
+async = require 'async'
+request = require 'request'
+request = request.defaults()
+settings = require 'settings-sharelatex'
+
+module.exports = ExportsHandler = self =
+
+	exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) ->
+		self._buildExport project_id, user_id, brand_variation_id, (err, export_data) ->
+			return callback(err) if err?
+			self._requestExport export_data, (err, export_v1_id) ->
+				return callback(err) if err?
+				export_data.v1_id = export_v1_id
+				# TODO: possibly store the export data in Mongo
+				callback null, export_data
+
+	_buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
+		jobs =
+			project: (cb) ->
+				ProjectGetter.getProject project_id, cb
+			# TODO: when we update async, signature will change from (cb, results) to (results, cb)
+			rootDoc: [ 'project', (cb, results) ->
+				ProjectLocator.findRootDoc {project: results.project, project_id: project_id}, cb
+			]
+			user: (cb) ->
+				UserGetter.getUser user_id, {first_name: 1, last_name: 1, email: 1}, cb
+			historyVersion: (cb) ->
+				self._requestVersion project_id, cb
+
+		async.auto jobs, (err, results) ->
+			if err?
+				logger.err err:err, project_id:project_id, user_id:user_id, brand_variation_id:brand_variation_id, "error building project export"
+				return callback(err)
+
+			{project, rootDoc, user, historyVersion} = results
+			if !rootDoc[1]?
+				err = new Error("cannot export project without root doc")
+				logger.err err:err, project_id: project_id
+				return callback(err)
+
+			export_data =
+				project:
+					id: project_id
+					rootDocPath: rootDoc[1]?.fileSystem
+					historyId: project.overleaf?.history?.id
+					historyVersion: historyVersion
+				user:
+					id: user_id
+					firstName: user.first_name
+					lastName: user.last_name
+					email: user.email
+					orcidId: null # until v2 gets ORCID
+				destination:
+					brandVariationId: brand_variation_id
+				options:
+					callbackUrl: null # for now, until we want v1 to call us back
+			callback null, export_data
+
+	_requestExport: (export_data, callback=(err, export_v1_id) ->) ->
+		request.post {
+			url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports"
+			auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+			json: export_data
+		}, (err, res, body) ->
+			if err?
+				logger.err err:err, export:export_data, "error making request to v1 export"
+				callback err
+			else if 200 <= res.statusCode < 300
+				callback null, body.exportId
+			else
+				err = new Error("v1 export returned a failure status code: #{res.statusCode}")
+				logger.err err:err, export:export_data, "v1 export returned failure status code: #{res.statusCode}"
+				callback err
+
+	_requestVersion: (project_id, callback=(err, export_v1_id) ->) ->
+		request.get {
+			url: "#{settings.apis.project_history.url}/project/#{project_id}/version"
+			json: true
+		}, (err, res, body) ->
+			if err?
+				logger.err err:err, project_id:project_id, "error making request to project history"
+				callback err
+			else if res.statusCode >= 200 and res.statusCode < 300
+				callback null, body.version
+			else
+				err = new Error("project history version returned a failure status code: #{res.statusCode}")
+				logger.err err:err, project_id:project_id, "project history version returned failure status code: #{res.statusCode}"
+				callback err

+ 1 - 1
services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee

@@ -9,7 +9,7 @@ logger = require("logger-sharelatex")
 module.exports =
 
 	generateAndEmailResetToken:(email, callback = (error, exists) ->)->
-		UserGetter.getUser email:email, (err, user)->
+		UserGetter.getUserByMainEmail email, (err, user)->
 			if err then return callback(err)
 			if !user? or user.holdingAccount
 				logger.err email:email, "user could not be found for password reset"

+ 13 - 1
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -157,7 +157,7 @@ module.exports = ProjectController =
 			hasSubscription: (cb)->
 				LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
 			user: (cb) ->
-				User.findById user_id, "featureSwitches overleaf awareOfV2", cb
+				User.findById user_id, "featureSwitches overleaf awareOfV2 features", cb
 			}, (err, results)->
 				if err?
 					logger.err err:err, "error getting data for project list page"
@@ -172,6 +172,7 @@ module.exports = ProjectController =
 				user = results.user
 				warnings = ProjectController._buildWarningsList results.v1Projects
 
+
 				ProjectController._injectProjectOwners projects, (error, projects) ->
 					return next(error) if error?
 					viewModel = {
@@ -193,6 +194,14 @@ module.exports = ProjectController =
 					else
 						viewModel.showUserDetailsArea = false
 
+					paidUser = user.features?.github and user.features?.dropbox # use a heuristic for paid account
+					freeUserProportion = 0.10
+					sampleFreeUser = parseInt(user._id.toString().slice(-2), 16) < freeUserProportion * 255
+					showFrontWidget = paidUser or sampleFreeUser
+					logger.log {paidUser, sampleFreeUser, showFrontWidget}, 'deciding whether to show front widget'
+					if showFrontWidget
+						viewModel.frontChatWidgetRoomId = Settings.overleaf?.front_chat_widget_room_id
+
 					res.render 'project/list', viewModel
 					timer.done()
 
@@ -290,6 +299,8 @@ module.exports = ProjectController =
 						autoPairDelimiters: user.ace.autoPairDelimiters
 						pdfViewer : user.ace.pdfViewer
 						syntaxValidation: user.ace.syntaxValidation
+						fontFamily: user.ace.fontFamily
+						lineHeight: user.ace.lineHeight
 					}
 					trackChangesState: project.track_changes
 					privilegeLevel: privilegeLevel
@@ -302,6 +313,7 @@ module.exports = ProjectController =
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
 					showRichText: req.query?.rt == 'true'
+					showPublishModal: req.query?.pm == 'true'
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 16 - 12
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -16,38 +16,42 @@ AnalyticsManger = require("../Analytics/AnalyticsManager")
 
 module.exports = ProjectCreationHandler =
 
-	createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
+	createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
 		metrics.inc("project-creation")
 		if arguments.length == 3
-			callback = projectHistoryId
-			projectHistoryId = null
+			callback = attributes
+			attributes = null
 
 		ProjectDetailsHandler.validateProjectName projectName, (error) ->
 			return callback(error) if error?
 			logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
-			if projectHistoryId?
-				ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, (error, project) ->
+			if attributes?
+				ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
 					return callback(error) if error?
 					AnalyticsManger.recordEvent(
-						owner_id, 'project-imported', { projectId: project._id, projectHistoryId: projectHistoryId }
+						owner_id, 'project-imported', { projectId: project._id, attributes: attributes }
 					)
 					callback(error, project)
 			else
 				HistoryManager.initializeProject (error, history) ->
 					return callback(error) if error?
-					ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, (error, project) ->
+					attributes = overleaf: history: id: history?.overleaf_id
+					ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
 						return callback(error) if error?
 						AnalyticsManger.recordEvent(
 							owner_id, 'project-created', { projectId: project._id }
 						)
 						callback(error, project)
 
-	_createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
+	_createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
 		rootFolder = new Folder {'name':'rootFolder'}
-		project = new Project
-			 owner_ref          : new ObjectId(owner_id)
-			 name               : projectName
-		project.overleaf.history.id = projectHistoryId
+
+		attributes.owner_ref = new ObjectId(owner_id)
+		attributes.name = projectName
+		project = new Project attributes
+
+		Object.assign(project, attributes)
+
 		if Settings.apis?.project_history?.displayHistoryForNewProjects
 			project.overleaf.history.display = true
 		if Settings.currentImageName?

+ 34 - 18
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -405,14 +405,41 @@ module.exports = ProjectEntityUpdateHandler = self =
 				DocumentUpdaterHandler.resyncProjectHistory project_id, projectHistoryId, docs, files, callback
 
 	_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
+		self._updateProjectStructureWithDeletedEntity project, entity, entityType, path, userId, (error) ->
+			return callback(error) if error?
+			if(entityType.indexOf("file") != -1)
+				self._cleanUpFile project, entity, path, userId, callback
+			else if (entityType.indexOf("doc") != -1)
+				self._cleanUpDoc project, entity, path, userId, callback
+			else if (entityType.indexOf("folder") != -1)
+				self._cleanUpFolder project, entity, path, userId, callback
+			else
+				callback()
+
+	# Note: the _cleanUpEntity code and _updateProjectStructureWithDeletedEntity
+	# methods both need to recursively iterate over the entities in folder.
+	# These are currently using separate implementations of the recursion. In
+	# future, these could be simplified using a common project entity iterator.
+	_updateProjectStructureWithDeletedEntity: (project, entity, entityType, entityPath, userId, callback = (error) ->) ->
+		# compute the changes to the project structure
 		if(entityType.indexOf("file") != -1)
-			self._cleanUpFile project, entity, path, userId, callback
+			changes = oldFiles: [ {file: entity, path: entityPath} ]
 		else if (entityType.indexOf("doc") != -1)
-			self._cleanUpDoc project, entity, path, userId, callback
+			changes = oldDocs: [ {doc: entity, path: entityPath} ]
 		else if (entityType.indexOf("folder") != -1)
-			self._cleanUpFolder project, entity, path, userId, callback
-		else
-			callback()
+			changes = {oldDocs: [], oldFiles: []}
+			_recurseFolder = (folder, folderPath) ->
+				for doc in folder.docs
+					changes.oldDocs.push {doc, path: path.join(folderPath, doc.name)}
+				for file in folder.fileRefs
+					changes.oldFiles.push {file, path: path.join(folderPath, file.name)}
+				for childFolder in folder.folders
+					_recurseFolder(childFolder, path.join(folderPath, childFolder.name))
+			_recurseFolder entity, entityPath
+		# now send the project structure changes to the docupdater
+		project_id = project._id.toString()
+		projectHistoryId = project.overleaf?.history?.id
+		DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	_cleanUpDoc: (project, doc, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
@@ -429,21 +456,10 @@ module.exports = ProjectEntityUpdateHandler = self =
 				return callback(error) if error?
 				DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) ->
 					return callback(error) if error?
-					DocstoreManager.deleteDoc project_id, doc_id, (error) ->
-						return callback(error) if error?
-						changes = oldDocs: [ {doc, path} ]
-						projectHistoryId = project.overleaf?.history?.id
-						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
+					DocstoreManager.deleteDoc project_id, doc_id, callback
 
 	_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
-		ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, (error) ->
-			return callback(error) if error?
-			project_id = project._id.toString()
-			projectHistoryId = project.overleaf?.history?.id
-			changes = oldFiles: [ {file, path} ]
-			# we are now keeping a copy of every file versio so we no longer delete
-			# the file from the filestore
-			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
+		ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, callback
 
 	_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
 		jobs = []

+ 2 - 46
services/web/app/coffee/Features/Referal/ReferalAllocator.coffee

@@ -1,8 +1,8 @@
 _ = require("underscore")
 logger = require('logger-sharelatex')
 User = require('../../models/User').User
-SubscriptionLocator = require "../Subscription/SubscriptionLocator"
 Settings = require "settings-sharelatex"
+FeaturesUpdater = require "../Subscription/FeaturesUpdater"
 
 module.exports = ReferalAllocator =
 	allocate: (referal_id, new_user_id, referal_source, referal_medium, callback = ->)->
@@ -25,50 +25,6 @@ module.exports = ReferalAllocator =
 					if err?
 						logger.err err:err, referal_id:referal_id, new_user_id:new_user_id, "something went wrong allocating referal"
 						return callback(err)
-					ReferalAllocator.assignBonus user._id, callback
+					FeaturesUpdater.refreshFeatures user._id, callback
 			else
 				callback()
-
-
-
-	assignBonus: (user_id, callback = (error) ->) ->
-		query = _id: user_id
-		User.findOne query, (error, user) ->
-			return callback(error) if error
-			return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
-			logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
-			if user.refered_user_count? and user.refered_user_count > 0
-				newFeatures = ReferalAllocator._calculateFeatures(user)
-				if _.isEqual newFeatures, user.features
-					return callback()
-				User.update query, { $set: features: newFeatures }, callback
-			else
-				callback()
-
-	_calculateFeatures : (user)->
-		bonusLevel = ReferalAllocator._getBonusLevel(user)
-		currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
-		betterBonusFeatures = {}
-		_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
-			currentLevel = user?.features?[key]
-			if _.isBoolean(currentLevel) and currentLevel == false
-				betterBonusFeatures[key] = bonusLevel
-
-			if _.isNumber(currentLevel)
-				if currentLevel == -1
-					return
-				bonusIsGreaterThanCurrent = currentLevel < bonusLevel
-				if bonusIsGreaterThanCurrent or bonusLevel == -1
-					betterBonusFeatures[key] = bonusLevel
-		newFeatures = _.extend(currentFeatures, betterBonusFeatures)
-		return newFeatures
-
-
-	_getBonusLevel: (user)->
-		highestBonusLevel = 0
-		_.each _.keys(Settings.bonus_features), (level)->
-			levelIsLessThanUser = level <= user.refered_user_count
-			levelIsMoreThanCurrentHighest = level >= highestBonusLevel
-			if levelIsLessThanUser and levelIsMoreThanCurrentHighest
-				highestBonusLevel = level
-		return highestBonusLevel

+ 44 - 0
services/web/app/coffee/Features/Referal/ReferalFeatures.coffee

@@ -0,0 +1,44 @@
+_ = require("underscore")
+logger = require('logger-sharelatex')
+User = require('../../models/User').User
+Settings = require "settings-sharelatex"
+
+module.exports = ReferalFeatures =
+	getBonusFeatures: (user_id, callback = (error) ->) ->
+		query = _id: user_id
+		User.findOne query, (error, user) ->
+			return callback(error) if error
+			return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
+			logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
+			if user.refered_user_count? and user.refered_user_count > 0
+				newFeatures = ReferalFeatures._calculateFeatures(user)
+				callback null, newFeatures
+			else
+				callback null, {}
+
+	_calculateFeatures : (user)->
+		bonusLevel = ReferalFeatures._getBonusLevel(user)
+		currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
+		betterBonusFeatures = {}
+		_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
+			currentLevel = user?.features?[key]
+			if _.isBoolean(currentLevel) and currentLevel == false
+				betterBonusFeatures[key] = bonusLevel
+
+			if _.isNumber(currentLevel)
+				if currentLevel == -1
+					return
+				bonusIsGreaterThanCurrent = currentLevel < bonusLevel
+				if bonusIsGreaterThanCurrent or bonusLevel == -1
+					betterBonusFeatures[key] = bonusLevel
+		newFeatures = _.extend(currentFeatures, betterBonusFeatures)
+		return newFeatures
+
+	_getBonusLevel: (user)->
+		highestBonusLevel = 0
+		_.each _.keys(Settings.bonus_features), (level)->
+			levelIsLessThanUser = level <= user.refered_user_count
+			levelIsMoreThanCurrentHighest = level >= highestBonusLevel
+			if levelIsLessThanUser and levelIsMoreThanCurrentHighest
+				highestBonusLevel = level
+		return highestBonusLevel

+ 2 - 1
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -46,7 +46,8 @@ module.exports = ReferencesHandler =
 	_isFullIndex: (project, callback = (err, result) ->) ->
 		UserGetter.getUser project.owner_ref, { features: true }, (err, owner) ->
 			return callback(err) if err?
-			callback(null, owner?.features?.references == true)
+			features = owner?.features
+			callback(null, features?.references == true || features?.referencesSearch == true)
 
 	indexAll: (projectId, callback=(err, data)->) ->
 		ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) ->

+ 83 - 0
services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee

@@ -0,0 +1,83 @@
+async = require("async")
+PlansLocator = require("./PlansLocator")
+_ = require("underscore")
+SubscriptionLocator = require("./SubscriptionLocator")
+UserFeaturesUpdater = require("./UserFeaturesUpdater")
+Settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+ReferalFeatures = require("../Referal/ReferalFeatures")
+V1SubscriptionManager = require("./V1SubscriptionManager")
+
+oneMonthInSeconds = 60 * 60 * 24 * 30
+
+module.exports = FeaturesUpdater =
+	refreshFeatures: (user_id, callback)->
+		jobs =
+			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
+			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
+			v1Features:         (cb) -> FeaturesUpdater._getV1Features user_id, cb
+			bonusFeatures:      (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
+		async.series jobs, (err, results)->
+			if err?
+				logger.err err:err, user_id:user_id,
+					"error getting subscription or group for refreshFeatures"
+				return callback(err)
+
+			{individualFeatures, groupFeatureSets, v1Features, bonusFeatures} = results
+			logger.log {user_id, individualFeatures, groupFeatureSets, v1Features, bonusFeatures}, 'merging user features'
+			featureSets = groupFeatureSets.concat [individualFeatures, v1Features, bonusFeatures]
+			features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures)
+
+			logger.log {user_id, features}, 'updating user features'
+			UserFeaturesUpdater.updateFeatures user_id, features, callback
+
+	_getIndividualFeatures: (user_id, callback = (error, features = {}) ->) ->
+		SubscriptionLocator.getUsersSubscription user_id, (err, sub)->
+			callback err, FeaturesUpdater._subscriptionToFeatures(sub)
+
+	_getGroupFeatureSets: (user_id, callback = (error, featureSets = []) ->) ->
+		SubscriptionLocator.getGroupSubscriptionsMemberOf user_id, (err, subs) ->
+			callback err, (subs or []).map FeaturesUpdater._subscriptionToFeatures
+
+	_getV1Features: (user_id, callback = (error, features = {}) ->) ->
+		V1SubscriptionManager.getPlanCodeFromV1 user_id, (err, planCode) ->
+			callback err, FeaturesUpdater._planCodeToFeatures(planCode)
+
+	_mergeFeatures: (featuresA, featuresB) ->
+		features = Object.assign({}, featuresA)
+		for key, value of featuresB
+			# Special merging logic for non-boolean features
+			if key == 'compileGroup'
+				if features['compileGroup'] == 'priority' or featuresB['compileGroup'] == 'priority'
+					features['compileGroup'] = 'priority'
+				else
+					features['compileGroup'] = 'standard'
+			else if key == 'collaborators'
+				if features['collaborators'] == -1 or featuresB['collaborators'] == -1
+					features['collaborators'] = -1
+				else
+					features['collaborators'] = Math.max(
+						features['collaborators'] or 0,
+						featuresB['collaborators'] or 0
+					)
+			else if key == 'compileTimeout'
+				features['compileTimeout'] = Math.max(
+					features['compileTimeout'] or 0,
+					featuresB['compileTimeout'] or 0
+				)
+			else
+				# Boolean keys, true is better
+				features[key] = features[key] or featuresB[key]
+		return features
+
+	_subscriptionToFeatures: (subscription) ->
+		FeaturesUpdater._planCodeToFeatures(subscription?.planCode)
+
+	_planCodeToFeatures: (planCode) ->
+		if !planCode?
+			return {}
+		plan = PlansLocator.findLocalPlanInSettings planCode
+		if !plan?
+			return {}
+		else
+			return plan.features

+ 7 - 0
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -9,6 +9,7 @@ logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 UserGetter = require "../User/UserGetter"
+FeaturesUpdater = require './FeaturesUpdater'
 
 module.exports = SubscriptionController =
 
@@ -237,3 +238,9 @@ module.exports = SubscriptionController =
 				return next(error) if error?
 				req.body = body
 				next()
+
+	refreshUserFeatures: (req, res, next) ->
+		{user_id} = req.params
+		FeaturesUpdater.refreshFeatures user_id, (error) ->
+			return next(error) if error?
+			res.sendStatus 200

+ 3 - 3
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -2,7 +2,7 @@ async = require("async")
 _ = require("underscore")
 SubscriptionUpdater = require("./SubscriptionUpdater")
 SubscriptionLocator = require("./SubscriptionLocator")
-UserLocator = require("../User/UserLocator")
+UserGetter = require("../User/UserGetter")
 LimitationsManager = require("./LimitationsManager")
 logger = require("logger-sharelatex")
 OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
@@ -21,7 +21,7 @@ module.exports = SubscriptionGroupHandler =
 			if limitReached
 				logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 				return callback(limitReached:limitReached)
-			UserLocator.findByEmail newEmail, (err, user)->
+			UserGetter.getUserByMainEmail newEmail, (err, user)->
 				return callback(err) if err?
 				if user?
 					SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@@ -50,7 +50,7 @@ module.exports = SubscriptionGroupHandler =
 				users.push buildEmailInviteViewModel(email)
 			jobs = _.map subscription.member_ids, (user_id)->
 				return (cb)->
-					UserLocator.findById user_id, (err, user)->
+					UserGetter.getUser user_id, (err, user)->
 						if err? or !user?
 							users.push _id:user_id
 							return cb()

+ 2 - 2
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -28,8 +28,8 @@ module.exports =
 	getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)->
 		Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
 
-	getGroupSubscriptionMemberOf: (user_id, callback)->
-		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
+	getGroupSubscriptionsMemberOf: (user_id, callback)->
+		Subscription.find {member_ids: user_id}, {_id:1, planCode:1}, callback
 
 	getGroupsWithEmailInvite: (email, callback) ->
 		Subscription.find { invited_emails: email }, callback

+ 2 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -46,4 +46,6 @@ module.exports =
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
 		webRouter.post "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan
 
+		# Currently used in acceptance tests only, as a way to trigger the syncing logic
+		publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures
 

+ 8 - 49
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -2,26 +2,26 @@ async = require("async")
 _ = require("underscore")
 Subscription = require('../../models/Subscription').Subscription
 SubscriptionLocator = require("./SubscriptionLocator")
-UserFeaturesUpdater = require("./UserFeaturesUpdater")
 PlansLocator = require("./PlansLocator")
 Settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
-ObjectId = require('mongoose').Types.ObjectId	
-ReferalAllocator = require("../Referal/ReferalAllocator")
+ObjectId = require('mongoose').Types.ObjectId
+FeaturesUpdater = require('./FeaturesUpdater')
 
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
 module.exports = SubscriptionUpdater =
-
 	syncSubscription: (recurlySubscription, adminUser_id, callback) ->
 		logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist"
 		SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
+			return callback(err) if err?
 			if subscription?
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist"
 				SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 			else
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does not exist, creating a new one"
 				SubscriptionUpdater._createNewSubscription adminUser_id, (err, subscription)->
+					return callback(err) if err?
 					SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 
 	addUserToGroup: (adminUser_id, user_id, callback)->
@@ -34,7 +34,7 @@ module.exports = SubscriptionUpdater =
 			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
+			FeaturesUpdater.refreshFeatures user_id, callback
 	
 	addEmailInviteToGroup: (adminUser_id, email, callback) ->
 		logger.log {adminUser_id, email}, "adding email into mongo subscription"
@@ -53,7 +53,7 @@ module.exports = SubscriptionUpdater =
 			if err?
 				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
 				return callback(err)
-			SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
+			FeaturesUpdater.refreshFeatures user_id, callback
 
 	removeEmailInviteFromGroup: (adminUser_id, email, callback)->
 		Subscription.update {
@@ -62,9 +62,6 @@ module.exports = SubscriptionUpdater =
 			invited_emails: email
 		}, callback
 
-	refreshSubscription: (user_id, callback=(err)->) ->
-		SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
-
 	deleteSubscription: (subscription_id, callback = (error) ->) ->
 		SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
 			return callback(err) if err?
@@ -72,7 +69,7 @@ module.exports = SubscriptionUpdater =
 			logger.log {subscription_id, affected_user_ids}, "deleting subscription and downgrading users"
 			Subscription.remove {_id: ObjectId(subscription_id)}, (err) ->
 				return callback(err) if err?
-				async.mapSeries affected_user_ids, SubscriptionUpdater._setUsersMinimumFeatures, callback
+				async.mapSeries affected_user_ids, FeaturesUpdater.refreshFeatures, callback
 
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
@@ -100,43 +97,5 @@ module.exports = SubscriptionUpdater =
 			allIds = _.union subscription.member_ids, [subscription.admin_id]
 			jobs = allIds.map (user_id)->
 				return (cb)->
-					SubscriptionUpdater._setUsersMinimumFeatures user_id, cb
+					FeaturesUpdater.refreshFeatures user_id, cb
 			async.series jobs, callback
-
-	_setUsersMinimumFeatures: (user_id, callback)->
-		jobs =
-			subscription: (cb)->
-				SubscriptionLocator.getUsersSubscription user_id, cb
-			groupSubscription: (cb)->
-				SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb
-			v1PlanCode: (cb) ->
-				Modules = require '../../infrastructure/Modules'
-				Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) ->
-					cb(err, results?[0] || null)
-		async.series jobs, (err, results)->
-			if err?
-				logger.err err:err, user_id:user_id,
-					"error getting subscription or group for _setUsersMinimumFeatures"
-				return callback(err)
-			{subscription, groupSubscription, v1PlanCode} = results
-			# Group Subscription
-			if groupSubscription? and groupSubscription.planCode?
-				logger.log user_id:user_id, "using group which user is memor of for features"
-				UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback
-			# Personal Subscription
-			else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
-				logger.log user_id:user_id, "using users subscription plan code for features"
-				UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
-			# V1 Subscription
-			else if v1PlanCode?
-				logger.log user_id: user_id, "using the V1 plan for features"
-				UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback
-			# Default
-			else
-				logger.log user_id:user_id, "using default features for user with no subscription or group"
-				UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)->
-					if err?
-						logger.err err:err, user_id:user_id, "Error setting minimum user feature"
-						return callback(err)
-					ReferalAllocator.assignBonus user_id, callback
-

+ 4 - 7
services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee

@@ -1,15 +1,12 @@
 logger = require("logger-sharelatex")
 User = require('../../models/User').User
-PlansLocator = require("./PlansLocator")
 
 module.exports =
-	
-	updateFeatures: (user_id, plan_code, callback = (err, features)->)->
+	updateFeatures: (user_id, features, callback = (err, features)->)->
 		conditions = _id:user_id
 		update = {}
-		plan = PlansLocator.findLocalPlanInSettings(plan_code)
-		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
+		logger.log user_id:user_id, features:features, "updating users features"
+		update["features.#{key}"] = value for key, value of features
 		User.update conditions, update, (err)->
-			callback err, plan.features
+			callback err, features
 

+ 50 - 0
services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee

@@ -0,0 +1,50 @@
+UserGetter = require "../User/UserGetter"
+request = require "request"
+settings = require "settings-sharelatex"
+logger = require "logger-sharelatex"
+
+module.exports = V1SubscriptionManager =
+	# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
+	# For this to work, we need plans in settings with plan-codes:
+	#   - 'v1_pro'
+	#   - 'v1_pro_plus'
+	#   - 'v1_student'
+	#   - 'v1_free'
+	getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
+		logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
+		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
+			return callback(err) if err?
+			v1Id = user?.overleaf?.id
+			if !v1Id?
+				logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
+				return callback(null, null)
+			V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
+				return callback(err) if err?
+				planName = body?.plan_name
+				logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
+				if planName in ['pro', 'pro_plus', 'student', 'free']
+					planName = "v1_#{planName}"
+				else
+					# Throw away 'anonymous', etc as being equivalent to null
+					planName = null
+				return callback(null, planName)
+
+	_v1PlanRequest: (v1Id, callback=(err, body)->) ->
+		if !settings?.apis?.v1
+			return callback null, null
+		request {
+			method: 'GET',
+			url: settings.apis.v1.url +
+				"/api/v1/sharelatex/users/#{v1Id}/plan_code"
+			auth:
+				user: settings.apis.v1.user
+				pass: settings.apis.v1.pass
+				sendImmediately: true
+			json: true,
+			timeout: 5 * 1000
+		}, (error, response, body) ->
+			return callback(error) if error?
+			if 200 <= response.statusCode < 300
+				return callback null, body
+			else
+				return callback new Error("non-success code from v1: #{response.statusCode}")

+ 3 - 6
services/web/app/coffee/Features/Uploads/FileTypeManager.coffee

@@ -3,7 +3,7 @@ Path = require("path")
 
 module.exports = FileTypeManager =
 	TEXT_EXTENSIONS : [
-		"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy"
+		"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy", "latexmkrc"
 	]
 
 	IGNORE_EXTENSIONS : [
@@ -34,7 +34,7 @@ module.exports = FileTypeManager =
 		extension = parts.slice(-1)[0]
 		if extension?
 			extension = extension.toLowerCase()
-		binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1)
+		binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1) and parts[0] != 'latexmkrc'
 		
 		if binaryFile
 			return callback null, true
@@ -52,13 +52,10 @@ module.exports = FileTypeManager =
 		if extension?
 			extension = extension.toLowerCase()
 		ignore = false
-		if name[0] == "."
+		if name[0] == "." and extension != 'latexmkrc'
 			ignore = true
 		if @IGNORE_EXTENSIONS.indexOf(extension) != -1
 			ignore = true
 		if @IGNORE_FILENAMES.indexOf(name) != -1
 			ignore = true
 		callback null, ignore
-
-
-

+ 7 - 2
services/web/app/coffee/Features/User/UserController.coffee

@@ -1,6 +1,6 @@
 UserHandler = require("./UserHandler")
 UserDeleter = require("./UserDeleter")
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 User = require("../../models/User").User
 newsLetterManager = require('../Newsletter/NewsletterManager')
 UserRegistrationHandler = require("./UserRegistrationHandler")
@@ -45,7 +45,7 @@ module.exports = UserController =
 
 	unsubscribe: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 
@@ -81,6 +81,11 @@ module.exports = UserController =
 				user.ace.pdfViewer = req.body.pdfViewer
 			if req.body.syntaxValidation?
 				user.ace.syntaxValidation = req.body.syntaxValidation
+			if req.body.fontFamily?
+				user.ace.fontFamily = req.body.fontFamily
+			if req.body.lineHeight?
+				user.ace.lineHeight = req.body.lineHeight
+
 			user.save (err)->
 				newEmail = req.body.email?.trim().toLowerCase()
 				if !newEmail? or newEmail == user.email or req.externalAuthenticationSystemUsed()

+ 0 - 9
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -1,19 +1,10 @@
 User = require("../../models/User").User
-UserLocator = require("./UserLocator")
 logger = require("logger-sharelatex")
 metrics = require('metrics-sharelatex')
 
 
 module.exports = UserCreator =
 
-	getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
-		self = @
-		UserLocator.findByEmail email, (err, user)->
-			if user?
-				callback(err, user)
-			else
-				self.createNewUser email:email, holdingAccount:true, callback
-
 	createNewUser: (opts, callback)->
 		logger.log opts:opts, "creating new user"
 		user = new User()

+ 10 - 0
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
 
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
+		if query?.email?
+			return callback(new Error("Don't use getUser to find user by email"), null)
 		if arguments.length == 2
 			callback = projection
 			projection = {}
@@ -19,6 +21,13 @@ module.exports = UserGetter =
 
 		db.users.findOne query, projection, callback
 
+	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
+		email = email.trim()
+		if arguments.length == 2
+			callback = projection
+			projection = {}
+		db.users.findOne email: email, projection, callback
+
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 		try
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
@@ -39,6 +48,7 @@ module.exports = UserGetter =
 
 [
 	'getUser',
+	'getUserByMainEmail',
 	'getUsers',
 	'getUserOrUserStubById'
 ].map (method) ->

+ 0 - 21
services/web/app/coffee/Features/User/UserLocator.coffee

@@ -1,21 +0,0 @@
-mongojs = require("../../infrastructure/mongojs")
-metrics = require("metrics-sharelatex")
-db = mongojs.db
-ObjectId = mongojs.ObjectId
-logger = require('logger-sharelatex')
-
-module.exports = UserLocator =
-
-	findByEmail: (email, callback)->
-		email = email.trim()
-		db.users.findOne email:email, (err, user)->
-			callback(err, user)
-
-	findById: (_id, callback)->
-		db.users.findOne _id:ObjectId(_id+""), callback
-
-[
-	'findById',
-	'findByEmail'
-].map (method) ->
-	metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger

+ 1 - 2
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -1,4 +1,3 @@
-UserLocator = require("./UserLocator")
 UserGetter = require("./UserGetter")
 UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
@@ -61,7 +60,7 @@ module.exports =
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user: user_id, "loading settings page"
 		shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			return next(err) if err?
 			res.render 'user/settings',
 				title:'account_settings'

+ 2 - 1
services/web/app/coffee/Features/User/UserRegistrationHandler.coffee

@@ -1,6 +1,7 @@
 sanitize = require('sanitizer')
 User = require("../../models/User").User
 UserCreator = require("./UserCreator")
+UserGetter = require("./UserGetter")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 NewsLetterManager = require("../Newsletter/NewsletterManager")
 async = require("async")
@@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler =
 		if !requestIsValid
 			return callback(new Error("request is not valid"))
 		userDetails.email = userDetails.email?.trim()?.toLowerCase()
-		User.findOne email:userDetails.email, (err, user)->
+		UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
 			if err?
 				return callback err
 			if user?.holdingAccount == false

+ 2 - 2
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -3,7 +3,7 @@ mongojs = require("../../infrastructure/mongojs")
 metrics = require("metrics-sharelatex")
 db = mongojs.db
 ObjectId = mongojs.ObjectId
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
@@ -18,7 +18,7 @@ module.exports = UserUpdater =
 	changeEmailAddress: (user_id, newEmail, callback)->
 		self = @
 		logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
-		UserLocator.findByEmail newEmail, (error, user) ->
+		UserGetter.getUserByMainEmail newEmail, (error, user) ->
 			if user?
 				return callback({message:"alread_exists"})
 			self.updateUser user_id.toString(), {

+ 5 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -183,6 +183,9 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		# Don't include the query string parameters, otherwise Google
 		# treats ?nocdn=true as the canonical version
 		res.locals.currentUrl = Url.parse(req.originalUrl).pathname
+		res.locals.capitalize = (string) ->
+			return "" if string.length == 0
+			return string.charAt(0).toUpperCase() + string.slice(1)
 		next()
 
 	webRouter.use (req, res, next)->
@@ -321,5 +324,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			chatMessageBorderLightness : if isOl then "40%" else "70%"
 			chatMessageBgSaturation    : if isOl then "85%" else "60%"
 			chatMessageBgLightness     : if isOl then "40%" else "97%"
+			defaultFontFamily          : if isOl then 'lucida' else 'monaco'
+			defaultLineHeight          : if isOl then 'normal' else 'compact'
 			renderAnnouncements        : !isOl
 		next()

+ 4 - 2
services/web/app/coffee/infrastructure/Features.coffee

@@ -14,9 +14,11 @@ module.exports = Features =
 				return Settings.enableGithubSync
 			when 'v1-return-message'
 				return Settings.accountMerge? and Settings.overleaf?
-			when 'publish-modal'
-				return Settings.showPublishModal
+			when 'v2-banner'
+				return Settings.showV2Banner
 			when 'custom-togglers'
 				return Settings.overleaf?
+			when 'templates'
+				return !Settings.overleaf?
 			else
 				throw new Error("unknown feature: #{feature}")

+ 40 - 11
services/web/app/coffee/infrastructure/LockManager.coffee

@@ -3,20 +3,37 @@ Settings = require('settings-sharelatex')
 RedisWrapper = require("./RedisWrapper")
 rclient = RedisWrapper.client("lock")
 logger = require "logger-sharelatex"
+os = require "os"
+crypto = require "crypto"
+
+HOST = os.hostname()
+PID = process.pid
+RND = crypto.randomBytes(4).toString('hex')
+COUNT = 0
 
 module.exports = LockManager =
 	LOCK_TEST_INTERVAL: 50 # 50ms between each test of the lock
+	MAX_TEST_INTERVAL: 1000 # back off to 1s between each test of the lock
 	MAX_LOCK_WAIT_TIME: 10000 # 10s maximum time to spend trying to get the lock
 	REDIS_LOCK_EXPIRY: 30 # seconds. Time until lock auto expires in redis
 	SLOW_EXECUTION_THRESHOLD: 5000 # 5s, if execution takes longer than this then log
 
+	# Use a signed lock value as described in
+	# http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
+	# to prevent accidental unlocking by multiple processes
+	randomLock : () ->
+		time = Date.now()
+		return "locked:host=#{HOST}:pid=#{PID}:random=#{RND}:time=#{time}:count=#{COUNT++}"
+
+	unlockScript: 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'
+
 	runWithLock: (namespace, id, runner = ( (releaseLock = (error) ->) -> ), callback = ( (error) -> )) ->
 		# This error is defined here so we get a useful stacktrace
 		slowExecutionError = new Error "slow execution during lock"
 
 		timer = new metrics.Timer("lock.#{namespace}")
 		key = "lock:web:#{namespace}:#{id}"
-		LockManager._getLock key, namespace, (error) ->
+		LockManager._getLock key, namespace, (error, lockValue) ->
 			return callback(error) if error?
 
 			# The lock can expire in redis but the process carry on. This setTimout call
@@ -27,7 +44,7 @@ module.exports = LockManager =
 			exceededLockTimeout = setTimeout countIfExceededLockTimeout, LockManager.REDIS_LOCK_EXPIRY * 1000
 
 			runner (error1, values...) ->
-				LockManager._releaseLock key, (error2) ->
+				LockManager._releaseLock key, lockValue, (error2) ->
 					clearTimeout exceededLockTimeout
 
 					timeTaken = new Date - timer.start
@@ -39,19 +56,21 @@ module.exports = LockManager =
 					return callback(error) if error?
 					callback null, values...
 
-	_tryLock : (key, namespace, callback = (err, isFree)->)->
-		rclient.set key, "locked", "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
+	_tryLock : (key, namespace, callback = (err, isFree, lockValue)->)->
+		lockValue = LockManager.randomLock()
+		rclient.set key, lockValue, "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
 			return callback(err) if err?
 			if gotLock == "OK"
 				metrics.inc "lock.#{namespace}.try.success"
-				callback err, true
+				callback err, true, lockValue
 			else
 				metrics.inc "lock.#{namespace}.try.failed"
 				logger.log key: key, redis_response: gotLock, "lock is locked"
 				callback err, false
 
-	_getLock: (key, namespace, callback = (error) ->) ->
+	_getLock: (key, namespace, callback = (error, lockValue) ->) ->
 		startTime = Date.now()
+		testInterval = LockManager.LOCK_TEST_INTERVAL
 		attempts = 0
 		do attempt = () ->
 			if Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME
@@ -59,13 +78,23 @@ module.exports = LockManager =
 				return callback(new Error("Timeout"))
 
 			attempts += 1
-			LockManager._tryLock key, namespace, (error, gotLock) ->
+			LockManager._tryLock key, namespace, (error, gotLock, lockValue) ->
 				return callback(error) if error?
 				if gotLock
 					metrics.gauge "lock.#{namespace}.get.success.tries", attempts
-					callback(null)
+					callback(null, lockValue)
 				else
-					setTimeout attempt, LockManager.LOCK_TEST_INTERVAL
+					setTimeout attempt, testInterval
+					# back off when the lock is taken to avoid overloading
+					testInterval = Math.min(testInterval * 2, LockManager.MAX_TEST_INTERVAL)
 
-	_releaseLock: (key, callback)->
-		rclient.del key, callback
+	_releaseLock: (key, lockValue, callback)->
+		rclient.eval LockManager.unlockScript, 1, key, lockValue, (err, result) ->
+			if err?
+				return callback(err)
+			else if result? and result isnt 1 # successful unlock should release exactly one key
+				logger.error {key:key, lockValue:lockValue, redis_err:err, redis_result:result}, "unlocking error"
+				metrics.inc "unlock-error"
+				return callback(new Error("tried to release timed out lock"))
+			else
+				callback(null,result)

+ 2 - 1
services/web/app/coffee/infrastructure/Modules.coffee

@@ -30,7 +30,8 @@ module.exports = Modules =
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
-				@viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
+				filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")
+				@viewIncludes[view].push pug.compileFile(filePath, doctype: "html")
 			
 	moduleIncludes: (view, locals) ->
 		compiledPartials = Modules.viewIncludes[view] or []

+ 10 - 8
services/web/app/coffee/models/User.coffee

@@ -20,14 +20,16 @@ UserSchema = new Schema
 	loginCount        : {type : Number, default: 0}
 	holdingAccount    : {type : Boolean, default: false}
 	ace               : {
-		mode        :   {type : String, default: 'none'}
-		theme       :   {type : String, default: 'textmate'}
-		fontSize    :   {type : Number, default:'12'}
-		autoComplete:   {type : Boolean, default: true}
-		autoPairDelimiters: {type : Boolean, default: true}
-		spellCheckLanguage :   {type : String, default: "en"}
-		pdfViewer   :   {type : String, default: "pdfjs"}
-		syntaxValidation   :   {type : Boolean}
+		mode               : {type : String, default: 'none'}
+		theme              : {type : String, default: 'textmate'}
+		fontSize           : {type : Number, default:'12'}
+		autoComplete       : {type : Boolean, default: true}
+		autoPairDelimiters : {type : Boolean, default: true}
+		spellCheckLanguage : {type : String, default: "en"}
+		pdfViewer          : {type : String, default: "pdfjs"}
+		syntaxValidation   : {type : Boolean}
+		fontFamily         : {type : String}
+		lineHeight         : {type : String}
 	}
 	features		  : {
 		collaborators:    { type:Number,  default: Settings.defaultFeatures.collaborators }

+ 2 - 0
services/web/app/coffee/router.coffee

@@ -26,6 +26,7 @@ HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 HistoryController = require("./Features/History/HistoryController")
+ExportsController = require("./Features/Exports/ExportsController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
@@ -206,6 +207,7 @@ module.exports = class Router
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 
+		webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

+ 0 - 67
services/web/app/views/contact-us-modal.pug

@@ -1,67 +0,0 @@
-script(type='text/ng-template', id='supportModalTemplate')
-	.modal-header
-		button.close(
-			type="button"
-			data-dismiss="modal"
-			ng-click="close()"
-		) &times;
-		h3 #{translate("contact_us")}
-	.modal-body.contact-us-modal
-		form(name="contactForm")
-			span(ng-show="sent == false")
-				.alert.alert-danger(ng-show="error") Something went wrong sending your request :(
-				label
-					| #{translate("subject")}
-				.form-group
-					input.field.text.medium.span8.form-control(
-						name="subject",
-						required
-						ng-model="form.subject",
-						ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
-						maxlength='255',
-						tabindex='1',
-						onkeyup='')
-				.contact-suggestions(ng-show="suggestions.length")
-					p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>__kb__</a>", kb: translate("knowledge_base") })} 
-					ul.contact-suggestion-list
-						li(ng-repeat="suggestion in suggestions")
-							a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
-								span(ng-bind-html="suggestion.name")
-								i.fa.fa-angle-right
-				label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
-					| #{translate("email")}
-				.form-group(ng-show="'"+getUserEmail()+"'.length < 1")
-					input.field.text.medium.span8.form-control(
-						name="email",
-						required
-						ng-model="form.email", 
-						ng-init="form.email = '"+getUserEmail()+"'",
-						type='email', spellcheck='false',
-						value='',
-						maxlength='255',
-						tabindex='2')
-				label#title12.desc
-					| #{translate("project_url")} (#{translate("optional")})
-				.form-group
-					input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
-				label.desc
-					| #{translate("contact_message_label")}
-				.form-group
-					textarea.field.text.medium.span8.form-control(
-						name="body",
-						required
-						ng-model="form.message",
-						type='text',
-						value='',
-						tabindex='4',
-						onkeyup=''
-					)
-				.form-group.text-center
-					input.btn-success.btn.btn-lg(
-						type='submit',
-						ng-disabled="contactForm.$invalid || sending",
-						ng-click="contactUs()"
-						value=translate("contact_us")
-					)
-		span(ng-show="sent")
-			p #{translate("request_sent_thank_you")}

+ 3 - 2
services/web/app/views/layout.pug

@@ -98,7 +98,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 
 		- if (settings.overleaf && settings.overleaf.useOLFreeTrial)
 			script.
-				window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial'
+				window.useV2TrialUrl = true
+
 
 	body
 		if(settings.recaptcha)
@@ -146,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js', {hashedPath:true})
 			)
 
-		include contact-us-modal
+		!= moduleIncludes("contactModal", locals)
 		include v1-tooltip
 		include sentry
 		

+ 4 - 1
services/web/app/views/project/editor.pug

@@ -157,7 +157,10 @@ block requirejs
 				},
 				"ace/ext-language_tools": {
 					"deps": ["ace/ace"]
-				}
+				},
+				"ace/keybinding-vim": {
+					"deps": ["ace/ace"]
+				},
 			},
 			"config":{
 				"moment":{

+ 3 - 0
services/web/app/views/project/editor/editor.pug

@@ -58,6 +58,7 @@ div.full-size(
 			read-only="!permissions.write",
 			file-name="editor.open_doc_name",
 			on-ctrl-enter="recompileViaKey",
+			on-save="recompileViaKey",
 			on-ctrl-j="toggleReviewPanel",
 			on-ctrl-shift-c="addNewCommentFromKbdShortcut",
 			on-ctrl-shift-a="toggleTrackChangesFromKbdShortcut",
@@ -68,6 +69,8 @@ div.full-size(
 			track-changes= "editor.trackChanges",
 			doc-id="editor.open_doc_id"
 			renderer-data="reviewPanel.rendererData"
+			font-family="settings.fontFamily || ui.defaultFontFamily"
+			line-height="settings.lineHeight || ui.defaultLineHeight"
 		)
 
 		!= moduleIncludes('editor:body', locals)

+ 20 - 0
services/web/app/views/project/editor/left-menu.pug

@@ -150,6 +150,26 @@ aside#left-menu.full-size(
 					each size in ['10','11','12','13','14','16','20','24']
 						option(value=size) #{size}px
 
+			.form-controls
+				label(for="fontFamily") #{translate("font_family")}
+				select(
+					name="fontFamily"
+					ng-model="settings.fontFamily"
+				)
+					option(value="", disabled) #{translate("default")}
+					each fontFamily in ['monaco', 'lucida']
+						option(value=fontFamily) #{capitalize(fontFamily)}
+
+			.form-controls
+				label(for="lineHeight") #{translate("line_height")}
+				select(
+					name="lineHeight"
+					ng-model="settings.lineHeight"
+				)
+					option(value="", disabled) #{translate("default")}
+					each lineHeight in ['compact', 'normal', 'wide']
+						option(value=lineHeight) #{translate(lineHeight)}
+
 			.form-controls
 				label(for="pdfViewer") #{translate("pdf_viewer")}
 				select(

+ 34 - 34
services/web/app/views/project/editor/pdf.pug

@@ -371,40 +371,40 @@ div.full-size.pdf(ng-controller="PdfController")
 					a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")
 						| #{translate("learn_how_to_make_documents_compile_quickly")}
 
-			.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
-				p(ng-if="project.owner._id == user.id")
-					strong #{translate("upgrade_for_faster_compiles")}
-				p(ng-if="project.owner._id != user.id")
-					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
-				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
-				p Plus:
-				div
-					ul.list-unstyled
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("unlimited_projects")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("full_doc_history")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_dropbox")}
-						li
-							i.fa.fa-check &nbsp;
-							| #{translate("sync_to_github")}
-						li
-							i.fa.fa-check &nbsp;
-							|#{translate("compile_larger_projects")}
-				p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
-					a.btn.btn-success.row-spaced-small(
-						href
-						ng-class="buttonClass"
-						ng-click="startFreeTrial('compile-timeout')"
-					) #{translate("start_free_trial")}
-
+			if settings.enableSubscriptions
+				.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
+					p(ng-if="project.owner._id == user.id")
+						strong #{translate("upgrade_for_faster_compiles")}
+					p(ng-if="project.owner._id != user.id")
+						strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
+					p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
+					p Plus:
+					div
+						ul.list-unstyled
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("unlimited_projects")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("full_doc_history")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_dropbox")}
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_github")}
+							li
+								i.fa.fa-check &nbsp;
+								|#{translate("compile_larger_projects")}
+					p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
+						a.btn.btn-success.row-spaced-small(
+							href
+							ng-class="buttonClass"
+							ng-click="startFreeTrial('compile-timeout')"
+						) #{translate("start_free_trial")}
 
 			.alert.alert-danger(ng-show="pdf.autoCompileDisabled")
 				p

+ 3 - 3
services/web/app/views/project/list/front-chat.pug

@@ -1,4 +1,4 @@
-- if (settings.overleaf && settings.overleaf.front_chat_widget_room_id != null)
+- if (frontChatWidgetRoomId)
 	script.
-		window.FCSP = '#{settings.overleaf.front_chat_widget_room_id}';
-	script(src="https://chat-assets.frontapp.com/v1/chat.bundle.js")
+		window.FCSP = '#{frontChatWidgetRoomId}';
+	script(src="https://chat-assets.frontapp.com/v1/chat.bundle.js")

+ 4 - 1
services/web/app/views/project/list/item.pug

@@ -38,4 +38,7 @@
 			tooltip-append-to-body="true"
 		)
 .col-xs-4
-	span.last-modified {{project.lastUpdated | formatDate}}
+	if settings.overleaf
+		span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
+	else
+		span.last-modified {{project.lastUpdated | formatDate}}

+ 1 - 1
services/web/app/views/project/list/notifications.pug

@@ -1,4 +1,4 @@
-if (user.awareOfV2 && !settings.overleaf)
+if hasFeature('v2-banner')
 	.userNotifications
 		ul.list-unstyled.notifications-list(ng-controller="OverleafV2NotificationController", ng-show="visible")
 			li.notification_entry

+ 4 - 2
services/web/app/views/project/list/project-list.pug

@@ -32,14 +32,16 @@
 						ng-click="downloadSelectedProjects()"
 					)
 						i.fa.fa-cloud-download
+					- var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete")
+					- var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o"
 					a.btn.btn-default(
 						href,
-						tooltip=translate('delete'),
+						tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`,
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						ng-click="openArchiveProjectsModal()"
 					)
-						i.fa.fa-trash-o
+						i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`)
 
 				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
 					a.btn.btn-default.dropdown-toggle(

+ 1 - 1
services/web/app/views/project/list/side-bar.pug

@@ -41,7 +41,7 @@
 		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")}
+			a(href) #{settings.overleaf ? translate("archived_projects") : translate("deleted_projects")}
 		if isShowingV1Projects
 			li(ng-class="{active: (filter == 'v1')}", ng-click="filterProjects('v1')")
 				a(href) #{translate("v1_projects")}

+ 1 - 1
services/web/app/views/project/list/v1-item.pug

@@ -22,4 +22,4 @@
 	span.owner {{ownerName()}}
 
 .col-xs-4
-	span.last-modified {{project.lastUpdated | formatDate}}
+	span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

+ 22 - 19
services/web/app/views/subscriptions/group_admin.pug

@@ -59,24 +59,29 @@ block content
 										.col-md-12.text-centered
 											small #{translate("no_members")}
 
+						hr
 						div(ng-if="users.length < groupSize", ng-cloak)
-							hr
-							p
-								.small #{translate("add_more_members")}
-								form.form
-									.row
-										.col-xs-6
-											input.form-control(
-												name="email",
-												type="text",
-												placeholder="jane@example.com, joe@example.com",
-												ng-model="inputs.emails",
-												on-enter="addMembers()"
-											)
-										.col-xs-4
-											button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
-										.col-xs-2
-											a(href="/subscription/group/export") Export CSV
+							p.small #{translate("add_more_members")}
+							form.form
+								.row
+									.col-xs-6
+										input.form-control(
+											name="email",
+											type="text",
+											placeholder="jane@example.com, joe@example.com",
+											ng-model="inputs.emails",
+											on-enter="addMembers()"
+										)
+									.col-xs-4
+										button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
+									.col-xs-2
+										a(href="/subscription/group/export") Export CSV
+											
+						div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak)
+							.row
+								.col-xs-2.col-xs-offset-10
+									a(href="/subscription/group/export") Export CSV
+									
 
 	script(type="text/javascript").
 		window.users = !{JSON.stringify(users)};
@@ -84,5 +89,3 @@ block content
 
 
 		
-
-

+ 6 - 0
services/web/bin/unit_test_app

@@ -0,0 +1,6 @@
+#!/bin/bash
+set -e;
+
+MOCHA="node_modules/.bin/mocha --exit --recursive --reporter spec"
+
+$MOCHA "$@" test/unit/js

+ 5 - 1
services/web/config/settings.defaults.coffee

@@ -156,6 +156,10 @@ module.exports = settings =
 			url: process.env['LINKED_URL_PROXY']
 		thirdpartyreferences:
 			url: "http://#{process.env['THIRD_PARTY_REFERENCES_HOST'] or 'localhost'}:3046"
+		v1:
+			url: "http://#{process.env['V1_HOST'] or 'localhost'}:5000"
+			user: 'overleaf'
+			pass: 'password'
 
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@@ -363,7 +367,7 @@ module.exports = settings =
 
 	appName: "ShareLaTeX (Community Edition)"
 	adminEmail: "placeholder@example.com"
-	
+
 	brandPrefix: "" # Set to 'ol-' for overleaf styles
 
 	nav:

+ 1 - 0
services/web/docker-compose.yml

@@ -17,6 +17,7 @@ services:
       PROJECT_HISTORY_ENABLED: 'true'
       ENABLED_LINKED_FILE_TYPES: 'url'
       LINKED_URL_PROXY: 'http://localhost:6543'
+      SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
     depends_on:
       - redis
       - mongo

+ 2 - 1
services/web/nodemon.frontend.json

@@ -8,7 +8,8 @@
   "exec": "make compile || exit 1",
   "watch": [
     "public/coffee/",
-    "public/stylesheets/"
+    "public/stylesheets/",
+    "modules/**/public/coffee/"
   ],
   "ext": "coffee less"
 }

Разница между файлами не показана из-за своего большого размера
+ 293 - 339
services/web/npm-shrinkwrap.json


+ 8 - 0
services/web/package.json

@@ -15,6 +15,7 @@
     "test:acceptance:dir": "npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@",
     "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js",
     "test:unit": "npm -q run compile && bin/unit_test $@",
+    "test:unit:app": "npm -q run compile && bin/unit_test_app $@",
     "test:frontend": "karma start",
     "compile": "make compile",
     "start": "npm -q run compile && node $NODE_APP_OPTIONS app.js",
@@ -27,6 +28,7 @@
   "dependencies": {
     "archiver": "0.9.0",
     "async": "0.6.2",
+    "backbone": "^1.3.3",
     "base64-stream": "^0.1.2",
     "basic-auth-connect": "^1.0.0",
     "bcrypt": "1.0.1",
@@ -37,16 +39,20 @@
     "cookie": "^0.2.3",
     "cookie-parser": "1.3.5",
     "csurf": "^1.8.3",
+    "d3": "^3.5.16",
     "dateformat": "1.0.4-1.2.3",
+    "daterangepicker": "^2.1.27",
     "express": "4.13.0",
     "express-http-proxy": "^1.1.0",
     "express-session": "^1.14.2",
     "fs-extra": "^4.0.2",
     "fuse.js": "^3.0.0",
+    "handlebars": "^4.0.11",
     "heapdump": "^0.3.7",
     "helmet": "^3.8.1",
     "http-proxy": "^1.8.1",
     "jade": "~1.3.1",
+    "jquery": "^1.11.1",
     "jsonwebtoken": "^8.0.1",
     "ldapjs": "^0.7.1",
     "lodash": "^4.13.1",
@@ -65,6 +71,7 @@
     "nodemailer-mandrill-transport": "^1.2.0",
     "nodemailer-sendgrid-transport": "^0.2.0",
     "nodemailer-ses-transport": "^1.3.0",
+    "nvd3": "^1.8.6",
     "optimist": "0.6.1",
     "passport": "^0.3.2",
     "passport-ldapauth": "^0.6.0",
@@ -134,6 +141,7 @@
     "grunt-postcss": "^0.8.0",
     "grunt-sed": "^0.1.1",
     "grunt-shell": "^2.1.0",
+    "handlebars-loader": "^1.7.0",
     "karma": "^2.0.0",
     "karma-chai-sinon": "^0.1.5",
     "karma-chrome-launcher": "^2.2.0",

+ 4 - 0
services/web/public/coffee/filters/formatDate.coffee

@@ -17,3 +17,7 @@ define [
 	App.filter "relativeDate", () ->
 		(date) ->
 			moment(date).calendar()
+
+	App.filter "fromNowDate", () ->
+		(date) ->
+			moment(date).fromNow()

+ 2 - 0
services/web/public/coffee/ide.coffee

@@ -80,6 +80,8 @@ define [
 			miniReviewPanelVisible: false
 			chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen
 			chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
+			defaultFontFamily: window.uiConfig.defaultFontFamily
+			defaultLineHeight: window.uiConfig.defaultLineHeight
 		}
 		$scope.user = window.user
 

+ 1 - 0
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -1,5 +1,6 @@
 define [
 	"ide/editor/Document"
+	"ide/editor/components/spellMenu"
 	"ide/editor/directives/aceEditor"
 	"ide/editor/directives/toggleSwitch"
 	"ide/editor/controllers/SavingNotificationController"

+ 34 - 0
services/web/public/coffee/ide/editor/components/spellMenu.coffee

@@ -0,0 +1,34 @@
+define ["base"], (App) ->
+  App.component "spellMenu", {
+    bindings: {
+      open: "<"
+      top: "<"
+      left: "<"
+      highlight: "<"
+      replaceWord: "&"
+      learnWord: "&"
+    }
+    template: """
+  <div
+    class="dropdown context-menu spell-check-menu"
+    ng-show="$ctrl.open"
+    ng-style="{top: $ctrl.top, left: $ctrl.left}"
+    ng-class="{open: $ctrl.open}"
+  >
+    <ul class="dropdown-menu">
+      <li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
+        <a
+          href
+          ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
+        >
+          {{ suggestion }}
+        </a>
+      </li>
+      <li class="divider"></li>
+      <li>
+        <a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
+      </li>
+    </ul>
+  </div>
+    """
+  }

+ 75 - 24
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -3,9 +3,11 @@ define [
 	"ace/ace"
 	"ace/ext-searchbox"
 	"ace/ext-modelist"
+	"ace/keybinding-vim"
 	"ide/editor/directives/aceEditor/undo/UndoManager"
 	"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
 	"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
+	"ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter"
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
 	"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
@@ -14,9 +16,10 @@ define [
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
     "ide/files/services/files"
-], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
+], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
+	Vim = ace.require('ace/keyboard/vim').Vim
 
 	# set the path for ace workers if using a CDN (from editor.pug)
 	if window.aceWorkerPath != ""
@@ -60,6 +63,7 @@ define [
 				onCtrlJ: "="       # Toggle the review panel
 				onCtrlShiftC: "="  # Add a new comment
 				onCtrlShiftA: "="  # Toggle track-changes on/off
+				onSave: "="        # Cmd/Ctrl-S or :w in Vim
 				syntaxValidation: "="
 				reviewPanel: "="
 				eventsBridge: "="
@@ -67,6 +71,8 @@ define [
 				trackChangesEnabled: "="
 				docId: "="
 				rendererData: "="
+				lineHeight: "="
+				fontFamily: "="
 			}
 			link: (scope, element, attrs) ->
 				# Don't freak out if we're already in an apply callback
@@ -98,7 +104,8 @@ define [
 
 				if scope.spellCheck # only enable spellcheck when explicitly required
 					spellCheckCache = $cacheFactory.get("spellCheck-#{scope.name}") || $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
-					spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http, $q)
+					spellCheckManager = new SpellCheckManager(scope, spellCheckCache, $http, $q, new SpellCheckAdapter(editor))
+
 				undoManager           = new UndoManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@@ -106,16 +113,26 @@ define [
 				metadataManager = new MetadataManager(scope, editor, element, metadata)
 				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
 
-				# Prevert Ctrl|Cmd-S from triggering save dialog
-				editor.commands.addCommand
-					name: "save",
-					bindKey: win: "Ctrl-S", mac: "Command-S"
-					exec: () ->
-					readOnly: true
+				scope.$watch "onSave", (callback) ->
+					if callback?
+						Vim.defineEx 'write', 'w', callback
+						editor.commands.addCommand
+							name: "save",
+							bindKey: win: "Ctrl-S", mac: "Command-S"
+							exec: callback
+							readOnly: true
+						# Not technically 'save', but Ctrl-. recompiles in OL v1
+						# so maintain compatibility
+						editor.commands.addCommand
+							name: "recompile_v1",
+							bindKey: win: "Ctrl-.", mac: "Ctrl-."
+							exec: callback
+							readOnly: true
 				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 Command-Shift-/ on OS X, which is mapped to
 				# toggleBlockComment.
@@ -266,6 +283,29 @@ define [
 						"font-size": value + "px"
 					})
 
+				scope.$watch "fontFamily", (value) ->
+					if value?
+						switch value
+							when 'monaco'
+								editor.setOption('fontFamily', '"Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", monospace')
+							when 'lucida'
+								editor.setOption('fontFamily', '"Lucida Console", monospace')
+							else
+								editor.setOption('fontFamily', null)
+
+				scope.$watch "lineHeight", (value) ->
+					if value?
+						switch value
+							when 'compact'
+								editor.container.style.lineHeight = 1.33
+							when 'normal'
+								editor.container.style.lineHeight = 1.6
+							when 'wide'
+								editor.container.style.lineHeight = 2
+							else
+								editor.container.style.lineHeight = 1.6
+						editor.renderer.updateFontSize()
+
 				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
 					if old_sharejs_doc?
 						detachFromAce(old_sharejs_doc)
@@ -323,6 +363,23 @@ define [
 						session.setScrollTop(session.getScrollTop() + 1)
 						session.setScrollTop(session.getScrollTop() - 1)
 
+				onSessionChangeForSpellCheck = (e) ->
+					spellCheckManager.onSessionChange()
+					e.oldSession?.getDocument().off "change", spellCheckManager.onChange
+					e.session.getDocument().on "change", spellCheckManager.onChange
+					e.oldSession?.off "changeScrollTop", spellCheckManager.onScroll
+					e.session.on "changeScrollTop", spellCheckManager.onScroll
+
+				initSpellCheck = () ->
+					spellCheckManager.init()
+					editor.on 'changeSession', onSessionChangeForSpellCheck
+					onSessionChangeForSpellCheck({ session: editor.getSession() }) # Force initial setup
+					editor.on 'nativecontextmenu', spellCheckManager.onContextMenu
+
+				tearDownSpellCheck = () ->
+					editor.off 'changeSession', onSessionChangeForSpellCheck
+					editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
+
 				attachToAce = (sharejs_doc) ->
 					lines = sharejs_doc.getSnapshot().split("\n")
 					session = editor.getSession()
@@ -368,6 +425,7 @@ define [
 					editor.initing = false
 					# now ready to edit document
 					editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
+					initSpellCheck()
 
 					resetScrollMargins()
 
@@ -429,6 +487,7 @@ define [
 
 				scope.$on '$destroy', () ->
 					if scope.sharejsDoc?
+						tearDownSpellCheck()
 						detachFromAce(scope.sharejsDoc)
 						session = editor.getSession()
 						session?.destroy()
@@ -450,22 +509,14 @@ define [
 						>Dismiss</a>
 					</div>
 					<div class="ace-editor-body"></div>
-					<div
-						class="dropdown context-menu spell-check-menu"
-						ng-show="spellingMenu.open"
-						ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
-						ng-class="{open: spellingMenu.open}"
-					>
-						<ul class="dropdown-menu">
-							<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
-								<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
-							</li>
-							<li class="divider"></li>
-							<li>
-								<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
-							</li>
-						</ul>
-					</div>
+					<spell-menu
+						open="spellMenu.open"
+						top="spellMenu.top"
+						left="spellMenu.left"
+						highlight="spellMenu.highlight"
+						replace-word="replaceWord(highlight, suggestion)"
+						learn-word="learnWord(highlight)"
+					></spell-menu>
 					<div
 						class="annotation-label"
 						ng-show="annotationLabel.show"

+ 66 - 117
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee

@@ -4,144 +4,93 @@ define [
 	Range = ace.require("ace/range").Range
 
 	class Highlight
-		constructor: (options) ->
-			@row = options.row
-			@column = options.column
+		constructor: (@markerId, @range, options) ->
 			@word = options.word
 			@suggestions = options.suggestions
 
 	class HighlightedWordManager
 		constructor: (@editor) ->
 			@reset()
-		
+
 		reset: () ->
-			@highlights = rows: []
-
-		addHighlight: (highlight) ->
-			unless highlight instanceof Highlight
-				highlight = new Highlight(highlight)
-			range = new Range(
-				highlight.row, highlight.column,
-				highlight.row, highlight.column + highlight.word.length
-			)
-			highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", 'text', false
-			@highlights.rows[highlight.row] ||= []
-			@highlights.rows[highlight.row].push highlight
+			@highlights?.forEach (highlight) =>
+				@editor.getSession().removeMarker(highlight.markerId)
+			@highlights = []
+
+		addHighlight: (options) ->
+			session = @editor.getSession()
+			doc = session.getDocument()
+			# Set up Range that will automatically update it's positions when the
+			# document changes
+			range = new Range()
+			range.start = doc.createAnchor({
+				row: options.row,
+				column: options.column
+			})
+			range.end = doc.createAnchor({
+				row: options.row,
+				column: options.column + options.word.length
+			})
+			# Prevent range from adding newly typed characters to the end of the word.
+			# This makes it appear as if the spelling error continues to the next word
+			# even after a space
+			range.end.$insertRight = true
+
+			markerId = session.addMarker range, "spelling-highlight", 'text', false
+
+			@highlights.push new Highlight(markerId, range, options)
 
 		removeHighlight: (highlight) ->
 			@editor.getSession().removeMarker(highlight.markerId)
-			for h, i in @highlights.rows[highlight.row]
-				if h == highlight
-					@highlights.rows[highlight.row].splice(i, 1)
+			@highlights = @highlights.filter (hl) ->
+				hl != highlight
 
 		removeWord: (word) ->
-			toRemove = []
-			for row in @highlights.rows
-				for highlight in (row || [])
-					if highlight.word == word
-						toRemove.push(highlight)
-			for highlight in toRemove
-				@removeHighlight highlight
+			@highlights.filter (highlight) ->
+				highlight.word == word
+			.forEach (highlight) =>
+				@removeHighlight(highlight)
 
-		moveHighlight: (highlight, position) ->
-			@removeHighlight highlight
-			highlight.row = position.row
-			highlight.column = position.column
-			@addHighlight highlight
-
-		clearRows: (from, to) ->
-			from ||= 0
-			to ||= @highlights.rows.length - 1
-			for row in @highlights.rows.slice(from, to + 1)
-				for highlight in (row || []).slice(0)
-					@removeHighlight highlight
-
-		insertRows: (offset, number) ->
-			# rows are inserted after offset. i.e. offset row is not modified
-			affectedHighlights = []
-			for row in @highlights.rows.slice(offset)
-				affectedHighlights.push(highlight) for highlight in (row || [])
-			for highlight in affectedHighlights
-				@moveHighlight highlight,
-					row: highlight.row + number
-					column: highlight.column
-
-		removeRows: (offset, number) ->
-			# offset is the first row to delete
-			affectedHighlights = []
-			for row in @highlights.rows.slice(offset)
-				affectedHighlights.push(highlight) for highlight in (row || [])
-			for highlight in affectedHighlights
-				if highlight.row >= offset + number
-					@moveHighlight highlight,
-						row: highlight.row - number
-						column: highlight.column
-				else
-					@removeHighlight highlight
+		clearRow: (row) ->
+			@highlights.filter (highlight) ->
+				highlight.range.start.row == row
+			.forEach (highlight) =>
+				@removeHighlight(highlight)
 
 		findHighlightWithinRange: (range) ->
-			rows = @highlights.rows.slice(range.start.row, range.end.row + 1)
-			for row in rows
-				for highlight in (row || [])
-					if @_doesHighlightOverlapRange(highlight, range.start, range.end)
-						return highlight
-			return null
-
-		applyChange: (change) ->
-			start = change.start
-			end = change.end
-			if change.action == "insert"
-				if start.row != end.row
-					rowsAdded = end.row - start.row
-					@insertRows start.row + 1, rowsAdded
-				# make a copy since we're going to modify in place
-				oldHighlights = (@highlights.rows[start.row] || []).slice(0)
-				for highlight in oldHighlights
-					if highlight.column > start.column
-						# insertion was fully before this highlight
-						@moveHighlight highlight,
-							row: end.row
-							column: highlight.column + (end.column - start.column)
-					else if highlight.column + highlight.word.length >= start.column
-						# insertion was inside this highlight
-						@removeHighlight highlight
-
-			else if change.action == "remove"
-				if start.row == end.row
-					oldHighlights = (@highlights.rows[start.row] || []).slice(0)
-				else
-					rowsRemoved = end.row - start.row
-					oldHighlights =
-						(@highlights.rows[start.row] || []).concat(
-							(@highlights.rows[end.row] || [])
-						)
-					@removeRows start.row + 1, rowsRemoved
-
-				for highlight in oldHighlights
-					if @_doesHighlightOverlapRange highlight, start, end
-						@removeHighlight highlight
-					else if @_isHighlightAfterRange highlight, start, end
-						@moveHighlight highlight,
-							row: start.row
-							column: highlight.column - (end.column - start.column)
+			_.find @highlights, (highlight) =>
+				@_doesHighlightOverlapRange highlight, range.start, range.end
 
 		_doesHighlightOverlapRange: (highlight, start, end) ->
+			highlightRow = highlight.range.start.row
+			highlightStartColumn = highlight.range.start.column
+			highlightEndColumn = highlight.range.end.column
+
 			highlightIsAllBeforeRange =
-				highlight.row < start.row or
-				(highlight.row == start.row and highlight.column + highlight.word.length <= start.column)
+				highlightRow < start.row or
+				(highlightRow == start.row and highlightEndColumn <= start.column)
 			highlightIsAllAfterRange =
-				highlight.row > end.row or
-				(highlight.row == end.row and highlight.column >= end.column)
+				highlightRow > end.row or
+				(highlightRow == end.row and highlightStartColumn >= end.column)
 			!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
 
-		_isHighlightAfterRange: (highlight, start, end) ->
-			return true if highlight.row > end.row
-			return false if highlight.row < end.row
-			highlight.column >= end.column
-			
-
+		clearHighlightTouchingRange: (range) ->
+			highlight = _.find @highlights, (hl) =>
+				@_doesHighlightTouchRange hl, range.start, range.end
+			if highlight
+				@removeHighlight highlight
 
-			
+		_doesHighlightTouchRange: (highlight, start, end) ->
+			highlightRow = highlight.range.start.row
+			highlightStartColumn = highlight.range.start.column
+			highlightEndColumn = highlight.range.end.column
 
-	
+			rangeStartIsWithinHighlight =
+				highlightStartColumn <= start.column and
+				highlightEndColumn >= start.column
+			rangeEndIsWithinHighlight =
+				highlightStartColumn <= end.column and
+				highlightEndColumn >= end.column
 
+			highlightRow == start.row and
+				(rangeStartIsWithinHighlight or rangeEndIsWithinHighlight)

+ 56 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee

@@ -0,0 +1,56 @@
+define [
+	"ace/ace"
+	"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
+], (Ace, HighlightedWordManager) ->
+	Range = ace.require('ace/range').Range
+
+	class SpellCheckAdapter
+		constructor: (@editor) ->
+			@highlightedWordManager = new HighlightedWordManager(@editor)
+
+		getLines: () ->
+			@editor.getValue().split('\n')
+
+		normalizeChangeEvent: (e) -> e
+
+		getCoordsFromContextMenuEvent: (e) ->
+			e.domEvent.stopPropagation()
+			return {
+				x: e.domEvent.clientX,
+				y: e.domEvent.clientY
+			}
+
+		preventContextMenuEventDefault: (e) ->
+			e.domEvent.preventDefault()
+
+		getHighlightFromCoords: (coords) ->
+			position = @editor.renderer.screenToTextCoordinates(coords.x, coords.y)
+			@highlightedWordManager.findHighlightWithinRange({
+				start: position
+				end: position
+			})
+
+		selectHighlightedWord: (highlight) ->
+			row = highlight.range.start.row
+			startColumn = highlight.range.start.column
+			endColumn = highlight.range.end.column
+
+			@editor.getSession().getSelection().setSelectionRange(
+				new Range(
+					row, startColumn,
+					row, endColumn
+				)
+			)
+
+		replaceWord: (highlight, newWord) =>
+			row = highlight.range.start.row
+			startColumn = highlight.range.start.column
+			endColumn = highlight.range.end.column
+
+			@editor.getSession().replace(new Range(
+				row, startColumn,
+				row, endColumn
+			), newWord)
+
+			# Bring editor back into focus after clicking on suggestion
+			@editor.focus()

+ 85 - 117
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee

@@ -1,129 +1,88 @@
-define [
-	"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
-	"ace/ace"
-], (HighlightedWordManager) ->
-	Range = ace.require("ace/range").Range
-
+define [], () ->
 	class SpellCheckManager
-		constructor: (@$scope, @editor, @element, @cache, @$http, @$q) ->
-			$(document.body).append @element.find(".spell-check-menu")
+		constructor: (@$scope, @cache, @$http, @$q, @adapter) ->
+			@$scope.spellMenu = {
+				open: false
+				top: '0px'
+				left: '0px'
+				suggestions: []
+			}
 			@inProgressRequest = null
 			@updatedLines = []
-			@highlightedWordManager = new HighlightedWordManager(@editor)
 
-			@$scope.$watch "spellCheckLanguage", (language, oldLanguage) =>
+			@$scope.$watch 'spellCheckLanguage', (language, oldLanguage) =>
 				if language != oldLanguage and oldLanguage?
 					@runFullCheck()
 
-			onChange = (e) =>
-				@runCheckOnChange(e)
-				
-			onScroll = () =>
-				@closeContextMenu()
+			@$scope.replaceWord = @adapter.replaceWord
+			@$scope.learnWord = @learnWord
 
-			@editor.on "changeSession", (e) =>
-				@highlightedWordManager.reset()
-				if @inProgressRequest?
-					@inProgressRequest.abort()
+			$(document).on 'click', (e) =>
+				@closeContextMenu() if e.which != 3 # Ignore if right click
+				return true
 
-				if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-					@runSpellCheckSoon(200)
+		init: () ->
+			@updatedLines = Array(@adapter.getLines().length).fill(true)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-				e.oldSession?.getDocument().off "change", onChange
-				e.session.getDocument().on "change", onChange
-				
-				e.oldSession?.off "changeScrollTop", onScroll
-				e.session.on "changeScrollTop", onScroll
+		isSpellCheckEnabled: () ->
+			return !!(
+				@$scope.spellCheck and
+				@$scope.spellCheckLanguage and
+				@$scope.spellCheckLanguage != ''
+			)
 
-			@$scope.spellingMenu = {left: '0px', top: '0px'}
+		onChange: (e) =>
+			if @isSpellCheckEnabled()
+				@markLinesAsUpdated(@adapter.normalizeChangeEvent(e))
 
-			@editor.on "nativecontextmenu", (e) =>
-				e.domEvent.stopPropagation();
-				@closeContextMenu(e.domEvent)
-				@openContextMenu(e.domEvent)
+				@adapter.highlightedWordManager.clearHighlightTouchingRange(e)
 
-			$(document).on "click", (e) =>
-				if e.which != 3 # Ignore if this was a right click
-					@closeContextMenu(e)
-				return true
+				@runSpellCheckSoon()
 
-			@$scope.replaceWord = (highlight, suggestion) =>
-				@replaceWord(highlight, suggestion)
+		onSessionChange: () =>
+			@adapter.highlightedWordManager.reset()
+			@inProgressRequest.abort() if @inProgressRequest?
 
-			@$scope.learnWord = (highlight) =>
-				@learnWord(highlight)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-		runFullCheck: () ->
-			@highlightedWordManager.clearRows()
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@runSpellCheck()
+		onContextMenu: (e) =>
+			@closeContextMenu()
+			@openContextMenu(e)
 
-		runCheckOnChange: (e) ->
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@highlightedWordManager.applyChange(e)
-				@markLinesAsUpdated(e)
-				@runSpellCheckSoon()
+		onScroll: () => @closeContextMenu()
 
 		openContextMenu: (e) ->
-			position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
-			highlight = @highlightedWordManager.findHighlightWithinRange
-				start: position
-				end:   position
-
-			@$scope.$apply () =>
-				@$scope.spellingMenu.highlight = highlight
-
+			coords = @adapter.getCoordsFromContextMenuEvent(e)
+			highlight = @adapter.getHighlightFromCoords(coords)
 			if highlight
-				e.stopPropagation()
-				e.preventDefault()
-
-				@editor.getSession().getSelection().setSelectionRange(
-					new Range(
-						highlight.row, highlight.column
-						highlight.row, highlight.column + highlight.word.length
-					)
-				)
-
+				@adapter.preventContextMenuEventDefault(e)
+				@adapter.selectHighlightedWord(highlight)
 				@$scope.$apply () =>
-					@$scope.spellingMenu.open = true
-					@$scope.spellingMenu.left = e.clientX + 'px'
-					@$scope.spellingMenu.top = e.clientY + 'px'
+					@$scope.spellMenu = {
+						open: true
+						top: coords.y + 'px'
+						left: coords.x + 'px'
+						highlight: highlight
+					}
 				return false
 
-		closeContextMenu: (e) ->
-			# this is triggered on scroll, so for performance only apply
-			# setting when it changes
-			if @$scope?.spellingMenu?.open != false
+		closeContextMenu: () ->
+			# This is triggered on scroll, so for performance only apply setting when
+			# it changes
+			if @$scope?.spellMenu and @$scope.spellMenu.open != false
 				@$scope.$apply () =>
-					@$scope.spellingMenu.open = false
+					@$scope.spellMenu.open = false
 
-		replaceWord: (highlight, text) ->
-			@editor.getSession().replace(new Range(
-				highlight.row, highlight.column,
-				highlight.row, highlight.column + highlight.word.length
-			), text)
-
-		learnWord: (highlight) ->
+		learnWord: (highlight) =>
 			@apiRequest "/learn", word: highlight.word
-			@highlightedWordManager.removeWord highlight.word
+			@adapter.highlightedWordManager.removeWord highlight.word
 			language = @$scope.spellCheckLanguage
 			@cache?.put("#{language}:#{highlight.word}", true)
 
-		getHighlightedWordAtCursor: () ->
-			cursor = @editor.getCursorPosition()
-			highlight = @highlightedWordManager.findHighlightWithinRange
-				start: cursor
-				end: cursor
-			return highlight
-
-		runSpellCheckSoon: (delay = 1000) ->
-			run = () =>
-				delete @timeoutId
-				@runSpellCheck(@updatedLines)
-				@updatedLines = []
-			if @timeoutId?
-				clearTimeout @timeoutId
-			@timeoutId = setTimeout run, delay
+		runFullCheck: () ->
+			@adapter.highlightedWordManager.reset()
+			@runSpellCheck() if @isSpellCheckEnabled()
 
 		markLinesAsUpdated: (change) ->
 			start = change.start
@@ -146,6 +105,15 @@ define [
 				@updatedLines[start.row] = true
 				removeLines()
 
+		runSpellCheckSoon: (delay = 1000) ->
+			run = () =>
+				delete @timeoutId
+				@runSpellCheck(@updatedLines)
+				@updatedLines = []
+			if @timeoutId?
+				clearTimeout @timeoutId
+			@timeoutId = setTimeout run, delay
+
 		runSpellCheck: (linesToProcess) ->
 			{words, positions} = @getWords(linesToProcess)
 			language = @$scope.spellCheckLanguage
@@ -178,11 +146,11 @@ define [
 			displayResult = (highlights) =>
 				if linesToProcess?
 					for shouldProcess, row in linesToProcess
-						@highlightedWordManager.clearRows(row, row) if shouldProcess
+						@adapter.highlightedWordManager.clearRow(row) if shouldProcess
 				else
-					@highlightedWordManager.clearRows()
+					@adapter.highlightedWordManager.reset()
 				for highlight in highlights
-					@highlightedWordManager.addHighlight highlight
+					@adapter.highlightedWordManager.addHighlight highlight
 
 			if not words.length
 				displayResult highlights
@@ -212,8 +180,24 @@ define [
 							seen[key] = true
 					displayResult highlights
 
+		apiRequest: (endpoint, data, callback = (error, result) ->)->
+			data.token = window.user.id
+			data._csrf = window.csrfToken
+			# use angular timeout option to cancel request if doc is changed
+			requestHandler = @$q.defer()
+			options = {timeout: requestHandler.promise}
+			httpRequest = @$http.post("/spelling" + endpoint, data, options)
+			.then (response) =>
+				callback(null, response.data)
+			.catch (response) =>
+				callback(new Error('api failure'))
+			# provide a method to cancel the request
+			abortRequest = () ->
+				requestHandler.resolve()
+			return { abort: abortRequest }
+
 		getWords: (linesToProcess) ->
-			lines = @editor.getValue().split("\n")
+			lines = @adapter.getLines()
 			words = []
 			positions = []
 			for line, row in lines
@@ -232,22 +216,6 @@ define [
 						words.push(word)
 			return words: words, positions: positions
 
-		apiRequest: (endpoint, data, callback = (error, result) ->)->
-			data.token = window.user.id
-			data._csrf = window.csrfToken
-			# use angular timeout option to cancel request if doc is changed
-			requestHandler = @$q.defer()
-			options = {timeout: requestHandler.promise}
-			httpRequest = @$http.post("/spelling" + endpoint, data, options)
-			.then (response) =>
-				callback(null, response.data)
-			.catch (response) =>
-				callback(new Error('api failure'))
-			# provide a method to cancel the request
-			abortRequest = () ->
-				requestHandler.resolve()
-			return { abort: abortRequest }
-
 		blacklistedCommandRegex: ///
 			\\                    # initial backslash
 			(label                # any of these commands

+ 14 - 0
services/web/public/coffee/ide/settings/controllers/SettingsController.coffee

@@ -8,6 +8,12 @@ define [
 		if $scope.settings.pdfViewer not in ["pdfjs", "native"]
 			$scope.settings.pdfViewer = "pdfjs"
 
+		if $scope.settings.fontFamily? and $scope.settings.fontFamily not in ["monaco", "lucida"]
+			delete $scope.settings.fontFamily
+
+		if $scope.settings.lineHeight? and $scope.settings.lineHeight not in ["compact", "normal", "wide"]
+			delete $scope.settings.lineHeight
+
 		$scope.fontSizeAsStr = (newVal) ->
 			if newVal?
 				$scope.settings.fontSize = newVal
@@ -41,6 +47,14 @@ define [
 			if syntaxValidation != oldSyntaxValidation
 				settings.saveSettings({syntaxValidation: syntaxValidation})
 
+		$scope.$watch "settings.fontFamily", (fontFamily, oldFontFamily) =>
+			if fontFamily != oldFontFamily
+				settings.saveSettings({fontFamily: fontFamily})
+
+		$scope.$watch "settings.lineHeight", (lineHeight, oldLineHeight) =>
+			if lineHeight != oldLineHeight
+				settings.saveSettings({lineHeight: lineHeight})
+
 		$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
 			return if @ignoreUpdates
 			if oldLanguage? and language != oldLanguage

+ 2 - 2
services/web/public/coffee/main/account-upgrade.coffee

@@ -11,8 +11,8 @@ define [
 			w = window.open()
 			go = () ->
 				ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-				if window.redirectToOLFreeTrialUrl?
-					url = window.redirectToOLFreeTrialUrl
+				if window.useV2TrialUrl
+					url = "/user/trial"
 				else
 					url = "/user/subscription/new?planCode=#{plan}&ssp=true"
 					if couponCode?

+ 0 - 72
services/web/public/coffee/main/contact-us.coffee

@@ -1,79 +1,7 @@
 define [
 	"base"
 	"libs/platform"
-	"services/algolia-search"
 ], (App, platform) ->
-	App.controller 'ContactModal', ($scope, $modal) ->
-		$scope.contactUsModal = () ->
-			modalInstance = $modal.open(
-				templateUrl: "supportModalTemplate"
-				controller: "SupportModalController"
-			)
-
-	App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
-		$scope.form = {}
-		$scope.sent = false
-		$scope.sending = false
-		$scope.suggestions = [];
-
-		_handleSearchResults = (success, results) ->
-			suggestions = for hit in results.hits
-				page_underscored = hit.pageName.replace(/\s/g,'_')
-
-				suggestion = 
-					url :"/learn/kb/#{page_underscored}"
-					name : hit._highlightResult.pageName.value
-
-			event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
-
-			$scope.$applyAsync () -> 
-				$scope.suggestions = suggestions
-
-		$scope.contactUs = ->
-			if !$scope.form.email? or $scope.form.email == ""
-				console.log "email not set"
-				return
-			$scope.sending = true
-			ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
-			message = $scope.form.message
-			if $scope.form.project_url?
-				message	= "#{message}\n\n project_url = #{$scope.form.project_url}" 
-			params =
-				email: $scope.form.email
-				message: message or ""
-				subject: $scope.form.subject + " - [#{ticketNumber}]"
-				labels: "support"
-				about: "<div>browser: #{platform?.name} #{platform?.version}</div>
-						<div>os: #{platform?.os?.family} #{platform?.os?.version}</div>"
-
-			Groove.createTicket params, (response)->
-				$scope.sending = false
-				if response.responseText == "" # Blocked request or similar
-					$scope.error = true
-				else
-					data = JSON.parse(response.responseText)
-					if data.errors?
-						$scope.error = true
-					else
-						$scope.sent = true
-				$scope.$apply()
-
-		$scope.$watch "form.subject", (newVal, oldVal) ->
-			if newVal and newVal != oldVal and newVal.length > 3
-				algoliaSearch.searchKB newVal, _handleSearchResults, { 
-					hitsPerPage: 3
-					typoTolerance: 'strict'
-				}
-			else
-				$scope.suggestions = [];
-
-		$scope.clickSuggestionLink = (url) ->
-			event_tracking.sendMB "contact-form-suggestions-clicked", { url }
-
-		$scope.close = () ->
-			$modalInstance.close()
-
-
 	App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
 
 		$scope.form = {}

+ 0 - 28
services/web/public/coffee/main/learn.coffee

@@ -55,32 +55,4 @@ define [
 					hits = _.map response.hits, buildHitViewModel
 					updateHits hits
 
-		$scope.showMissingTemplateModal = () ->
-			modalInstance = $modal.open(
-				templateUrl: "missingWikiPageModal"
-				controller: "MissingWikiPageController"
-			)
 
-
-	App.controller 'MissingWikiPageController', ($scope, $modalInstance) ->
-		$scope.form = {}
-		$scope.sent = false
-		$scope.sending = false
-		$scope.contactUs = ->
-			if !$scope.form.message?
-				console.log "message not set"
-				return
-			$scope.sending = true
-			ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
-			params =
-				email: $scope.form.email or "support@sharelatex.com"
-				message: $scope.form.message or ""
-				subject: "new wiki page sujection - [#{ticketNumber}]"
-				labels: "support wiki"
-
-			Groove.createTicket params, (err, json)->
-				$scope.sent = true
-				$scope.$apply()
-
-		$scope.close = () ->
-			$modalInstance.close()

+ 3 - 0
services/web/public/coffee/main/project-list/project-list.coffee

@@ -8,6 +8,7 @@ define [
 		$scope.notifications = window.data.notifications
 		$scope.allSelected = false
 		$scope.selectedProjects = []
+		$scope.isArchiveableProjectSelected = false
 		$scope.filter = "all"
 		$scope.predicate = "lastUpdated"
 		$scope.nUntagged = 0
@@ -85,6 +86,8 @@ define [
 
 		$scope.updateSelectedProjects = () ->
 			$scope.selectedProjects = $scope.projects.filter (project) -> project.selected
+			$scope.isArchiveableProjectSelected = $scope.selectedProjects.some (project) ->
+				window.user_id == project.owner._id
 
 		$scope.getSelectedProjects = () ->
 			$scope.selectedProjects

+ 2 - 2
services/web/public/coffee/services/algolia-search.coffee

@@ -8,7 +8,7 @@ define [
 			kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
 
 		service =
-			searchWiki: wikiIdx.search.bind(wikiIdx)
-			searchKB: kbIdx.search.bind(kbIdx)
+			searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null
+			searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null
 		
 		return service

+ 71 - 0
services/web/public/js/ace-1.2.5/theme-overleaf.js

@@ -0,0 +1,71 @@
+ace.define("ace/theme/overleaf",["require","exports","module","ace/lib/dom"], function(require, exports, module) {
+"use strict";
+
+exports.isDark = false;
+exports.cssClass = "ace-overleaf";
+exports.cssText = ".ace-overleaf .ace_gutter {\
+background: #f0f0f0;\
+color: #333;\
+}\
+.ace-overleaf .ace_print-margin {\
+width: 1px;\
+background: #e8e8e8;\
+}\
+.ace-overleaf {\
+background-color: #FFFFFF;\
+color: black;\
+}\
+.ace-overleaf .ace_cursor {\
+color: black;\
+}\
+.ace-overleaf .ace_marker-layer .ace_selection {\
+background: rgb(181, 213, 255);\
+}\
+.ace-overleaf.ace_multiselect .ace_selection.ace_start {\
+box-shadow: 0 0 3px 0px white;\
+}\
+.ace-overleaf .ace_marker-layer .ace_step {\
+background: rgb(252, 255, 0);\
+}\
+.ace-overleaf .ace_marker-layer .ace_bracket {\
+border: 1px solid #5A5CAD;\
+}\
+.ace-overleaf .ace_marker-layer .ace_active-line {\
+background: rgba(0, 0, 0, 0.07);\
+}\
+.ace-overleaf .ace_gutter-active-line {\
+background-color: #dcdcdc;\
+}\
+.ace-overleaf .ace_marker-layer .ace_selected-word {\
+background: rgb(250, 250, 255);\
+border: 1px solid rgb(200, 200, 250);\
+}\
+.ace-overleaf .ace_fold {\
+background-color: #6B72E6;\
+}\
+.ace-overleaf .ace_comment {\
+color: #0080FF;\
+font-style: italic;\
+}\
+.ace-overleaf .ace_storage,\
+.ace-overleaf .ace_keyword {\
+color: #3F7F7F;\
+}\
+.ace-overleaf .ace_variable,\
+.ace-overleaf .ace_string {\
+color: #5A5CAD;\
+}\
+";
+exports.$id = "ace/theme/overleaf";
+
+var dom = require("../lib/dom");
+dom.importCssString(exports.cssText, exports.cssClass);
+});
+                (function() {
+                    ace.require(["ace/theme/overleaf"], function(m) {
+                        if (typeof module == "object" && typeof exports == "object" && module) {
+                            module.exports = m;
+                        }
+                    });
+                })();
+            

+ 2 - 1
services/web/public/stylesheets/_ol_style_includes.less

@@ -1,2 +1,3 @@
 @import "app/sidebar-v2-dash-pane.less";
-@import "app/front-chat-widget.less";
+@import "app/front-chat-widget.less";
+@import "app/ol-chat.less";

+ 2 - 0
services/web/public/stylesheets/_style_includes.less

@@ -47,6 +47,7 @@
 @import "components/tooltip.less";
 @import "components/popovers.less";
 @import "components/carousel.less";
+@import "components/daterange-picker";
 
 // ngTagsInput
 @import "components/tags-input.less";
@@ -80,6 +81,7 @@
 @import "app/error-pages.less";
 @import "app/v1-badge.less";
 @import "app/editor/history-v2.less";
+@import "app/metrics.less";
 
 // Vendor CSS
 @import	"../js/libs/pdfListView/TextLayer.css";

+ 21 - 7
services/web/public/stylesheets/app/editor.less

@@ -13,6 +13,7 @@
 @import "./editor/hotkeys.less";
 @import "./editor/review-panel.less";
 @import "./editor/rich-text.less";
+@import "./editor/publish-modal.less";
 
 @ui-layout-toggler-def-height: 50px;
 @ui-resizer-size: 7px;
@@ -204,13 +205,9 @@
 	}
 }
 
-.cm-editor-wrapper {
-	height: 100%;
-
-	.CodeMirror {
-		height: 100%;
-	}
-}
+/**************************************
+Ace
+***************************************/
 
 // The internal components of the aceEditor directive
 .ace-editor-wrapper {
@@ -285,6 +282,23 @@
 	}
 }
 
+/**************************************
+CodeMirror
+***************************************/
+.cm-editor-wrapper {
+	position: relative;
+	height: 100%;
+}
+
+.cm-editor-body {
+	height: 100%;
+}
+
+// CM (for some reason) has height set to 300px in it's stylesheet
+.CodeMirror {
+	height: 100%;
+}
+
 .ui-layout-resizer when (@is-overleaf = false) {
 	width: 6px;
 	background-color: @editor-resizer-bg-color;

+ 89 - 0
services/web/public/stylesheets/app/editor/publish-modal.less

@@ -0,0 +1,89 @@
+.modal-body-publish {
+		#search-input-container {
+				overflow: hidden;
+				margin: 5px 0 10px;
+		}
+		.table-content-name {
+				width: 100%;
+				margin-bottom: 10px;
+				font-weight: 300;
+		}
+		.table-content-category {
+				font-weight: 300;
+				text-align: right;
+				font-style: italic;
+				width: 30%;
+				float: right;
+				text-transform: capitalize
+		}
+		.table-content-category ~ .table-content-name {
+				width: 70%;
+				display: inline-block;
+		}
+		.wl-icon:before{
+				font-size: 14px;
+		}
+		.button-as-link{
+         color: green;
+         text-transform: none;
+         background: none;
+         padding: 0;
+         border: none;
+         border-radius: 0;
+         font-size: 14px;
+         @extend a;
+         &:hover,
+         &:active,
+         &:focus {
+             color: green;
+             background: none;
+         }
+	       text-align: left;
+		}
+    .affix-content-title {
+      color: @gray-light;
+      font-size: 1.2em;
+      padding-left: 10px;
+    }
+
+    .affix-subcontent {
+      margin: 5px 0 50px;
+    }
+    .overbox {
+        padding: @line-height-computed / 2;
+	      background-color: white;
+	      margin-top: @line-height-computed / 2;
+        border: 1px solid @gray-lighter;
+    }
+    .content-as-table {
+	      .table-content,
+	      .table-content > * {
+		        display: table;
+	      }
+	      .table-content-icon {
+		        float: left;
+		        height: 80px;
+		        width: 106px;
+            border: 1px solid @gray-lightest;
+		        display: flex;
+		        align-items: center;
+		        overflow: hidden;
+		        * {
+			          width: 100%;
+		        }
+	      }
+	      .table-content-text {
+		        float: right;
+		        width: calc(~'100% - 106px');
+		        vertical-align: top;
+		        padding-left: 15px;
+	      }
+	      .table-content-slogan {
+		        height: 80px;
+		        overflow: hidden;
+	      }
+	      .table-content-link {
+		        padding-top: 10px;
+	      }
+    }
+}

+ 6 - 0
services/web/public/stylesheets/app/editor/review-panel.less

@@ -775,6 +775,12 @@
 	}
 }
 
+#editor-rich-text {
+	.rp-size-expanded & {
+		right: @review-panel-width;
+	}
+}
+
 .rp-toggle {
 	display: inline-block;
 	vertical-align: middle;

+ 7 - 0
services/web/public/stylesheets/app/editor/rich-text.less

@@ -219,4 +219,11 @@
     font-style: italic;
     color: #999;
   }
+
+  .spelling-error {
+    background-image: url(/img/spellcheck-underline.png);
+    background-repeat: repeat-x;
+    background-position: bottom;
+  }
 }
+

+ 177 - 0
services/web/public/stylesheets/app/metrics.less

@@ -0,0 +1,177 @@
+#metrics {
+  max-width: none;
+  padding: 0 30px;
+  width: auto;
+
+  svg.nvd3-svg {
+    width: 100%;
+  }
+
+  .overbox {
+    margin: 0;
+    padding: 40px 20px;
+    background: #fff;
+    border: 1px solid #DFDFDF;
+    .box {
+      padding-bottom: 30px;
+      overflow: hidden;
+      margin-bottom: 40px;
+      border-bottom: 1px solid rgb(216, 216, 216);
+
+      .header {
+        margin-bottom: 20px;
+
+        h4 {
+          font-size: 19px;
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  .print-button {
+    margin-right: 10px;
+    font-size: 20px;
+  }
+
+  .title-button {
+    margin-right: 5px;
+    font-size: 20px;
+  }
+
+  .metric-col {
+    padding: 15px;
+  }
+
+  .metric-header-container {
+    h4 {
+      margin-bottom: 0;
+    }
+  }
+
+  svg {
+    display: block;
+    height: 250px;
+    text {
+      font-family: "Open Sans", sans-serif;
+    }
+    &:not(:root) {
+      overflow: visible
+    }
+
+    &.hidden-legend-margin-fix {
+      margin-top: 15px;
+      height: 235px;
+    }
+
+    &.no-fill-opacity {
+      .nvd3 {
+        .nv-area {
+          fill-opacity: 1;
+        }
+      }
+    }
+  }
+
+  .nvtooltip {
+    z-index: 10;
+  }
+
+  .tooltip {
+    width: 150px;
+  }
+
+  // BEGIN: Metrics header
+  .metric-header-container {
+    > h4 {
+      margin-top: 0;
+      margin-bottom: 0;
+    }
+  }
+  // END: Metrics header
+
+  // BEGIN: Metrics footer
+  .metric-footer-container {
+    text-align: center;
+  }
+  // END: Metrics footer
+
+  // BEGIN: Metrics overlays
+  .metric-overlay-loading,
+  .metric-overlay-error,
+  .metric-overlay-backdrop {
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 100%;
+    padding: 16px; /* 15px of .metric-col padding + 1px border */
+    padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/
+  }
+
+  .metric-overlay-loading {
+    padding: 175px 20%;
+  }
+
+  .metric-overlay-error {
+    display: none;
+    text-align: center;
+    padding-top: 175px;
+  }
+
+  .metric-overlay-backdrop {
+    opacity: 0.5;
+  }
+
+  .metric-overlay-backdrop-inner {
+    background-color: #fff;
+    width: 100%;
+    height: 100%;
+  }
+  // END: Metrics overlays
+}
+
+#metrics-header {
+  @media (min-width: 1200px) {
+    margin-bottom: 30px;
+  }
+
+  .section_header {
+    margin-bottom: 0;
+  }
+
+  #filters-container {
+    text-align: right;
+
+    .by {
+      color: #989898;
+      font-style: italic;
+    }
+  }
+
+  #lags-container {
+    .dropdown-menu {
+      min-width: 0;
+    }
+  }
+
+  #dates-container {
+    display: inline-block;
+    .daterangepicker {
+      margin-right: 15px;
+    }
+  }
+}
+
+#metrics-footer {
+  margin-top: 30px;
+  text-align: center;
+}
+
+body.print-loading {
+  #metrics {
+    .metric-col {
+      opacity: 0.5;
+    }
+  }
+}

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

@@ -0,0 +1,6 @@
+// Styles for Chat panel in Overleaf v2
+
+.chat .message-wrapper .message .message-content a {
+	color: inherit;
+	text-decoration: underline;
+}

+ 610 - 0
services/web/public/stylesheets/components/daterange-picker.less

@@ -0,0 +1,610 @@
+//
+// A stylesheet for use with Bootstrap 3.x
+// @author: Dan Grossman http://www.dangrossman.info/
+// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved.
+// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php
+// @website: https://www.improvely.com/
+//
+
+//
+// VARIABLES
+//
+
+//
+// Settings
+
+// The class name to contain everything within.
+@arrow-size:     7px;
+
+//
+// Colors
+@daterangepicker-color:                      @brand-primary;
+@daterangepicker-bg-color:                   #fff;
+
+@daterangepicker-cell-color:                 @daterangepicker-color;
+@daterangepicker-cell-border-color:          transparent;
+@daterangepicker-cell-bg-color:              @daterangepicker-bg-color;
+
+@daterangepicker-cell-hover-color:           @daterangepicker-color;
+@daterangepicker-cell-hover-border-color:    @daterangepicker-cell-border-color;
+@daterangepicker-cell-hover-bg-color:        #eee;
+
+@daterangepicker-in-range-color:             #000;
+@daterangepicker-in-range-border-color:      transparent;
+@daterangepicker-in-range-bg-color:          #ebf4f8;
+
+@daterangepicker-active-color:               #fff;
+@daterangepicker-active-bg-color:            #a93529;
+@daterangepicker-active-border-color:        transparent;
+
+@daterangepicker-unselected-color:           #999;
+@daterangepicker-unselected-border-color:    transparent;
+@daterangepicker-unselected-bg-color:        #fff;
+
+//
+// daterangepicker
+@daterangepicker-width:          278px;
+@daterangepicker-padding:        4px;
+@daterangepicker-z-index:        3000;
+
+@daterangepicker-border-size:    1px;
+@daterangepicker-border-color:   #ccc;
+@daterangepicker-border-radius:  4px;
+
+
+//
+// Calendar
+@daterangepicker-calendar-margin:              @daterangepicker-padding;
+@daterangepicker-calendar-bg-color:            @daterangepicker-bg-color;
+
+@daterangepicker-calendar-border-size:         1px;
+@daterangepicker-calendar-border-color:        @daterangepicker-bg-color;
+@daterangepicker-calendar-border-radius:       @daterangepicker-border-radius;
+
+//
+// Calendar Cells
+@daterangepicker-cell-size:           20px;
+@daterangepicker-cell-width:          @daterangepicker-cell-size;
+@daterangepicker-cell-height:         @daterangepicker-cell-size;
+
+@daterangepicker-cell-border-radius:  @daterangepicker-calendar-border-radius;
+@daterangepicker-cell-border-size:    1px;
+
+//
+// Dropdowns
+@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1;
+
+//
+// Controls
+@daterangepicker-control-height:               30px;
+@daterangepicker-control-line-height:          @daterangepicker-control-height;
+@daterangepicker-control-color:                #555;
+
+@daterangepicker-control-border-size:          1px;
+@daterangepicker-control-border-color:         #ccc;
+@daterangepicker-control-border-radius:        4px;
+
+@daterangepicker-control-active-border-size:   1px;
+@daterangepicker-control-active-border-color:  @brand-primary;
+@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius;
+
+@daterangepicker-control-disabled-color:       #ccc;
+
+//
+// Ranges
+@daterangepicker-ranges-color:                @brand-primary;
+@daterangepicker-ranges-bg-color:             daterangepicker-ranges-color;
+
+@daterangepicker-ranges-border-size:          1px;
+@daterangepicker-ranges-border-color:         @daterangepicker-ranges-bg-color;
+@daterangepicker-ranges-border-radius:        @daterangepicker-border-radius;
+
+@daterangepicker-ranges-hover-color:          #fff;
+@daterangepicker-ranges-hover-bg-color:       @daterangepicker-ranges-color;
+@daterangepicker-ranges-hover-border-size:    @daterangepicker-ranges-border-size;
+@daterangepicker-ranges-hover-border-color:   @daterangepicker-ranges-hover-bg-color;
+@daterangepicker-ranges-hover-border-radius:  @daterangepicker-border-radius;
+
+@daterangepicker-ranges-active-border-size:   @daterangepicker-ranges-border-size;
+@daterangepicker-ranges-active-border-color:  @daterangepicker-ranges-bg-color;
+@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius;
+
+//
+// STYLESHEETS
+//
+.daterangepicker {
+  position: absolute;
+  color: @daterangepicker-color;
+  background-color: @daterangepicker-bg-color;
+  border-radius: @daterangepicker-border-radius;
+  width: @daterangepicker-width;
+  padding: @daterangepicker-padding;
+  margin-top: @daterangepicker-border-size;
+
+  // TODO: Should these be parameterized??
+  // top: 100px;
+  // left: 20px;
+
+  @arrow-prefix-size: @arrow-size;
+  @arrow-suffix-size: (@arrow-size - @daterangepicker-border-size);
+
+  &:before, &:after {
+    position: absolute;
+    display: inline-block;
+
+    border-bottom-color: rgba(0, 0, 0, 0.2);
+    content: '';
+  }
+
+  &:before {
+    top: -@arrow-prefix-size;
+
+    border-right: @arrow-prefix-size solid transparent;
+    border-left: @arrow-prefix-size solid transparent;
+    border-bottom: @arrow-prefix-size solid @daterangepicker-border-color;
+  }
+
+  &:after {
+    top: -@arrow-suffix-size;
+
+    border-right: @arrow-suffix-size solid transparent;
+    border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color;
+    border-left: @arrow-suffix-size solid transparent;
+  }
+
+  &.opensleft {
+    &:before {
+      // TODO: Make this relative to prefix size.
+      right: @arrow-prefix-size + 2px;
+    }
+
+    &:after {
+      // TODO: Make this relative to suffix size.
+      right: @arrow-suffix-size + 4px;
+    }
+  }
+
+  &.openscenter {
+    &:before {
+      left: 0;
+      right: 0;
+      width: 0;
+      margin-left: auto;
+      margin-right: auto;
+    }
+
+    &:after {
+      left: 0;
+      right: 0;
+      width: 0;
+      margin-left: auto;
+      margin-right: auto;
+    }
+  }
+
+  &.opensright {
+    &:before {
+      // TODO: Make this relative to prefix size.
+      left: @arrow-prefix-size + 2px;
+    }
+
+    &:after {
+      // TODO: Make this relative to suffix size.
+      left: @arrow-suffix-size + 4px;
+    }
+  }
+
+  &.dropup {
+    margin-top: -5px;
+
+    // NOTE: Note sure why these are special-cased.
+    &:before {
+      top: initial;
+      bottom: -@arrow-prefix-size;
+      border-bottom: initial;
+      border-top: @arrow-prefix-size solid @daterangepicker-border-color;
+    }
+
+    &:after {
+      top: initial;
+      bottom:-@arrow-suffix-size;
+      border-bottom: initial;
+      border-top: @arrow-suffix-size solid @daterangepicker-bg-color;
+    }
+  }
+
+  &.dropdown-menu {
+    max-width: none;
+    z-index: @daterangepicker-dropdown-z-index;
+  }
+
+  &.single {
+    .ranges, .calendar {
+      float: none;
+    }
+  }
+
+  /* Calendars */
+  &.show-calendar {
+    .calendar {
+      display: block;
+    }
+  }
+
+  .calendar {
+    display: none;
+    max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2);
+    margin: @daterangepicker-calendar-margin;
+
+    &.single {
+      .calendar-table {
+        border: none;
+      }
+    }
+
+    th, td {
+      white-space: nowrap;
+      text-align: center;
+
+      // TODO: Should this actually be hard-coded?
+      min-width: 32px;
+    }
+  }
+
+  .calendar-table {
+    border: @daterangepicker-calendar-border-size solid @daterangepicker-calendar-border-color;
+    padding: @daterangepicker-calendar-margin;
+    border-radius: @daterangepicker-calendar-border-radius;
+    background-color: @daterangepicker-calendar-bg-color;
+  }
+
+  table {
+    width: 100%;
+    margin: 0;
+  }
+
+  td, th {
+    text-align: center;
+    width: @daterangepicker-cell-width;
+    height: @daterangepicker-cell-height;
+    border-radius: @daterangepicker-cell-border-radius;
+    border: @daterangepicker-cell-border-size solid @daterangepicker-cell-border-color;
+    white-space: nowrap;
+    cursor: pointer;
+
+    &.available {
+      &:hover {
+        background-color: @daterangepicker-cell-hover-bg-color;
+        border-color: @daterangepicker-cell-hover-border-color;
+        color: @daterangepicker-cell-hover-color;
+      }
+    }
+
+    &.week {
+      font-size: 80%;
+      color: #ccc;
+    }
+  }
+
+  td {
+    &.off {
+      &, &.in-range, &.start-date, &.end-date {
+        background-color: @daterangepicker-unselected-bg-color;
+        border-color: @daterangepicker-unselected-border-color;
+        color: @daterangepicker-unselected-color;
+      }
+    }
+
+    //
+    // Date Range
+    &.in-range {
+      background-color: @daterangepicker-in-range-bg-color;
+      border-color: @daterangepicker-in-range-border-color;
+      color: @daterangepicker-in-range-color;
+
+      // TODO: Should this be static or should it be parameterized?
+      border-radius: 0;
+    }
+
+    &.start-date {
+      border-radius: @daterangepicker-cell-border-radius 0 0 @daterangepicker-cell-border-radius;
+    }
+
+    &.end-date {
+      border-radius: 0 @daterangepicker-cell-border-radius @daterangepicker-cell-border-radius 0;
+    }
+
+    &.start-date.end-date {
+      border-radius: @daterangepicker-cell-border-radius;
+    }
+
+    &.active {
+      &, &:hover {
+        background-color: @daterangepicker-active-bg-color;
+        border-color: @daterangepicker-active-border-color;
+        color: @daterangepicker-active-color;
+      }
+    }
+  }
+
+  th {
+    &.month {
+      width: auto;
+    }
+  }
+
+  //
+  // Disabled Controls
+  //
+  td, option {
+    &.disabled {
+      color: #999;
+      cursor: not-allowed;
+      text-decoration: line-through;
+    }
+  }
+
+  select {
+    &.monthselect, &.yearselect {
+      font-size: 12px;
+      padding: 1px;
+      height: auto;
+      margin: 0;
+      cursor: default;
+    }
+
+    &.monthselect {
+      margin-right: 2%;
+      width: 56%;
+    }
+
+    &.yearselect {
+      width: 40%;
+    }
+
+    &.hourselect, &.minuteselect, &.secondselect, &.ampmselect {
+      width: 50px;
+      margin-bottom: 0;
+    }
+  }
+
+  //
+  // Text Input Controls (above calendar)
+  //
+  .input-mini {
+    border: @daterangepicker-control-border-size solid @daterangepicker-control-border-color;
+    border-radius: @daterangepicker-control-border-radius;
+    color: @daterangepicker-control-color;
+    height: @daterangepicker-control-line-height;
+    line-height: @daterangepicker-control-height;
+    display: block;
+    vertical-align: middle;
+
+    // TODO: Should these all be static, too??
+    margin: 0 0 5px 0;
+    padding: 0 6px 0 28px;
+    width: 100%;
+
+    &.active {
+      border: @daterangepicker-control-active-border-size solid @daterangepicker-control-active-border-color;
+      border-radius: @daterangepicker-control-active-border-radius;
+    }
+  }
+
+  .daterangepicker_input {
+    position: relative;
+
+    i {
+      position: absolute;
+
+      // NOTE: These appear to be eyeballed to me...
+      left: 8px;
+      top: 8px;
+    }
+  }
+  &.rtl {
+    .input-mini {
+      padding-right: 28px;
+      padding-left: 6px;
+    }
+    .daterangepicker_input i {
+      left: auto;
+      right: 8px;
+    }
+  }
+
+  //
+  // Time Picker
+  //
+  .calendar-time {
+    text-align: center;
+    margin: 5px auto;
+    line-height: @daterangepicker-control-line-height;
+    position: relative;
+    padding-left: 28px;
+
+    select {
+      &.disabled {
+        color: @daterangepicker-control-disabled-color;
+        cursor: not-allowed;
+      }
+    }
+  }
+}
+
+//
+// Predefined Ranges
+//
+
+.ranges {
+  font-size: 11px;
+  float: none;
+  margin: 4px;
+  text-align: left;
+
+  ul {
+    list-style: none;
+    margin: 0 auto;
+    padding: 0;
+    width: 100%;
+  }
+
+  li {
+    font-size: 13px;
+    background-color: @daterangepicker-ranges-bg-color;
+    border: @daterangepicker-ranges-border-size solid @daterangepicker-ranges-border-color;
+    border-radius: @daterangepicker-ranges-border-radius;
+    color: @daterangepicker-ranges-color;
+    padding: 3px 12px;
+    margin-bottom: 8px;
+    cursor: pointer;
+
+    &:hover {
+      background-color: @daterangepicker-ranges-hover-bg-color;
+      border: @daterangepicker-ranges-hover-border-size solid @daterangepicker-ranges-hover-border-color;
+      color: @daterangepicker-ranges-hover-color;
+    }
+
+    &.active {
+      background-color: @daterangepicker-ranges-hover-bg-color;
+      border: @daterangepicker-ranges-hover-border-size solid @daterangepicker-ranges-hover-border-color;
+      color: @daterangepicker-ranges-hover-color;
+    }
+  }
+}
+
+/*  Larger Screen Styling */
+@media (min-width: 564px) {
+  .daterangepicker {
+    width: auto;
+
+    .ranges {
+      ul {
+        width: 160px;
+      }
+    }
+
+    &.single {
+      .ranges {
+        ul {
+          width: 100%;
+        }
+      }
+
+      .calendar.left {
+        clear: none;
+      }
+
+      &.ltr {
+        .ranges, .calendar {
+          float:left;
+        }
+      }
+      &.rtl {
+        .ranges, .calendar {
+          float:right;
+        }
+      }
+    }
+
+    &.ltr {
+      direction: ltr;
+      text-align: left;
+      .calendar{
+        &.left {
+          clear: left;
+          margin-right: 0;
+
+          .calendar-table {
+            border-right: none;
+            border-top-right-radius: 0;
+            border-bottom-right-radius: 0;
+          }
+        }
+
+        &.right {
+          margin-left: 0;
+
+          .calendar-table {
+            border-left: none;
+            border-top-left-radius: 0;
+            border-bottom-left-radius: 0;
+          }
+        }
+      }
+
+      .left .daterangepicker_input {
+        padding-right: 12px;
+      }
+
+      .calendar.left .calendar-table {
+        padding-right: 12px;
+      }
+
+      .ranges, .calendar {
+        float: left;
+      }
+    }
+    &.rtl {
+      direction: rtl;
+      text-align: right;
+      .calendar{
+        &.left {
+          clear: right;
+          margin-left: 0;
+
+          .calendar-table {
+            border-left: none;
+            border-top-left-radius: 0;
+            border-bottom-left-radius: 0;
+          }
+        }
+
+        &.right {
+          margin-right: 0;
+
+          .calendar-table {
+            border-right: none;
+            border-top-right-radius: 0;
+            border-bottom-right-radius: 0;
+          }
+        }
+      }
+
+      .left .daterangepicker_input {
+        padding-left: 12px;
+      }
+
+      .calendar.left .calendar-table {
+        padding-left: 12px;
+      }
+
+      .ranges, .calendar {
+        text-align: right;
+        float: right;
+      }
+    }
+  }
+}
+
+@media (min-width: 730px) {
+  .daterangepicker {
+    .ranges {
+      width: auto;
+    }
+    &.ltr {
+      .ranges {
+        float: left;
+      }
+    }
+    &.rtl {
+      .ranges {
+        float: right;
+      }
+    }
+
+    .calendar.left {
+      clear: none !important;
+    }
+  }
+}

+ 56 - 0
services/web/test/acceptance/coffee/ExportsTests.coffee

@@ -0,0 +1,56 @@
+expect = require('chai').expect
+request = require './helpers/request'
+_ = require 'underscore'
+
+
+User = require './helpers/User'
+ProjectGetter = require '../../../app/js/Features/Project/ProjectGetter.js'
+ExportsHandler = require '../../../app/js/Features/Exports/ExportsHandler.js'
+
+MockProjectHistoryApi = require './helpers/MockProjectHistoryApi'
+MockV1Api = require './helpers/MockV1Api'
+
+describe 'Exports', ->
+	before (done) ->
+		@brand_variation_id = '18'
+		@owner = new User()
+		@owner.login (error) =>
+			throw error if error?
+			@owner.createProject 'example-project', {template: 'example'}, (error, @project_id) =>
+				throw error if error?
+				done()
+
+	describe 'exporting a project', ->
+		beforeEach (done) ->
+			@version = Math.floor(Math.random() * 10000)
+			MockProjectHistoryApi.setProjectVersion(@project_id, @version)
+			@export_id = Math.floor(Math.random() * 10000)
+			MockV1Api.setExportId(@export_id)
+			MockV1Api.clearExportParams()
+			@owner.request {
+				method: 'POST',
+				url: "/project/#{@project_id}/export/#{@brand_variation_id}",
+				json: {},
+			}, (error, response, body) =>
+				throw error if error?
+				expect(response.statusCode).to.equal 200
+				@exportResponseBody = body
+				done()
+
+		it 'should have sent correct data to v1', (done) ->
+			{project, user, destination, options} = MockV1Api.getLastExportParams()
+			# project details should match
+			expect(project.id).to.equal @project_id
+			expect(project.rootDocPath).to.equal '/main.tex'
+			# version should match what was retrieved from project-history
+			expect(project.historyVersion).to.equal @version
+			# user details should match
+			expect(user.id).to.equal @owner.id
+			expect(user.email).to.equal @owner.email
+			# brand-variation should match
+			expect(destination.brandVariationId).to.equal @brand_variation_id
+			done()
+
+		it 'should have returned the export ID provided by v1', (done) ->
+			expect(@exportResponseBody.export_v1_id).to.equal @export_id
+			done()

+ 4 - 3
services/web/test/acceptance/coffee/ProjectInviteTests.coffee

@@ -16,6 +16,7 @@ createInvite = (sendingUser, projectId, email, callback=(err, invite)->) ->
 				privileges: 'readAndWrite'
 		}, (err, response, body) ->
 			return callback(err) if err
+			expect(response.statusCode).to.equal 200
 			callback(null, body.invite)
 
 createProject = (owner, projectName, callback=(err, projectId, project)->) ->
@@ -207,9 +208,9 @@ describe "ProjectInviteTests", ->
 		@email = 'smoketestuser@example.com'
 		@projectName = 'sharing test'
 		Async.series [
-			(cb) => @user.login cb
-			(cb) => @user.logout cb
+			(cb) => @user.ensureUserExists cb
 			(cb) => @sendingUser.login cb
+			(cb) => @sendingUser.setFeatures { collaborators: 10 }, cb
 		], done
 
 	describe 'creating invites', ->
@@ -266,7 +267,7 @@ describe "ProjectInviteTests", ->
 					(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 0, cb
 				], done
 
-			it 'should allow the project owner to many invites at once', (done) ->
+			it 'should allow the project owner to create many invites at once', (done) ->
 				@inviteOne = null
 				@inviteTwo = null
 				Async.series [

+ 5 - 2
services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee

@@ -22,6 +22,7 @@ describe "ProjectStructureMongoLock", ->
 		before (done) ->
 			# We want to instantly fail if the lock is taken
 			LockManager.MAX_LOCK_WAIT_TIME = 1
+			@lockValue = "lock-value"
 			userDetails =
 				holdingAccount:false,
 				email: 'test@example.com'
@@ -33,11 +34,13 @@ describe "ProjectStructureMongoLock", ->
 					@locked_project = project
 					namespace = ProjectEntityMongoUpdateHandler.LOCK_NAMESPACE
 					@lock_key = "lock:web:#{namespace}:#{project._id}"
-					LockManager._getLock @lock_key, namespace, done
+					LockManager._getLock @lock_key, namespace, (err, lockValue) =>
+						@lockValue = lockValue
+						done()
 			return
 
 		after (done) ->
-			LockManager._releaseLock @lock_key, done
+			LockManager._releaseLock @lock_key, @lockValue, done
 
 		describe 'interacting with the locked project', ->
 			LOCKING_UPDATE_METHODS = ['addDoc', 'addFile', 'mkdirp', 'moveEntity', 'renameEntity', 'addFolder']

+ 151 - 0
services/web/test/acceptance/coffee/SubscriptionTests.coffee

@@ -0,0 +1,151 @@
+expect = require("chai").expect
+async = require("async")
+UserClient = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+{ObjectId} = require("../../../app/js/infrastructure/mongojs")
+Subscription = require("../../../app/js/models/Subscription").Subscription
+User = require("../../../app/js/models/User").User
+
+MockV1Api = require "./helpers/MockV1Api"
+
+syncUserAndGetFeatures = (user, callback = (error, features) ->) ->
+	request {
+		method: 'POST',
+		url: "/user/#{user._id}/features/sync",
+		auth:
+			user: 'sharelatex'
+			pass: 'password'
+			sendImmediately: true
+	}, (error, response, body) ->
+		throw error if error?
+		expect(response.statusCode).to.equal 200
+		User.findById user._id, (error, user) ->
+			return callback(error) if error?
+			features = user.toObject().features
+			delete features.$init # mongoose internals
+			return callback null, features
+
+describe "Subscriptions", ->
+	beforeEach (done) ->
+		@user = new UserClient()
+		@user.ensureUserExists (error) ->
+			throw error if error?
+			done()
+
+	describe "when user has no subscriptions", ->
+		it "should set their features to the basic set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				expect(features).to.deep.equal(settings.defaultFeatures)
+				done()
+
+	describe "when the user has an individual subscription", ->
+		beforeEach ->
+			Subscription.create {
+				admin_id: @user._id
+				planCode: 'collaborator'
+				customAccount: true
+			} # returns a promise
+
+		it "should set their features to the upgraded set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user is in a group subscription", ->
+		beforeEach ->
+			Subscription.create {
+				admin_id: ObjectId()
+				member_ids: [@user._id]
+				groupAccount: true
+				planCode: 'collaborator'
+				customAccount: true
+			} # returns a promise
+
+		it "should set their features to the upgraded set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user has bonus features", ->
+		beforeEach ->
+			User.update {
+				_id: @user._id
+			}, {
+				refered_user_count: 10
+			} # returns a promise
+
+		it "should set their features to the bonus set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				expect(features).to.deep.equal(Object.assign(
+					{}, settings.defaultFeatures,	settings.bonus_features[9]
+				))
+				done()
+
+	describe "when the user has a v1 plan", ->
+		beforeEach ->
+			MockV1Api.setUser 42, plan_name: 'free'
+			User.update {
+				_id: @user._id
+			}, {
+				overleaf:
+					id: 42
+			} # returns a promise
+
+		it "should set their features to the v1 plan", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
+				expect(features).to.deep.equal(plan.features)
+				done()
+
+	describe "when the user has a v1 plan and bonus features", ->
+		beforeEach ->
+			MockV1Api.setUser 42, plan_name: 'free'
+			User.update {
+				_id: @user._id
+			}, {
+				overleaf:
+					id: 42
+				refered_user_count: 10
+			} # returns a promise
+
+		it "should set their features to the best of the v1 plan and bonus features", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				v1plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
+				expectedFeatures = Object.assign(
+					{}, v1plan.features, settings.bonus_features[9]
+				)
+				expect(features).to.deep.equal(expectedFeatures)
+				done()
+
+	describe "when the user has a group and personal subscription", ->
+		beforeEach (done) ->
+			Subscription.create {
+				admin_id: @user._id
+				planCode: 'professional'
+				customAccount: true
+			}, (error) =>
+				throw error if error?
+				Subscription.create {
+					admin_id: ObjectId()
+					member_ids: [@user._id]
+					groupAccount: true
+					planCode: 'collaborator'
+					customAccount: true
+				}, done
+			return
+
+		it "should set their features to the best set", (done) ->
+			syncUserAndGetFeatures @user, (error, features) =>
+				throw error if error?
+				plan = settings.plans.find (plan) -> plan.planCode == 'professional'
+				expect(features).to.deep.equal(plan.features)
+				done()

+ 12 - 0
services/web/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee

@@ -6,9 +6,14 @@ module.exports = MockProjectHistoryApi =
 
 	oldFiles: {}
 
+	projectVersions: {}
+
 	addOldFile: (project_id, version, pathname, content) ->
 		@oldFiles["#{project_id}:#{version}:#{pathname}"] = content
 
+	setProjectVersion: (project_id, version) ->
+		@projectVersions[project_id] = version
+
 	run: () ->
 		app.post "/project", (req, res, next) =>
 			res.json project: id: 1
@@ -21,6 +26,13 @@ module.exports = MockProjectHistoryApi =
 			else
 				res.send 404
 
+		app.get "/project/:project_id/version", (req, res, next) =>
+			{project_id} = req.params
+			if @projectVersions[project_id]?
+				res.json version: @projectVersions[project_id]
+			else
+				res.send 404
+
 		app.listen 3054, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 45 - 0
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee

@@ -0,0 +1,45 @@
+express = require("express")
+app = express()
+bodyParser = require('body-parser')
+
+app.use(bodyParser.json())
+
+module.exports = MockV1Api =
+	users: { }
+
+	setUser: (id, user) ->
+		@users[id] = user
+
+	exportId: null
+
+	exportParams: null
+
+	setExportId: (id) ->
+		@exportId = id
+
+	getLastExportParams: () ->
+		@exportParams
+
+	clearExportParams: () ->
+		@exportParams = null
+
+	run: () ->
+		app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) =>
+			user = @users[req.params.ol_user_id]
+			if user
+				res.json user
+			else
+				res.sendStatus 404
+
+		app.post "/api/v1/sharelatex/exports", (req, res, next) =>
+			#{project, version, pathname}
+			@exportParams = Object.assign({}, req.body)
+			res.json exportId: @exportId
+
+		app.listen 5000, (error) ->
+			throw error if error?
+		.on "error", (error) ->
+			console.error "error starting MockV1Api:", error.message
+			process.exit(1)
+
+MockV1Api.run()

+ 6 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -40,6 +40,12 @@ class User
 				@referal_id = user?.referal_id
 				callback(null, @password)
 
+	setFeatures: (features, callback = (error) ->) ->
+		update = {}
+		for key, value of features
+			update["features.#{key}"] = value
+		UserModel.update { _id: @id }, update, callback
+
 	logout: (callback = (error) ->) ->
 		@getCsrfToken (error) =>
 			return callback(error) if error?

+ 95 - 0
services/web/test/acceptance/config/settings.test.coffee

@@ -0,0 +1,95 @@
+module.exports =
+	enableSubscriptions: true
+
+	features: features =
+		v1_free:
+			collaborators: 1
+			dropbox: false
+			versioning: false
+			github: true
+			templates: false
+			references: false
+			referencesSearch: false
+			mendeley: true
+			compileTimeout: 60
+			compileGroup: "standard"
+			trackChanges: false
+		personal:
+			collaborators: 1
+			dropbox: false
+			versioning: false
+			github: false
+			templates: false
+			references: false
+			referencesSearch: false
+			mendeley: false
+			compileTimeout: 60
+			compileGroup: "standard"
+			trackChanges: false
+		collaborator:
+			collaborators: 10
+			dropbox: true
+			versioning: true
+			github: true
+			templates: true
+			references: true
+			referencesSearch: true
+			mendeley: true
+			compileTimeout: 180
+			compileGroup: "priority"
+			trackChanges: true
+		professional:
+			collaborators: -1
+			dropbox: true
+			versioning: true
+			github: true
+			templates: true
+			references: true
+			referencesSearch: true
+			mendeley: true
+			compileTimeout: 180
+			compileGroup: "priority"
+			trackChanges: true
+
+	defaultFeatures: features.personal
+	defaultPlanCode: 'personal'
+
+	plans: plans = [{
+		planCode: "v1_free"
+		name: "V1 Free"
+		price: 0
+		features: features.v1_free
+	},{
+		planCode: "personal"
+		name: "Personal"
+		price: 0
+		features: features.personal
+	},{
+		planCode: "collaborator"
+		name: "Collaborator"
+		price: 1500
+		features: features.collaborator
+	},{
+		planCode: "professional"
+		name: "Professional"
+		price: 3000
+		features: features.professional
+	}]
+
+	bonus_features:
+		1:
+			collaborators: 2
+			dropbox: false
+			versioning: false
+		3:
+			collaborators: 4
+			dropbox: false
+			versioning: false
+		6:
+			collaborators: 4
+			dropbox: true
+			versioning: true
+		9:
+			collaborators: -1
+			dropbox: true
+			versioning: true

+ 5 - 5
services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee

@@ -23,8 +23,8 @@ describe "BetaProgramController", ->
 				optIn: sinon.stub()
 				optOut: sinon.stub()
 			},
-			"../User/UserLocator": @UserLocator = {
-				findById: sinon.stub()
+			"../User/UserGetter": @UserGetter = {
+				getUser: sinon.stub()
 			},
 			"settings-sharelatex": @settings = {
 				languages: {}
@@ -119,7 +119,7 @@ describe "BetaProgramController", ->
 	describe "optInPage", ->
 
 		beforeEach ->
-			@UserLocator.findById.callsArgWith(1, null, @user)
+			@UserGetter.getUser.callsArgWith(1, null, @user)
 
 		it "should render the opt-in page", () ->
 			@BetaProgramController.optInPage @req, @res, @next
@@ -128,10 +128,10 @@ describe "BetaProgramController", ->
 			args[0].should.equal 'beta_program/opt_in'
 
 
-		describe "when UserLocator.findById produces an error", ->
+		describe "when UserGetter.getUser produces an error", ->
 
 			beforeEach ->
-				@UserLocator.findById.callsArgWith(1, new Error('woops'))
+				@UserGetter.getUser.callsArgWith(1, new Error('woops'))
 
 			it "should not render the opt-in page", () ->
 				@BetaProgramController.optInPage @req, @res, @next

+ 9 - 6
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -24,11 +24,14 @@ describe "CollaboratorsInviteController", ->
 			addCount: sinon.stub
 
 		@LimitationsManager = {}
+		@UserGetter =
+			getUserByMainEmail: sinon.stub()
+			getUser: sinon.stub()
 
 		@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
 			"../Project/ProjectGetter": @ProjectGetter = {}
 			'../Subscription/LimitationsManager' : @LimitationsManager
-			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
+			'../User/UserGetter': @UserGetter
 			"./CollaboratorsHandler": @CollaboratorsHandler = {}
 			"./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
 			'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
@@ -713,7 +716,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = {_id: ObjectId().toString()}
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `true`', (done) ->
 					@call (err, shouldAllow) =>
@@ -725,7 +728,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `false`', (done) ->
 					@call (err, shouldAllow) =>
@@ -735,15 +738,15 @@ describe "CollaboratorsInviteController", ->
 
 				it 'should have called getUser', (done) ->
 					@call (err, shouldAllow) =>
-						@UserGetter.getUser.callCount.should.equal 1
-						@UserGetter.getUser.calledWith({email: @email}, {_id: 1}).should.equal true
+						@UserGetter.getUserByMainEmail.callCount.should.equal 1
+						@UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true
 						done()
 
 			describe 'when getUser produces an error', ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 				it 'should callback with an error', (done) ->
 					@call (err, shouldAllow) =>

+ 9 - 9
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee

@@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", ->
 				_id: ObjectId()
 				first_name: "jim"
 			@existingUser = {_id: ObjectId()}
-			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
+			@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser)
 			@fakeProject =
 				_id: @project_id
 				name: "some project"
@@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should call getProject', (done) ->
@@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the user does not exist', ->
 
 			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null)
 
 			it 'should not produce an error', (done) ->
 				@call (err) =>
@@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->
@@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the getUser produces an error', ->
 
 			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 			it 'should produce an error', (done) ->
 				@call (err) =>
@@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->

+ 39 - 0
services/web/test/unit/coffee/Exports/ExportsControllerTests.coffee

@@ -0,0 +1,39 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+chai = require('chai')
+expect = chai.expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Exports/ExportsController.js'
+
+
+describe 'ExportsController', ->
+	project_id = "123njdskj9jlk"
+	user_id = "123nd3ijdks"
+	brand_variation_id = 22
+
+	beforeEach ->
+		@handler =
+			getUserNotifications: sinon.stub().callsArgWith(1)
+		@req =
+			params:
+				project_id: project_id
+				brand_variation_id: brand_variation_id
+			session:
+				user:
+					_id:user_id
+			i18n:
+				translate:->
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@req.session.user._id)
+		@controller = SandboxedModule.require modulePath, requires:
+			"./ExportsHandler":@handler
+			'logger-sharelatex':
+				log:->
+				err:->
+			'../Authentication/AuthenticationController': @AuthenticationController
+
+	it 'should ask the handler to perform the export', (done) ->
+		@handler.exportProject = sinon.stub().yields(null, {iAmAnExport: true, v1_id: 897})
+		@controller.exportProject @req, send:(body) =>
+			expect(body).to.deep.equal {export_v1_id: 897}
+			done()

+ 202 - 0
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee

@@ -0,0 +1,202 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = '../../../../app/js/Features/Exports/ExportsHandler.js'
+SandboxedModule = require('sandboxed-module')
+
+describe 'ExportsHandler', ->
+
+	beforeEach ->
+		@ProjectGetter = {}
+		@ProjectLocator = {}
+		@UserGetter = {}
+		@settings = {}
+		@stubRequest = {}
+		@request = defaults: => return @stubRequest
+		@ExportsHandler = SandboxedModule.require modulePath, requires:
+			'logger-sharelatex':
+				log: ->
+				err: ->
+			'../Project/ProjectGetter': @ProjectGetter
+			'../Project/ProjectLocator': @ProjectLocator
+			'../User/UserGetter': @UserGetter
+			'settings-sharelatex': @settings
+			'request': @request
+		@project_id = "project-id-123"
+		@project_history_id = 987
+		@user_id = "user-id-456"
+		@brand_variation_id = 789
+		@callback = sinon.stub()
+
+	describe 'exportProject', ->
+		beforeEach (done) ->
+			@export_data = {iAmAnExport: true}
+			@response_body = {iAmAResponseBody: true}
+			@ExportsHandler._buildExport = sinon.stub().yields(null, @export_data)
+			@ExportsHandler._requestExport = sinon.stub().yields(null, @response_body)
+			@ExportsHandler.exportProject @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+				@callback(error, export_data)
+				done()
+
+		it "should build the export", ->
+			@ExportsHandler._buildExport
+			.calledWith(@project_id, @user_id, @brand_variation_id)
+			.should.equal true
+
+		it "should request the export", ->
+			@ExportsHandler._requestExport
+			.calledWith(@export_data)
+			.should.equal true
+
+		it "should return the export", ->
+			@callback
+			.calledWith(null, @export_data)
+			.should.equal true
+
+	describe '_buildExport', ->
+		beforeEach (done) ->
+			@project =
+				id: @project_id
+				overleaf:
+					history:
+						id: @project_history_id
+			@user =
+				id: @user_id
+				first_name: 'Arthur'
+				last_name: 'Author'
+				email: 'arthur.author@arthurauthoring.org'
+			@rootDocPath = 'main.tex'
+			@historyVersion = 777
+			@ProjectGetter.getProject = sinon.stub().yields(null, @project)
+			@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, {fileSystem: 'main.tex'}])
+			@UserGetter.getUser = sinon.stub().yields(null, @user)
+			@ExportsHandler._requestVersion = sinon.stub().yields(null, @historyVersion)
+			done()
+
+		describe "when all goes well", ->
+			beforeEach (done) ->
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should request the project history version", ->
+				@ExportsHandler._requestVersion.called
+				.should.equal true
+
+			it "should return export data", ->
+				expected_export_data = 
+					project:
+						id: @project_id
+						rootDocPath: @rootDocPath
+						historyId: @project_history_id
+						historyVersion: @historyVersion
+					user:
+						id: @user_id
+						firstName: @user.first_name
+						lastName: @user.last_name
+						email: @user.email
+						orcidId: null
+					destination:
+						brandVariationId: @brand_variation_id
+					options:
+						callbackUrl: null
+				@callback.calledWith(null, expected_export_data)
+				.should.equal true
+
+		describe "when project is not found", ->
+			beforeEach (done) ->
+				@ProjectGetter.getProject = sinon.stub().yields(new Error("project not found"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when project has no root doc", ->
+			beforeEach (done) ->
+				@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, null])
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when user is not found", ->
+			beforeEach (done) ->
+				@UserGetter.getUser = sinon.stub().yields(new Error("user not found"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when project history request fails", ->
+			beforeEach (done) ->
+				@ExportsHandler._requestVersion = sinon.stub().yields(new Error("project history call failed"))
+				@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
+					@callback(error, export_data)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+	describe '_requestExport', ->
+		beforeEach (done) ->
+			@settings.apis =
+				v1:
+					url: 'http://localhost:5000'
+					user: 'overleaf'
+					pass: 'pass'
+			@export_data = {iAmAnExport: true}
+			@export_id = 4096
+			@stubPost = sinon.stub().yields(null, {statusCode: 200}, { exportId: @export_id })
+			done()
+
+		describe "when all goes well", ->
+			beforeEach (done) ->
+				@stubRequest.post = @stubPost
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it 'should issue the request', ->
+				expect(@stubPost.getCall(0).args[0]).to.deep.equal
+					url: @settings.apis.v1.url + '/api/v1/sharelatex/exports'
+					auth:
+						user: @settings.apis.v1.user
+						pass: @settings.apis.v1.pass
+					json: @export_data
+
+			it 'should return the v1 export id', ->
+				@callback.calledWith(null, @export_id)
+				.should.equal true
+
+		describe "when the request fails", ->
+			beforeEach (done) ->
+				@stubRequest.post = sinon.stub().yields(new Error("export request failed"))
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it "should return an error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true
+
+		describe "when the request returns an error code", ->
+			beforeEach (done) ->
+				@stubRequest.post = sinon.stub().yields(null, {statusCode: 401}, { })
+				@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
+					@callback(error, export_v1_id)
+					done()
+
+			it "should return the error", ->
+				(@callback.args[0][0] instanceof Error)
+				.should.equal true

+ 4 - 4
services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee

@@ -16,7 +16,7 @@ describe "PasswordResetHandler", ->
 			getNewToken:sinon.stub()
 			getValueFromTokenAndExpire:sinon.stub()
 		@UserGetter =
-			getUser:sinon.stub()
+			getUserByMainEmail:sinon.stub()
 		@EmailHandler = 
 			sendEmail:sinon.stub()
 		@AuthenticationManager =
@@ -40,7 +40,7 @@ describe "PasswordResetHandler", ->
 	describe "generateAndEmailResetToken", ->
 
 		it "should check the user exists", (done)->
-			@UserGetter.getUser.callsArgWith(1)
+			@UserGetter.getUserByMainEmail.callsArgWith(1)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
@@ -49,7 +49,7 @@ describe "PasswordResetHandler", ->
 
 		it "should send the email with the token", (done)->
 
-			@UserGetter.getUser.callsArgWith(1, null, @user)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
 			@EmailHandler.sendEmail.callsArgWith(2)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
@@ -62,7 +62,7 @@ describe "PasswordResetHandler", ->
 
 		it "should return exists = false for a holdingAccount", (done) ->
 			@user.holdingAccount = true
-			@UserGetter.getUser.callsArgWith(1, null, @user)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false

+ 28 - 0
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -15,6 +15,7 @@ describe "ProjectController", ->
 		@user =
 			_id:"588f3ddae8ebc1bac07c9fa4"
 			first_name: "bjkdsjfk"
+			features: {}
 		@settings =
 			apis:
 				chat:
@@ -300,6 +301,33 @@ describe "ProjectController", ->
 				done()
 			@ProjectController.projectListPage @req, @res
 
+		describe 'front widget', (done) ->
+			beforeEach ->
+				@settings.overleaf =
+					front_chat_widget_room_id: 'chat-room-id'
+
+			it 'should show for paid users', (done) ->
+				@user.features.github = true
+				@user.features.dropbox = true
+				@res.render = (pageName, opts)=>
+					opts.frontChatWidgetRoomId.should.equal @settings.overleaf.front_chat_widget_room_id
+					done()
+				@ProjectController.projectListPage @req, @res
+
+			it 'should show for sample users', (done) ->
+				@user._id = '588f3ddae8ebc1bac07c9f00' # last two digits
+				@res.render = (pageName, opts)=>
+					opts.frontChatWidgetRoomId.should.equal @settings.overleaf.front_chat_widget_room_id
+					done()
+				@ProjectController.projectListPage @req, @res
+
+			it 'should not show for non sample users', (done) ->
+				@user._id = '588f3ddae8ebc1bac07c9fff' # last two digits
+				@res.render = (pageName, opts)=>
+					expect(opts.frontChatWidgetRoomId).to.equal undefined
+					done()
+				@ProjectController.projectListPage @req, @res
+
 		describe 'with overleaf-integration-web-module hook', ->
 			beforeEach ->
 				@V1Response =

+ 5 - 1
services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee

@@ -98,7 +98,11 @@ describe 'ProjectCreationHandler', ->
 
 			it "should set the overleaf id if overleaf id provided", (done)->
 				overleaf_id = 2345
-				@handler.createBlankProject ownerId, projectName, overleaf_id, (err, project)->
+				attributes =
+					overleaf:
+						history:
+							id: overleaf_id
+				@handler.createBlankProject ownerId, projectName, attributes, (err, project)->
 					project.overleaf.history.id.should.equal overleaf_id
 					done()
 

+ 13 - 6
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -872,6 +872,12 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(@project, @entity, @path, userId)
 					.should.equal true
 
+			it "should should send the update to the doc updater", ->
+				oldDocs = [ doc: @entity, path: @path ]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {oldDocs})
+					.should.equal true
+
 		describe "a folder", ->
 			beforeEach (done) ->
 				@folder =
@@ -905,6 +911,13 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
 					.should.equal true
 
+			it "should should send one update to the doc updater for all docs and files", ->
+				oldFiles = [ {file: @file2, path: "/folder/file-name-2"}, {file: @file1, path: "/folder/subfolder/file-name-1"} ]
+				oldDocs = [ {doc: @doc2, path: "/folder/doc-name-2"}, { doc: @doc1, path: "/folder/subfolder/doc-name-1"} ]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {oldFiles, oldDocs})
+					.should.equal true
+
 	describe "_cleanUpDoc", ->
 		beforeEach ->
 			@doc =
@@ -941,12 +954,6 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(project_id, @doc._id.toString())
 					.should.equal true
 
-			it "should should send the update to the doc updater", ->
-				oldDocs = [ doc: @doc, path: @path ]
-				@DocumentUpdaterHandler.updateProjectStructure
-					.calledWith(project_id, projectHistoryId, userId, {oldDocs})
-					.should.equal true
-
 			it "should call the callback", ->
 				@callback.called.should.equal true
 

+ 7 - 118
services/web/test/unit/coffee/Referal/ReferalAllocatorTests.coffee

@@ -4,12 +4,12 @@ require('chai').should()
 sinon = require('sinon')
 modulePath = require('path').join __dirname, '../../../../app/js/Features/Referal/ReferalAllocator.js'
 
-describe 'Referalallocator', ->
+describe 'ReferalAllocator', ->
 
 	beforeEach ->
 		@ReferalAllocator = SandboxedModule.require modulePath, requires:
 			'../../models/User': User: @User = {}
-			"../Subscription/SubscriptionLocator": @SubscriptionLocator = {}
+			"../Subscription/FeaturesUpdater": @FeaturesUpdater = {}
 			"settings-sharelatex": @Settings = {}
 			'logger-sharelatex':
 				log:->
@@ -26,7 +26,7 @@ describe 'Referalallocator', ->
 				@referal_source = "bonus"
 				@User.update = sinon.stub().callsArgWith 3, null
 				@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
-				@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
+				@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
 				@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
 
 			it 'should update the referring user with the refered users id', ->
@@ -44,8 +44,8 @@ describe 'Referalallocator', ->
 					.calledWith( referal_id: @referal_id )
 					.should.equal true
 			
-			it "shoudl assign the user their bonus", ->
-				@ReferalAllocator.assignBonus
+			it "should refresh the user's subscription", ->
+				@FeaturesUpdater.refreshFeatures
 					.calledWith(@user_id)
 					.should.equal true
 
@@ -57,7 +57,7 @@ describe 'Referalallocator', ->
 				@referal_source = "public_share"
 				@User.update = sinon.stub().callsArgWith 3, null
 				@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
-				@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
+				@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
 				@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
 
 			it 'should not update the referring user with the refered users id', ->
@@ -69,118 +69,7 @@ describe 'Referalallocator', ->
 					.should.equal true
 			
 			it "should not assign the user a bonus", ->
-				@ReferalAllocator.assignBonus.called.should.equal false
+				@FeaturesUpdater.refreshFeatures.called.should.equal false
 
 			it "should call the callback", ->
 				@callback.called.should.equal true
-
-	describe "assignBonus", ->
-			beforeEach ->
-				@refered_user_count = 3
-				@Settings.bonus_features =
-					"3":
-						collaborators: 3
-						dropbox: false
-						versioning: false
-				stubbedUser = { 
-					refered_user_count: @refered_user_count, 
-					features:{collaborators:1, dropbox:false, versioning:false} 
-				}
-
-				@User.findOne = sinon.stub().callsArgWith 1, null, stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-				@ReferalAllocator.assignBonus @user_id, @callback
-
-			it "should get the users number of refered user", ->
-				@User.findOne
-					.calledWith(_id: @user_id)
-					.should.equal true
-
-			it "should update the user to bonus features", ->
-				@User.update
-					.calledWith({
-						_id: @user_id
-					}, {
-						$set:
-							features:
-								@Settings.bonus_features[@refered_user_count.toString()]
-					})
-					.should.equal true
-
-			it "should call the callback", ->
-				@callback.called.should.equal true
-	
-		describe "when there is nothing to assign", ->
-
-			beforeEach ->
-				@ReferalAllocator._calculateBonuses = sinon.stub().returns({})
-				@stubbedUser =
-					refered_user_count:4
-					features:{collaborators:3, versioning:true, dropbox:false}
-				@Settings.bonus_features =
-					"4":
-						collaborators:3
-						versioning:true
-						dropbox:false
-				@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-
-			it "should not call update if there are no bonuses to apply", (done)->
-				@ReferalAllocator.assignBonus @user_id, (err)=>
-					@User.update.called.should.equal false
-					done()
-
-		describe "when the user has better features already", ->
-			
-			beforeEach ->
-				@refered_user_count = 3
-				@stubbedUser =
-					refered_user_count:4
-					features:
-						collaborators:3
-						dropbox:false
-						versioning:false
-				@Settings.bonus_features =
-					"4":
-						collaborators: 10
-						dropbox: true
-						versioning: false
-
-				@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
-				@User.update = sinon.stub().callsArgWith 2, null
-
-			it "should not set in in mongo when the feature is better", (done)->
-				@ReferalAllocator.assignBonus @user_id, =>
-					@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:10} }).should.equal true
-					done()
-
-			it "should not overright if the user has -1 users", (done)->
-				@stubbedUser.features.collaborators = -1
-				@ReferalAllocator.assignBonus @user_id, =>
-					@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:-1} }).should.equal true
-					done()
-
-		describe "when the user is not at a bonus level", ->
-			beforeEach ->
-				@refered_user_count = 0
-				@Settings.bonus_features =
-					"1":
-						collaborators: 3
-						dropbox: false
-						versioning: false
-				@User.findOne = sinon.stub().callsArgWith 1, null, { refered_user_count: @refered_user_count }
-				@User.update = sinon.stub().callsArgWith 2, null
-				@ReferalAllocator.assignBonus @user_id, @callback
-
-			it "should get the users number of refered user", ->
-				@User.findOne
-					.calledWith(_id: @user_id)
-					.should.equal true
-
-			it "should not update the user to bonus features", ->
-				@User.update.called.should.equal false
-
-			it "should call the callback", ->
-				@callback.called.should.equal true
-
-

Некоторые файлы не были показаны из-за большого количества измененных файлов