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

Merge remote-tracking branch 'origin/master' into afc-update-user-references

Alberto Fernández Capel 8 лет назад
Родитель
Сommit
57775e60b1
100 измененных файлов с 3420 добавлено и 757 удалено
  1. 3 3
      services/web/Makefile
  2. 1 1
      services/web/Makefile.module
  3. 2 2
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  4. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  5. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  6. 11 1
      services/web/app/coffee/Features/Exports/ExportsController.coffee
  7. 10 3
      services/web/app/coffee/Features/Exports/ExportsHandler.coffee
  8. 16 9
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
  9. 127 0
      services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
  10. 7 1
      services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
  11. 1 1
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  12. 29 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  13. 31 28
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  14. 2 0
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  15. 11 2
      services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
  16. 3 0
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  17. 3 3
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  18. 38 28
      services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
  19. 133 0
      services/web/app/coffee/Features/Subscription/planFeatures.coffee
  20. 80 0
      services/web/app/coffee/Features/Templates/TemplatesController.coffee
  21. 8 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear
  22. 9 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee
  23. 10 0
      services/web/app/coffee/Features/Templates/TemplatesRouter.coffee
  24. 2 2
      services/web/app/coffee/Features/User/UserController.coffee
  25. 0 9
      services/web/app/coffee/Features/User/UserCreator.coffee
  26. 30 0
      services/web/app/coffee/Features/User/UserGetter.coffee
  27. 0 21
      services/web/app/coffee/Features/User/UserLocator.coffee
  28. 1 2
      services/web/app/coffee/Features/User/UserPagesController.coffee
  29. 2 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  30. 81 14
      services/web/app/coffee/Features/User/UserUpdater.coffee
  31. 20 5
      services/web/app/coffee/infrastructure/FileWriter.coffee
  32. 2 1
      services/web/app/coffee/infrastructure/Modules.coffee
  33. 9 2
      services/web/app/coffee/router.coffee
  34. 0 67
      services/web/app/views/contact-us-modal.pug
  35. 1 1
      services/web/app/views/layout.pug
  36. 4 1
      services/web/app/views/project/editor.pug
  37. 16 1
      services/web/app/views/project/editor/binary-file.pug
  38. 2 4
      services/web/app/views/project/editor/editor.pug
  39. 71 0
      services/web/app/views/project/editor/file-tree.pug
  40. 0 17
      services/web/app/views/project/editor/history-file-tree.pug
  41. 3 82
      services/web/app/views/project/editor/history.pug
  42. 2 2
      services/web/app/views/project/editor/history/diffPanelV1.pug
  43. 82 0
      services/web/app/views/project/editor/history/entriesListV1.pug
  44. 174 0
      services/web/app/views/project/editor/history/entriesListV2.pug
  45. 70 0
      services/web/app/views/project/editor/history/fileTreeV2.pug
  46. 34 3
      services/web/app/views/project/editor/history/previewPanelV2.pug
  47. 13 0
      services/web/app/views/project/editor/history/toolbarV2.pug
  48. 18 0
      services/web/app/views/project/editor/left-menu.pug
  49. 26 0
      services/web/app/views/project/editor/new_from_template.pug
  50. 47 2
      services/web/app/views/project/list/item.pug
  51. 8 2
      services/web/app/views/project/list/project-list.pug
  52. 2 2
      services/web/app/views/project/list/v1-item.pug
  53. 118 0
      services/web/app/views/subscriptions/_plans_page_details_less.pug
  54. 160 0
      services/web/app/views/subscriptions/_plans_page_details_more.pug
  55. 162 0
      services/web/app/views/subscriptions/_plans_page_mixins.pug
  56. 107 0
      services/web/app/views/subscriptions/_plans_page_tables.pug
  57. 4 1
      services/web/app/views/subscriptions/new.pug
  58. 8 243
      services/web/app/views/subscriptions/plans.pug
  59. 2 2
      services/web/config/settings.defaults.coffee
  60. 1 0
      services/web/docker-compose.yml
  61. 39 5
      services/web/public/coffee/directives/eventTracking.coffee
  62. 5 4
      services/web/public/coffee/ide.coffee
  63. 24 7
      services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
  64. 1 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  65. 5 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  66. 124 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  67. 3 13
      services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
  68. 13 0
      services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee
  69. 0 1
      services/web/public/coffee/ide/history/HistoryManager.coffee
  70. 86 10
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  71. 19 0
      services/web/public/coffee/ide/history/components/historyEntriesList.coffee
  72. 27 0
      services/web/public/coffee/ide/history/components/historyEntry.coffee
  73. 34 0
      services/web/public/coffee/ide/history/components/historyFileEntity.coffee
  74. 18 0
      services/web/public/coffee/ide/history/components/historyFileTree.coffee
  75. 1 1
      services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
  76. 13 14
      services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
  77. 54 0
      services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee
  78. 76 0
      services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee
  79. 4 0
      services/web/public/coffee/ide/history/util/HistoryViewModes.coffee
  80. 16 0
      services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
  81. 3 0
      services/web/public/coffee/ide/test-controls/index.coffee
  82. 0 72
      services/web/public/coffee/main/contact-us.coffee
  83. 7 0
      services/web/public/coffee/main/event.coffee
  84. 4 0
      services/web/public/coffee/main/new-subscription.coffee
  85. 40 29
      services/web/public/coffee/main/plans.coffee
  86. 39 14
      services/web/public/coffee/main/project-list/project-list.coffee
  87. 2 2
      services/web/public/coffee/services/algolia-search.coffee
  88. 20 0
      services/web/public/coffee/services/wait-for.coffee
  89. BIN
      services/web/public/img/advocates/erdogmus.jpg
  90. BIN
      services/web/public/img/advocates/henderson.jpg
  91. 1 0
      services/web/public/stylesheets/_style_includes.less
  92. 6 1
      services/web/public/stylesheets/app/editor.less
  93. 498 0
      services/web/public/stylesheets/app/editor/history-v2.less
  94. 13 8
      services/web/public/stylesheets/app/editor/history.less
  95. 90 1
      services/web/public/stylesheets/app/editor/toolbar.less
  96. 264 1
      services/web/public/stylesheets/app/plans.less
  97. 10 0
      services/web/public/stylesheets/app/project-list.less
  98. 6 1
      services/web/public/stylesheets/components/forms.less
  99. 19 1
      services/web/public/stylesheets/core/_common-variables.less
  100. 17 0
      services/web/public/stylesheets/core/ol-variables.less

+ 3 - 3
services/web/Makefile

@@ -180,7 +180,7 @@ clean_css:
 	rm -f public/stylesheets/*.css*
 	rm -f public/stylesheets/*.css*
 
 
 clean_ci:
 clean_ci:
-	docker-compose down -v
+	docker-compose down -v -t 0
 
 
 test: test_unit test_frontend test_acceptance
 test: test_unit test_frontend test_acceptance
 
 
@@ -204,7 +204,7 @@ test_acceptance_app_start_service: test_clean # stop service and clear dbs
 	docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance
 	docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance
 
 
 test_acceptance_app_stop_service:
 test_acceptance_app_stop_service:
-	docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo
+	docker-compose ${DOCKER_COMPOSE_FLAGS} stop -t 0 test_acceptance redis mongo
 
 
 test_acceptance_app_run:
 test_acceptance_app_run:
 	docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}
 	docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}
@@ -224,7 +224,7 @@ test_acceptance_module: $(MODULE_MAKEFILES)
 	fi
 	fi
 
 
 test_clean:
 test_clean:
-	docker-compose ${DOCKER_COMPOSE_FLAGS} down -v
+	docker-compose ${DOCKER_COMPOSE_FLAGS} down -v -t 0
 
 
 ci:
 ci:
 	MOCHA_ARGS="--reporter tap" \
 	MOCHA_ARGS="--reporter tap" \

+ 1 - 1
services/web/Makefile.module

@@ -62,7 +62,7 @@ test_acceptance_start_service: test_acceptance_stop_service
 	$(DOCKER_COMPOSE) up -d test_acceptance
 	$(DOCKER_COMPOSE) up -d test_acceptance
 
 
 test_acceptance_stop_service:
 test_acceptance_stop_service:
-	$(DOCKER_COMPOSE) stop test_acceptance redis mongo
+	$(DOCKER_COMPOSE) stop -t 0 test_acceptance redis mongo
 
 
 test_acceptance_run:
 test_acceptance_run:
 	$(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js
 	$(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js

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

@@ -1,5 +1,5 @@
 BetaProgramHandler = require './BetaProgramHandler'
 BetaProgramHandler = require './BetaProgramHandler'
-UserLocator = require "../User/UserLocator"
+UserGetter = require "../User/UserGetter"
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
 AuthenticationController = require '../Authentication/AuthenticationController'
 AuthenticationController = require '../Authentication/AuthenticationController'
@@ -30,7 +30,7 @@ module.exports = BetaProgramController =
 	optInPage: (req, res, next)->
 	optInPage: (req, res, next)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "showing beta participation page for user"
 		logger.log {user_id}, "showing beta participation page for user"
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			if err
 			if err
 				logger.err {err, user_id}, "error fetching user"
 				logger.err {err, user_id}, "error fetching user"
 				return next(err)
 				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)->) ->
 	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
 		if Settings.restrictInvitesToExistingAccounts == true
 		if Settings.restrictInvitesToExistingAccounts == true
 			logger.log {email}, "checking if user exists with this email"
 			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?
 				return callback(err) if err?
 				userExists = user? and user?._id?
 				userExists = user? and user?._id?
 				callback(null, userExists)
 				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)->) ->
 	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
 		email = invite.email
 		email = invite.email
-		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+		UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
 			if err?
 			if err?
 				logger.err {projectId, email}, "error checking if user exists"
 				logger.err {projectId, email}, "error checking if user exists"
 				return callback(err)
 				return callback(err)

+ 11 - 1
services/web/app/coffee/Features/Exports/ExportsController.coffee

@@ -7,7 +7,17 @@ module.exports =
 	exportProject: (req, res) ->
 	exportProject: (req, res) ->
 		{project_id, brand_variation_id} = req.params
 		{project_id, brand_variation_id} = req.params
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
+		export_params = {
+			project_id: project_id,
+			brand_variation_id: brand_variation_id,
+			user_id: user_id
+		}
+
+		if req.body && req.body.firstName && req.body.lastName
+			export_params.first_name = req.body.firstName.trim()
+			export_params.last_name = req.body.lastName.trim()
+
+		ExportsHandler.exportProject export_params, (err, export_data) ->
 			return next(err) if err?
 			return next(err) if err?
 			logger.log 
 			logger.log 
 				user_id:user_id
 				user_id:user_id

+ 10 - 3
services/web/app/coffee/Features/Exports/ExportsHandler.coffee

@@ -10,8 +10,8 @@ settings = require 'settings-sharelatex'
 
 
 module.exports = ExportsHandler = self =
 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) ->
+	exportProject: (export_params, callback=(error, export_data) ->) ->
+		self._buildExport export_params, (err, export_data) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			self._requestExport export_data, (err, export_v1_id) ->
 			self._requestExport export_data, (err, export_v1_id) ->
 				return callback(err) if err?
 				return callback(err) if err?
@@ -19,7 +19,10 @@ module.exports = ExportsHandler = self =
 				# TODO: possibly store the export data in Mongo
 				# TODO: possibly store the export data in Mongo
 				callback null, export_data
 				callback null, export_data
 
 
-	_buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
+	_buildExport: (export_params, callback=(err, export_data) ->) ->
+		project_id = export_params.project_id
+		user_id = export_params.user_id
+		brand_variation_id = export_params.brand_variation_id
 		jobs =
 		jobs =
 			project: (cb) ->
 			project: (cb) ->
 				ProjectGetter.getProject project_id, cb
 				ProjectGetter.getProject project_id, cb
@@ -43,6 +46,10 @@ module.exports = ExportsHandler = self =
 				logger.err err:err, project_id: project_id
 				logger.err err:err, project_id: project_id
 				return callback(err)
 				return callback(err)
 
 
+			if export_params.first_name && export_params.last_name
+				user.first_name = export_params.first_name
+				user.last_name = export_params.last_name
+
 			export_data =
 			export_data =
 				project:
 				project:
 					id: project_id
 					id: project_id

+ 16 - 9
services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee

@@ -5,7 +5,8 @@ logger = require 'logger-sharelatex'
 
 
 module.exports = LinkedFilesController = {
 module.exports = LinkedFilesController = {
 	Agents: {
 	Agents: {
-		url: require('./UrlAgent')
+		url: require('./UrlAgent'),
+		project_file: require('./ProjectFileAgent')
 	}
 	}
 
 
 	createLinkedFile: (req, res, next) ->
 	createLinkedFile: (req, res, next) ->
@@ -22,11 +23,17 @@ module.exports = LinkedFilesController = {
 
 
 		linkedFileData = Agent.sanitizeData(data)
 		linkedFileData = Agent.sanitizeData(data)
 		linkedFileData.provider = provider
 		linkedFileData.provider = provider
-		Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
-			if error?
-				logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
-				return Agent.handleError(error, req, res, next)
-			EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) ->
-				return next(error) if error?
-				res.send(204) # created
-}
+		Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) ->
+			return Agent.handleError(err, req, res, next) if err?
+			return res.sendStatus(403) if !allowed
+			Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) ->
+				return Agent.handleError(err) if err?
+				linkedFileData = newLinkedFileData
+				Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
+					if error?
+						logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
+						return Agent.handleError(error, req, res, next)
+					EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) ->
+						return next(error) if error?
+						res.json(new_file_id: file._id) # created
+	}

+ 127 - 0
services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee

@@ -0,0 +1,127 @@
+FileWriter = require('../../infrastructure/FileWriter')
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectLocator = require('../Project/ProjectLocator')
+ProjectGetter = require('../Project/ProjectGetter')
+DocstoreManager = require('../Docstore/DocstoreManager')
+FileStoreHandler = require('../FileStore/FileStoreHandler')
+FileWriter = require('../../infrastructure/FileWriter')
+_ = require "underscore"
+Settings = require 'settings-sharelatex'
+
+
+AccessDeniedError = (message) ->
+	error = new Error(message)
+	error.name = 'AccessDenied'
+	error.__proto__ = AccessDeniedError.prototype
+	return error
+AccessDeniedError.prototype.__proto__ = Error.prototype
+
+
+BadEntityTypeError = (message) ->
+	error = new Error(message)
+	error.name = 'BadEntityType'
+	error.__proto__ = BadEntityTypeError.prototype
+	return error
+BadEntityTypeError.prototype.__proto__ = Error.prototype
+
+
+BadDataError = (message) ->
+	error = new Error(message)
+	error.name = 'BadData'
+	error.__proto__ = BadDataError.prototype
+	return error
+BadDataError.prototype.__proto__ = Error.prototype
+
+
+ProjectNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'ProjectNotFound'
+	error.__proto__ = ProjectNotFoundError.prototype
+	return error
+ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'BadData'
+	error.__proto__ = SourceFileNotFoundError.prototype
+	return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = ProjectFileAgent =
+
+	sanitizeData: (data) ->
+		return _.pick(
+			data,
+			'source_project_id',
+			'source_entity_path'
+		)
+
+	_validate: (data) ->
+		return (
+			data.source_project_id? &&
+			data.source_entity_path?
+		)
+
+	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+		callback = _.once(callback)
+		{ source_project_id } = data
+		return callback(new BadDataError()) if !source_project_id?
+		ProjectGetter.getProject source_project_id, (err, project) ->
+			return callback(err) if err?
+			return callback(new ProjectNotFoundError()) if !project?
+			callback(err, _.extend(data, {source_project_display_name: project.name}))
+
+	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+		callback = _.once(callback)
+		if !ProjectFileAgent._validate(data)
+			return callback(new BadDataError())
+		{source_project_id, source_entity_path} = data
+		AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) ->
+			return callback(err) if err?
+			callback(null, canRead)
+
+	writeIncomingFileToDisk:
+		(project_id, data, current_user_id, callback = (error, fsPath) ->) ->
+			callback = _.once(callback)
+			if !ProjectFileAgent._validate(data)
+				return callback(new BadDataError())
+			{source_project_id, source_entity_path} = data
+			ProjectLocator.findElementByPath {
+				project_id: source_project_id,
+				path: source_entity_path
+			}, (err, entity, type) ->
+				if err?
+					if err.toString().match(/^not found.*/)
+						err = new SourceFileNotFoundError()
+					return callback(err)
+				ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback
+
+	_writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) ->
+		callback = _.once(callback)
+		if type == 'doc'
+			DocstoreManager.getDoc project_id, entity_id, (err, lines) ->
+				return callback(err) if err?
+				FileWriter.writeLinesToDisk entity_id, lines, callback
+		else if type == 'file'
+			FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) ->
+				return callback(err) if err?
+				FileWriter.writeStreamToDisk entity_id, fileStream, callback
+		else
+			callback(new BadEntityTypeError())
+
+	handleError: (error, req, res, next) ->
+		if error instanceof AccessDeniedError
+			res.status(403).send("You do not have access to this project")
+		else if error instanceof BadDataError
+			res.status(400).send("The submitted data is not valid")
+		else if error instanceof BadEntityTypeError
+			res.status(400).send("The file is the wrong type")
+		else if error instanceof SourceFileNotFoundError
+			res.status(404).send("Source file not found")
+		else if error instanceof ProjectNotFoundError
+			res.status(404).send("Project not found")
+		else
+			next(error)
+		next()

+ 7 - 1
services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee

@@ -27,6 +27,12 @@ module.exports = UrlAgent = {
 			url: @._prependHttpIfNeeded(data.url)
 			url: @._prependHttpIfNeeded(data.url)
 		}
 		}
 
 
+	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+		return callback(null, data)
+
+	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+		callback(null, true)
+
 	writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
 	writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		callback = _.once(callback)
 		url = data.url
 		url = data.url
@@ -65,4 +71,4 @@ module.exports = UrlAgent = {
 		if !Settings.apis?.linkedUrlProxy?.url?
 		if !Settings.apis?.linkedUrlProxy?.url?
 			throw new Error('no linked url proxy configured')
 			throw new Error('no linked url proxy configured')
 		return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}"
 		return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}"
-}
+}

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

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

+ 29 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources"
 TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
 TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
 CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
 CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
 Modules = require '../../infrastructure/Modules'
 Modules = require '../../infrastructure/Modules'
+ProjectEntityHandler = require './ProjectEntityHandler'
 crypto = require 'crypto'
 crypto = require 'crypto'
 
 
 module.exports = ProjectController =
 module.exports = ProjectController =
@@ -138,6 +139,33 @@ module.exports = ProjectController =
 			return next(err) if err?
 			return next(err) if err?
 			res.sendStatus 200
 			res.sendStatus 200
 
 
+	userProjectsJson: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ProjectGetter.findAllUsersProjects user_id,
+			'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) ->
+				return next(err) if err?
+				projects = ProjectController._buildProjectList(projects)
+					.filter((p) -> !p.archived)
+					.filter((p) -> !p.isV1Project)
+					.map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel})
+
+				res.json({projects: projects})
+
+	projectEntitiesJson: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		project_id = req.params.Project_id
+		ProjectGetter.getProject project_id, (err, project) ->
+			return next(err) if err?
+			ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) ->
+				return next(err) if err?
+				entities = docs.concat(files)
+					.sort (a, b) -> a.path > b.path  # Sort by path ascending
+					.map (e) -> {
+						path: e.path,
+						type: if e.doc? then 'doc' else 'file'
+					}
+				res.json({project_id: project_id, entities: entities})
+
 	projectListPage: (req, res, next)->
 	projectListPage: (req, res, next)->
 		timer = new metrics.Timer("project-list")
 		timer = new metrics.Timer("project-list")
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
@@ -313,6 +341,7 @@ module.exports = ProjectController =
 					maxDocLength: Settings.max_doc_length
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
 					useV2History: !!project.overleaf?.history?.display
 					showRichText: req.query?.rt == 'true'
 					showRichText: req.query?.rt == 'true'
+					showTestControls: req.query?.tc == 'true' || user.isAdmin
 					showPublishModal: req.query?.pm == 'true'
 					showPublishModal: req.query?.pm == 'true'
 				timer.done()
 				timer.done()
 
 

+ 31 - 28
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) ->
 		methodWithLock
 		methodWithLock
 
 
 module.exports = ProjectEntityUpdateHandler = self =
 module.exports = ProjectEntityUpdateHandler = self =
-	# this doesn't need any locking because it's only called by ProjectDuplicator
-	copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
-		project_id = project._id
-		projectHistoryId = project.overleaf?.history?.id
-		logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
-		return callback(err) if err?
-		ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=>
-			if !origonalFileRef?
-				logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
-				return callback()
-			# convert any invalid characters in original file to '_'
-			fileRef = new File name : SafePath.clean(origonalFileRef.name)
-			FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
+	copyFileFromExistingProjectWithProject: wrapWithLock
+		beforeLock: (next) ->
+			(project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
+				project_id = project._id
+				logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
+				ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) ->
+					if !origonalFileRef?
+						logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
+						return callback()
+					# convert any invalid characters in original file to '_'
+					fileRef = new File name : SafePath.clean(origonalFileRef.name)
+					FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
+						if err?
+							logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
+							return callback(err)
+						next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback)
+		withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)->
+			project_id = project._id
+			projectHistoryId = project.overleaf?.history?.id
+			ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) ->
 				if err?
 				if err?
-					logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
+					logger.err { err, project_id, folder_id }, "error putting element as part of copy"
 					return callback(err)
 					return callback(err)
-				ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+				TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
 					if err?
 					if err?
-						logger.err { err, project_id, folder_id }, "error putting element as part of copy"
-						return callback(err)
-					TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
-						if err?
-							logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
-						newFiles = [
-							file: fileRef
-							path: result?.path?.fileSystem
-							url: fileStoreUrl
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
-							return callback(error) if error?
-							callback null, fileRef, folder_id
+						logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
+					newFiles = [
+						file: fileRef
+						path: result?.path?.fileSystem
+						url: fileStoreUrl
+					]
+					DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) ->
+						return callback(error) if error?
+						callback null, fileRef, folder_id
 
 
 	updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
 	updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->

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

@@ -10,6 +10,8 @@ Async = require('async')
 oneMinInMs = 60 * 1000
 oneMinInMs = 60 * 1000
 fiveMinsInMs = oneMinInMs * 5
 fiveMinsInMs = oneMinInMs * 5
 
 
+if !settings.apis?.references?.url?
+	logger.log "references search not enabled"
 
 
 module.exports = ReferencesHandler =
 module.exports = ReferencesHandler =
 
 

+ 11 - 2
services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee

@@ -11,7 +11,16 @@ V1SubscriptionManager = require("./V1SubscriptionManager")
 oneMonthInSeconds = 60 * 60 * 24 * 30
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
 
 module.exports = FeaturesUpdater =
 module.exports = FeaturesUpdater =
-	refreshFeatures: (user_id, callback)->
+	refreshFeatures: (user_id, notifyV1 = true, callback = () ->)->
+		if typeof notifyV1 == 'function'
+			callback = notifyV1
+			notifyV1 = true
+
+		if notifyV1
+			V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) ->
+				if error?
+					logger.err {err: error, user_id}, "error notifying v1 about updated features"
+
 		jobs =
 		jobs =
 			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
 			individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
 			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
 			groupFeatureSets:   (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
@@ -80,4 +89,4 @@ module.exports = FeaturesUpdater =
 		if !plan?
 		if !plan?
 			return {}
 			return {}
 		else
 		else
-			return plan.features
+			return plan.features

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

@@ -10,6 +10,7 @@ GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 FeaturesUpdater = require './FeaturesUpdater'
 FeaturesUpdater = require './FeaturesUpdater'
+planFeatures = require './planFeatures'
 
 
 module.exports = SubscriptionController =
 module.exports = SubscriptionController =
 
 
@@ -20,6 +21,7 @@ module.exports = SubscriptionController =
 			viewName = "#{viewName}_#{req.query.v}"
 			viewName = "#{viewName}_#{req.query.v}"
 		logger.log viewName:viewName, "showing plans page"
 		logger.log viewName:viewName, "showing plans page"
 		currentUser = null
 		currentUser = null
+
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 			return next(err) if err?
 			return next(err) if err?
 			render = () ->
 			render = () ->
@@ -29,6 +31,7 @@ module.exports = SubscriptionController =
 					gaExperiments: Settings.gaExperiments.plansPage
 					gaExperiments: Settings.gaExperiments.plansPage
 					recomendedCurrency:recomendedCurrency
 					recomendedCurrency:recomendedCurrency
 					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
 					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
+					planFeatures: planFeatures
 			user_id = AuthenticationController.getLoggedInUserId(req)
 			user_id = AuthenticationController.getLoggedInUserId(req)
 			if user_id?
 			if user_id?
 				UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->
 				UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->

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

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

+ 38 - 28
services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee

@@ -12,39 +12,49 @@ module.exports = V1SubscriptionManager =
 	#   - 'v1_free'
 	#   - 'v1_free'
 	getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
 	getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
 		logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
 		logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
+		V1SubscriptionManager._v1Request userId, {
+			method: 'GET',
+			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/plan_code"
+		}, (error, body) ->
+			return callback(error) if error?
+			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)
+
+	notifyV1OfFeaturesChange: (userId, callback = (error) ->) ->
+		V1SubscriptionManager._v1Request userId, {
+			method: 'POST',
+			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/sync"
+		}, callback
+
+	_v1Request: (userId, options, callback=(err, body)->) ->
+		if !settings?.apis?.v1
+			return callback null, null
 		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
 		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			v1Id = user?.overleaf?.id
 			v1Id = user?.overleaf?.id
 			if !v1Id?
 			if !v1Id?
 				logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
 				logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
 				return callback(null, null)
 				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}"
+			request {
+				baseUrl: settings.apis.v1.url
+				url: options.url(v1Id)
+				method: options.method
+				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
 				else
-					# Throw away 'anonymous', etc as being equivalent to null
-					planName = null
-				return callback(null, planName)
+					return callback new Error("non-success code from v1: #{response.statusCode}")
 
 
-	_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}")

+ 133 - 0
services/web/app/coffee/Features/Subscription/planFeatures.coffee

@@ -0,0 +1,133 @@
+module.exports =
+	[
+		{
+			feature: 'number_collab'
+			value: 'str'
+			plans: {
+				free: '1'
+				coll: '10'
+				prof: 'unlimited'
+			}
+			student: '6'
+		}
+		{
+			feature: 'unlimited_private'
+			value: 'bool'
+			info: 'unlimited_private_info'
+			plans: {
+				free: true
+				coll: true
+				prof: true
+			},
+			student: true
+		}
+		{
+			feature: 'realtime_collab'
+			value: 'bool'
+			info: 'realtime_collab_info'
+			plans: {
+				free: true
+				coll: true
+				prof: true
+			}
+			student: true
+		}
+		{
+			feature: 'hundreds_templates'
+			value: 'bool'
+			info: 'hundreds_templates_info'
+			plans: {
+				free: true
+				coll: true
+				prof: true
+			}
+			student: true
+		}
+		{
+			feature: 'powerful_latex_editor'
+			value: 'bool'
+			info: 'latex_editor_info'
+			plans: {
+				free: true
+				coll: true
+				prof: true
+			}
+			student: true
+		}
+		{
+			feature: 'realtime_track_changes'
+			value: 'bool'
+			info: 'realtime_track_changes_info'
+			plans: {
+				free: false
+				coll: true
+				prof: true
+			},
+			student: true
+		}
+		{
+			feature: 'reference_search'
+			value: 'bool'
+			info: 'reference_search_info'
+			plans: {
+				free: false
+				coll: true
+				prof: true
+			},
+			student: true
+		},
+		{
+			feature: 'reference_sync'
+			info: 'reference_sync_info'
+			value: 'bool'
+			plans: {
+				free: false
+				coll: true
+				prof: true
+			},
+			student: true
+		}
+		{
+			feature: 'full_doc_history'
+			value: 'bool'
+			info: 'full_doc_history_info'
+			plans: {
+				free: false,
+				coll: true,
+				prof: true
+			},
+			student: true
+		}
+		{
+			feature: 'dropbox_integration_lowercase'
+			value: 'bool'
+			info: 'dropbox_integration_info'
+			plans: {
+				free: false,
+				coll: true,
+				prof: true
+			},
+			student: true
+		},
+		{
+			feature: 'github_integration_lowercase'
+			value: 'bool'
+			info: 'github_integration_info'
+			plans: {
+				free: false,
+				coll: true,
+				prof: true
+			},
+			student: true
+		},
+		{
+			feature: 'priority_support',
+			value: 'bool',
+			plans: {
+				free: false,
+				coll: true,
+				prof: true
+			},
+			student: true
+		},
+	]

+ 80 - 0
services/web/app/coffee/Features/Templates/TemplatesController.coffee

@@ -0,0 +1,80 @@
+path = require('path')
+Project = require('../../../js/models/Project').Project
+ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager')
+ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler')
+AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController')
+settings = require('settings-sharelatex')
+fs = require('fs')
+request = require('request')
+uuid = require('uuid')
+logger = require('logger-sharelatex')
+async = require("async")
+
+
+module.exports = TemplatesController =
+
+	getV1Template: (req, res)->
+		templateVersionId = req.params.Template_version_id
+		templateId = req.query.id
+		if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)
+			logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version"
+			return res.sendStatus 400
+		data = {}
+		data.templateVersionId = templateVersionId
+		data.templateId = templateId
+		data.name = req.query.templateName
+		data.compiler = req.query.latexEngine
+		res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data
+
+	createProjectFromV1Template: (req, res)->
+		currentUserId = AuthenticationController.getLoggedInUserId(req)
+		zipUrl =	"#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}"
+		zipReq = request(zipUrl, {
+			'auth': {
+				'user': settings.apis.v1.user,
+				'pass': settings.apis.v1.pass
+			}
+		})
+
+		TemplatesController.createFromZip(
+			zipReq,
+			{
+				templateName: req.body.templateName,
+				currentUserId: currentUserId,
+				compiler: req.body.compiler
+				docId: req.body.docId
+				templateId: req.body.templateId
+				templateVersionId: req.body.templateVersionId
+			},
+			req,
+			res
+		)
+
+	createFromZip: (zipReq, options, req, res)->
+		dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}"
+		writeStream = fs.createWriteStream(dumpPath)
+
+		zipReq.on "error", (error) ->
+			logger.error err: error, "error getting zip from template API"
+		zipReq.pipe(writeStream)
+		writeStream.on 'close', ->
+			ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)->
+				if err?
+					logger.err err:err, zipReq:zipReq, "problem building project from zip"
+					return res.sendStatus 500
+				setCompiler project._id, options.compiler, ->
+					fs.unlink dumpPath, ->
+					delete req.session.templateData
+					conditions = {_id:project._id}
+					update = {
+						fromV1TemplateId:options.templateId,
+						fromV1TemplateVersionId:options.templateVersionId
+					}
+					Project.update conditions, update, {}, (err)->
+						res.redirect "/project/#{project._id}"
+
+setCompiler = (project_id, compiler, callback)->
+	if compiler?
+		ProjectOptionsHandler.setCompiler project_id, compiler, callback
+	else
+		callback()

+ 8 - 0
services/web/app/coffee/Features/Templates/TemplatesMiddlewear

@@ -0,0 +1,8 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+module.exports =
+	saveTemplateDataInSession: (req, res, next)->
+		if req.query.templateName
+			req.session.templateData = req.query
+		next()

+ 9 - 0
services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee

@@ -0,0 +1,9 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+
+module.exports =
+	saveTemplateDataInSession: (req, res, next)->
+		if req.query.templateName
+			req.session.templateData = req.query
+		next()

+ 10 - 0
services/web/app/coffee/Features/Templates/TemplatesRouter.coffee

@@ -0,0 +1,10 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+TemplatesController = require("./TemplatesController")
+TemplatesMiddlewear = require('./TemplatesMiddlewear')
+
+module.exports = 
+	apply: (app)->
+
+		app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template
+
+		app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template

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

@@ -1,6 +1,6 @@
 UserHandler = require("./UserHandler")
 UserHandler = require("./UserHandler")
 UserDeleter = require("./UserDeleter")
 UserDeleter = require("./UserDeleter")
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 User = require("../../models/User").User
 User = require("../../models/User").User
 newsLetterManager = require('../Newsletter/NewsletterManager')
 newsLetterManager = require('../Newsletter/NewsletterManager')
 UserRegistrationHandler = require("./UserRegistrationHandler")
 UserRegistrationHandler = require("./UserRegistrationHandler")
@@ -45,7 +45,7 @@ module.exports = UserController =
 
 
 	unsubscribe: (req, res)->
 	unsubscribe: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 				res.send()
 
 

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

@@ -1,19 +1,10 @@
 User = require("../../models/User").User
 User = require("../../models/User").User
-UserLocator = require("./UserLocator")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 metrics = require('metrics-sharelatex')
 metrics = require('metrics-sharelatex')
 
 
 
 
 module.exports = UserCreator =
 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)->
 	createNewUser: (opts, callback)->
 		logger.log opts:opts, "creating new user"
 		logger.log opts:opts, "creating new user"
 		user = new User()
 		user = new User()

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

@@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
 
 
 module.exports = UserGetter =
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
 	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
 		if arguments.length == 2
 			callback = projection
 			callback = projection
 			projection = {}
 			projection = {}
@@ -19,6 +21,31 @@ module.exports = UserGetter =
 
 
 		db.users.findOne query, projection, callback
 		db.users.findOne query, projection, callback
 
 
+	getUserEmail: (userId, callback = (error, email) ->) ->
+		@getUser userId, { email: 1 }, (error, user) ->
+			callback(error, user?.email)
+
+	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
+		email = email.trim()
+		if arguments.length == 2
+			callback = projection
+			projection = {}
+		db.users.findOne email: email, projection, callback
+
+	getUserByAnyEmail: (email, projection, callback = (error, user) ->) ->
+		email = email.trim()
+		if arguments.length == 2
+			callback = projection
+			projection = {}
+		# $exists: true MUST be set to use the partial index
+		query = emails: { $exists: true }, 'emails.email': email
+		db.users.findOne query, projection, (error, user) =>
+			return callback(error, user) if error? or user?
+
+			# While multiple emails are being rolled out, check for the main email as
+			# well
+			@getUserByMainEmail email, projection, callback
+
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 		try
 		try
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
@@ -39,6 +66,9 @@ module.exports = UserGetter =
 
 
 [
 [
 	'getUser',
 	'getUser',
+	'getUserEmail',
+	'getUserByMainEmail',
+	'getUserByAnyEmail',
 	'getUsers',
 	'getUsers',
 	'getUserOrUserStubById'
 	'getUserOrUserStubById'
 ].map (method) ->
 ].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")
 UserGetter = require("./UserGetter")
 UserSessionsManager = require("./UserSessionsManager")
 UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
 ErrorController = require("../Errors/ErrorController")
@@ -61,7 +60,7 @@ module.exports =
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user: user_id, "loading settings page"
 		logger.log user: user_id, "loading settings page"
 		shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
 		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?
 			return next(err) if err?
 			res.render 'user/settings',
 			res.render 'user/settings',
 				title:'account_settings'
 				title:'account_settings'

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

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

+ 81 - 14
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -2,8 +2,9 @@ logger = require("logger-sharelatex")
 mongojs = require("../../infrastructure/mongojs")
 mongojs = require("../../infrastructure/mongojs")
 metrics = require("metrics-sharelatex")
 metrics = require("metrics-sharelatex")
 db = mongojs.db
 db = mongojs.db
+async = require("async")
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 
 
 module.exports = UserUpdater =
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
 	updateUser: (query, update, callback = (error) ->) ->
@@ -11,23 +12,89 @@ module.exports = UserUpdater =
 			query = _id: ObjectId(query)
 			query = _id: ObjectId(query)
 		else if query instanceof ObjectId
 		else if query instanceof ObjectId
 			query = _id: query
 			query = _id: query
+		else if typeof query._id == "string"
+			query._id = ObjectId(query._id)
 
 
 		db.users.update query, update, callback
 		db.users.update query, update, callback
 
 
 
 
-	changeEmailAddress: (user_id, newEmail, callback)->
-		self = @
-		logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
-		UserLocator.findByEmail newEmail, (error, user) ->
-			if user?
-				return callback({message:"alread_exists"})
-			self.updateUser user_id.toString(), {
-				$set: { "email": newEmail},
-			}, (err) ->
-				if err?
-					logger.err err:err, "problem updating users email"
-					return callback(err)
+	#
+	# DEPRECATED
+	#
+	# Change the user's main email address by adding a new email, switching the
+	# default email and removing the old email.  Prefer manipulating multiple
+	# emails and the default rather than calling this method directly
+	#
+	changeEmailAddress: (userId, newEmail, callback)->
+		logger.log userId: userId, newEmail: newEmail, "updaing email address of user"
+
+		oldEmail = null
+		async.series [
+			(cb) ->
+				UserGetter.getUserEmail userId, (error, email) ->
+					oldEmail = email
+					cb(error)
+			(cb) -> UserUpdater.addEmailAddress userId, newEmail, cb
+			(cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb
+			(cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb
+		], callback
+
+
+	# Add a new email address for the user. Email cannot be already used by this
+	# or any other user
+	addEmailAddress: (userId, newEmail, callback) ->
+		@_ensureUniqueEmailAddress newEmail, (error) =>
+			return callback(error) if error?
+
+			update = $push: emails: email: newEmail, createdAt: new Date()
+			@updateUser userId, update, (error) ->
+				if error?
+					logger.err error: error, 'problem updating users emails'
+					return callback(error)
 				callback()
 				callback()
 
 
 
 
-metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger
+	# remove one of the user's email addresses. The email cannot be the user's
+	# default email address
+	removeEmailAddress: (userId, email, callback) ->
+		query = _id: userId, email: $ne: email
+		update = $pull: emails: email: email
+		@updateUser query, update, (error, res) ->
+			if error?
+				logger.err error:error, 'problem removing users email'
+				return callback(error)
+			if res.nMatched == 0
+				return callback(new Error('Cannot remove default email'))
+			callback()
+
+
+	# set the default email address by setting the `email` attribute. The email
+	# must be one of the user's multiple emails (`emails` attribute)
+	setDefaultEmailAddress: (userId, email, callback) ->
+		query = _id: userId, 'emails.email': email
+		update = $set: email: email
+		@updateUser query, update, (error, res) ->
+			if error?
+				logger.err error:error, 'problem setting default emails'
+				return callback(error)
+			if res.nMatched == 0
+				return callback(new Error('Default email does not belong to user'))
+			callback()
+
+
+	# check for duplicate email address. This is also enforced at the DB level
+	_ensureUniqueEmailAddress: (newEmail, callback) ->
+		UserGetter.getUserByAnyEmail newEmail, (error, user) ->
+			return callback(message: 'alread_exists') if user?
+			callback()
+
+
+[
+	'updateUser'
+	'changeEmailAddress'
+	'setDefaultEmailAddress'
+	'addEmailAddress'
+	'removeEmailAddress'
+	'_ensureUniqueEmailAddress'
+].map (method) ->
+	metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)

+ 20 - 5
services/web/app/coffee/infrastructure/FileWriter.coffee

@@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex'
 request = require 'request'
 request = require 'request'
 
 
 module.exports = FileWriter =
 module.exports = FileWriter =
+
+	_ensureDumpFolderExists: (callback=(error)->) ->
+		fs.mkdir Settings.path.dumpFolder, (error) ->
+			if error? and error.code != 'EEXIST'
+				# Ignore error about already existing
+				return callback(error)
+			callback(null)
+
+	writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+		callback = _.once(callback)
+		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
+		FileWriter._ensureDumpFolderExists (error) ->
+			return callback(error) if error?
+			fs.writeFile fsPath, lines.join('\n'), (error) ->
+				return callback(error) if error?
+				callback(null, fsPath)
+
 	writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
 	writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		callback = _.once(callback)
 		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
 		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
 
 
 		stream.pause()
 		stream.pause()
-		fs.mkdir Settings.path.dumpFolder, (error) ->
+		FileWriter._ensureDumpFolderExists (error) ->
+			return callback(error) if error?
 			stream.resume()
 			stream.resume()
-			if error? and error.code != 'EEXIST'
-				# Ignore error about already existing
-				return callback(error)
 
 
 			writeStream = fs.createWriteStream(fsPath)
 			writeStream = fs.createWriteStream(fsPath)
 			stream.pipe(writeStream)
 			stream.pipe(writeStream)
@@ -39,4 +54,4 @@ module.exports = FileWriter =
 			else
 			else
 				err = new Error("bad response from url: #{response.statusCode}")
 				err = new Error("bad response from url: #{response.statusCode}")
 				logger.err {err, identifier, url}, err.message
 				logger.err {err, identifier, url}, err.message
-				callback(err)
+				callback(err)

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

@@ -30,7 +30,8 @@ module.exports = Modules =
 		for module in @modules
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
 				@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) ->
 	moduleIncludes: (view, locals) ->
 		compiledPartials = Modules.viewIncludes[view] or []
 		compiledPartials = Modules.viewIncludes[view] or []

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

@@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController')
 TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
 TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
 Features = require('./infrastructure/Features')
 Features = require('./infrastructure/Features')
 LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
 LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
+TemplatesRouter = require './Features/Templates/TemplatesRouter'
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -80,10 +81,10 @@ module.exports = class Router
 		ContactRouter.apply(webRouter, privateApiRouter)
 		ContactRouter.apply(webRouter, privateApiRouter)
 		AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
+		TemplatesRouter.apply(webRouter)
 
 
 		Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
 		Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
 
 
-
 		if Settings.enableSubscriptions
 		if Settings.enableSubscriptions
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 
 
@@ -119,6 +120,11 @@ module.exports = class Router
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		privateApiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
 		privateApiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
 
 
+		webRouter.get  '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson
+		webRouter.get  '/project/:Project_id/entities', AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanReadProject,
+			ProjectController.projectEntitiesJson
+
 		webRouter.get  '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
 		webRouter.get  '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
 		webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject
 		webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject
 
 
@@ -201,7 +207,8 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
 		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.get  "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
 		webRouter.get  "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
 		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory

+ 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'>" + translate("knowledge_base") + "</a>" })} 
-					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")}

+ 1 - 1
services/web/app/views/layout.pug

@@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js', {hashedPath:true})
 				src=buildJsPath('libs/require.js', {hashedPath:true})
 			)
 			)
 
 
-		include contact-us-modal
+		!= moduleIncludes("contactModal", locals)
 		include v1-tooltip
 		include v1-tooltip
 		include sentry
 		include sentry
 		
 		

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

@@ -57,9 +57,12 @@ block content
 				include ./editor/share
 				include ./editor/share
 				!= moduleIncludes("publish:body", locals)
 				!= moduleIncludes("publish:body", locals)
 
 
+				include ./editor/history/toolbarV2.pug
+
 				main#ide-body(
 				main#ide-body(
 					ng-cloak,
 					ng-cloak,
 					role="main",
 					role="main",
+					ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
 					layout="main",
 					layout="main",
 					ng-hide="state.loading",
 					ng-hide="state.loading",
 					resize-on="layout:chat:resize",
 					resize-on="layout:chat:resize",
@@ -70,7 +73,7 @@ block content
 				)
 				)
 					.ui-layout-west
 					.ui-layout-west
 						include ./editor/file-tree
 						include ./editor/file-tree
-						include ./editor/history-file-tree
+						include ./editor/history/fileTreeV2
 
 
 					.ui-layout-center
 					.ui-layout-center
 						include ./editor/editor
 						include ./editor/editor

+ 16 - 1
services/web/app/views/project/editor/binary-file.pug

@@ -47,7 +47,18 @@ div.binary-file.full-size(
 				|
 				|
 				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
 				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
 
 
-		span(ng-if="openFile.linkedFileData.provider == 'url'")
+		div(ng-if="openFile.linkedFileData.provider == 'project_file'")
+			p
+				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+				| Imported from
+				|
+				a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+					| {{ openFile.linkedFileData.source_project_display_name }}
+				| /{{ openFile.linkedFileData.source_entity_path.slice(1) }},
+				|
+				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+		span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'")
 			button.btn.btn-success(
 			button.btn.btn-success(
 				href, ng-click="refreshFile(openFile)",
 				href, ng-click="refreshFile(openFile)",
 				ng-disabled="refreshing"
 				ng-disabled="refreshing"
@@ -63,3 +74,7 @@ div.binary-file.full-size(
 			i.fa.fa-fw.fa-download
 			i.fa.fa-fw.fa-download
 			|
 			|
 			| #{translate("download")}
 			| #{translate("download")}
+		div(ng-if="refreshError")
+			br
+			.alert.alert-danger.col-md-6.col-md-offset-3
+				| Error: {{ refreshError}}

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

@@ -33,11 +33,11 @@ div.full-size(
 				i.fa.fa-arrow-left
 				i.fa.fa-arrow-left
 				| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
 				| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
 
 
-		!= moduleIncludes('editor:toolbar', locals)
+		!= moduleIncludes('editor:main', locals)
 
 
 		#editor(
 		#editor(
 			ace-editor="editor",
 			ace-editor="editor",
-			ng-if="!editor.richText",
+			ng-if="!editor.showRichText",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
 			style=showRichText ? "top: 32px" : "",
 			style=showRichText ? "top: 32px" : "",
 			theme="settings.theme",
 			theme="settings.theme",
@@ -73,8 +73,6 @@ div.full-size(
 			line-height="settings.lineHeight || ui.defaultLineHeight"
 			line-height="settings.lineHeight || ui.defaultLineHeight"
 		)
 		)
 
 
-		!= moduleIncludes('editor:body', locals)
-
 		include ./review-panel
 		include ./review-panel
 
 
 	.ui-layout-east
 	.ui-layout-east

+ 71 - 0
services/web/app/views/project/editor/file-tree.pug

@@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate')
 			span(ng-show="state.inflight") #{translate("creating")}...
 			span(ng-show="state.inflight") #{translate("creating")}...
 
 
 
 
+// Project Linked Files Modal
+script(type='text/ng-template', id='projectLinkedFileModalTemplate')
+	.modal-header
+		h3 New file from Project
+
+	.modal-body
+		div
+			div.alert.alert-danger(ng-if="state.error") Error, something went wrong!
+		div
+			form
+				.form-controls
+					label(for="project-select") Select a Project
+					span(ng-show="state.inFlight.projects")
+						| &nbsp;
+						i.fa.fa-spinner.fa-spin
+					select.form-control(
+						name="project-select"
+						ng-model="data.selectedProjectId"
+						ng-disabled="!shouldEnableProjectSelect()"
+					)
+						option(value="" disabled selected) - Please Select a Project
+						option(
+							ng-repeat="project in data.projects"
+							value="{{ project._id }}"
+						) {{ project.name }}
+
+				br
+				.form-controls
+					label(for="project-entity-select") Select a File
+					span(ng-show="state.inFlight.entities")
+						| &nbsp;
+						i.fa.fa-spinner.fa-spin
+					select.form-control(
+						name="project-entity-select"
+						ng-model="data.selectedProjectEntity"
+						ng-disabled="!shouldEnableProjectEntitySelect()"
+					)
+						option(value="" disabled selected) - Please Select a File
+						option(
+							ng-repeat="projectEntity in data.projectEntities"
+							value="{{ projectEntity.path }}"
+						) {{ projectEntity.path.slice(1) }}
+				br
+
+				.form-controls
+					label(for="name") File Name In This Project
+					input.form-control(
+						type="text"
+						placeholder="example.tex"
+						required
+						ng-model="data.name"
+						name="name"
+					)
+				br
+
+	.modal-footer
+		span(ng-show="state.inFlight.create")
+			i.fa.fa-spinner.fa-spin
+			| &nbsp;
+		button.btn.btn-default(
+			ng-disabled="state.inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-primary(
+			ng-disabled="!shouldEnableCreateButton()"
+			ng-click="create()"
+		)
+			span(ng-hide="state.inflight") #{translate("create")}
+			span(ng-show="state.inflight") #{translate("creating")}...
+
+
 script(type='text/ng-template', id='linkedFileModalTemplate')
 script(type='text/ng-template', id='linkedFileModalTemplate')
 	.modal-header
 	.modal-header
 		h3 New file from URL
 		h3 New file from URL

+ 0 - 17
services/web/app/views/project/editor/history-file-tree.pug

@@ -1,17 +0,0 @@
-aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view == 'history' && history.isV2").full-size
-	.toolbar.toolbar-filetree
-		span Modified files
-
-	.file-tree-inner
-		ul.list-unstyled.file-tree-list
-			li(
-				ng-repeat="(pathname, doc) in history.selection.docs"
-				ng-class="{ 'selected': history.selection.pathname == pathname }"
-			)
-				.entity
-					.entity-name.entity-name-history(
-						ng-click="history.selection.pathname = pathname",
-						ng-class="{ 'deleted': !!doc.deletedAtV }"
-					)
-						i.fa.fa-fw.fa-pencil
-						span  {{ pathname }}

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

@@ -40,90 +40,11 @@ div#history(ng-show="ui.view == 'history'")
 				p
 				p
 					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
 					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
 
 
-	aside.change-list(
-		ng-controller="HistoryListController"
-		infinite-scroll="loadMore()"
-		infinite-scroll-disabled="history.loading || history.atEnd"
-		infinite-scroll-initialize="ui.view == 'history'"
-	)
-		.infinite-scroll-inner
-			ul.list-unstyled(
-				ng-class="{\
-					'hover-state': history.hoveringOverListSelectors\
-				}"
-			)
-				li.change(
-					ng-repeat="update in history.updates"
-					ng-class="{\
-						'first-in-day': update.meta.first_in_day,\
-						'selected': update.inSelection,\
-						'selected-to': update.selectedTo,\
-						'selected-from': update.selectedFrom,\
-						'hover-selected': update.inHoverSelection,\
-						'hover-selected-to': update.hoverSelectedTo,\
-						'hover-selected-from': update.hoverSelectedFrom,\
-					}"
-					ng-controller="HistoryListItemController"
-				)
-
-					div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
-
-					div.selectors
-						div.range
-						form
-							input.selector-from(
-								type="radio"
-								name="fromVersion"
-								ng-model="update.selectedFrom"
-								ng-value="true"
-								ng-mouseover="mouseOverSelectedFrom()"
-								ng-mouseout="mouseOutSelectedFrom()"
-								ng-show="update.afterSelection || update.inSelection"
-							)
-						form
-							input.selector-to(
-								type="radio"
-								name="toVersion"
-								ng-model="update.selectedTo"
-								ng-value="true"
-								ng-mouseover="mouseOverSelectedTo()"
-								ng-mouseout="mouseOutSelectedTo()"
-								ng-show="update.beforeSelection || update.inSelection"
-							)
-
-					div.description(ng-click="select()")
-						div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
-						div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
-							| Edited
-						div.docs(ng-repeat="pathname in update.pathnames")
-							.doc {{ pathname }}
-						div.docs(ng-repeat="project_op in update.project_ops")
-							div(ng-if="project_op.rename")
-								.action Renamed
-								.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
-							div(ng-if="project_op.add")
-								.action Created
-								.doc {{ project_op.add.pathname }}
-							div(ng-if="project_op.remove")
-								.action Deleted
-								.doc {{ project_op.remove.pathname }}
-						div.users
-							div.user(ng-repeat="update_user in update.meta.users")
-								.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
-								.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
-								.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") 
-								.name(ng-if="update_user && update_user.id == user.id") You
-								.name(ng-if="update_user == null") #{translate("anonymous")}
-							div.user(ng-if="update.meta.users.length == 0")
-								.color-square(style="background-color: hsl(100, 100%, 50%)")
-								span #{translate("anonymous")}
-
-			.loading(ng-show="history.loading")
-				i.fa.fa-spin.fa-refresh
-				| &nbsp;&nbsp; #{translate("loading")}...
+	include ./history/entriesListV1
+	include ./history/entriesListV2
 
 
 	include ./history/diffPanelV1
 	include ./history/diffPanelV1
-	include ./history/diffPanelV2
+	include ./history/previewPanelV2
 
 
 script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header
 	.modal-header

+ 2 - 2
services/web/app/views/project/editor/history/diffPanelV1.pug

@@ -13,8 +13,8 @@
 					}"
 					}"
 				)
 				)
 				|  in <strong>{{history.diff.pathname}}</strong>
 				|  in <strong>{{history.diff.pathname}}</strong>
-			.toolbar-right
-				a.btn.btn-danger.btn-sm(
+			.toolbar-right(ng-if="permissions.write")
+				a.btn.btn-danger.btn-xs(
 					href,
 					href,
 					ng-click="openRestoreDiffModal()"
 					ng-click="openRestoreDiffModal()"
 				) #{translate("restore_to_before_these_changes")}
 				) #{translate("restore_to_before_these_changes")}

+ 82 - 0
services/web/app/views/project/editor/history/entriesListV1.pug

@@ -0,0 +1,82 @@
+aside.change-list(
+		ng-if="!history.isV2"
+		ng-controller="HistoryListController"
+		infinite-scroll="loadMore()"
+		infinite-scroll-disabled="history.loading || history.atEnd"
+		infinite-scroll-initialize="ui.view == 'history'"
+	)
+		.infinite-scroll-inner
+			ul.list-unstyled(
+				ng-class="{\
+					'hover-state': history.hoveringOverListSelectors\
+				}"
+			)
+				li.change(
+					ng-repeat="update in history.updates"
+					ng-class="{\
+						'first-in-day': update.meta.first_in_day,\
+						'selected': update.inSelection,\
+						'selected-to': update.selectedTo,\
+						'selected-from': update.selectedFrom,\
+						'hover-selected': update.inHoverSelection,\
+						'hover-selected-to': update.hoverSelectedTo,\
+						'hover-selected-from': update.hoverSelectedFrom,\
+					}"
+					ng-controller="HistoryListItemController"
+				)
+
+					div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
+
+					div.selectors
+						div.range
+						form
+							input.selector-from(
+								type="radio"
+								name="fromVersion"
+								ng-model="update.selectedFrom"
+								ng-value="true"
+								ng-mouseover="mouseOverSelectedFrom()"
+								ng-mouseout="mouseOutSelectedFrom()"
+								ng-show="update.afterSelection || update.inSelection"
+							)
+						form
+							input.selector-to(
+								type="radio"
+								name="toVersion"
+								ng-model="update.selectedTo"
+								ng-value="true"
+								ng-mouseover="mouseOverSelectedTo()"
+								ng-mouseout="mouseOutSelectedTo()"
+								ng-show="update.beforeSelection || update.inSelection"
+							)
+
+					div.description(ng-click="select()")
+						div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
+						div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
+							| #{translate("file_action_edited")}
+						div.docs(ng-repeat="pathname in update.pathnames")
+							.doc {{ pathname }}
+						div.docs(ng-repeat="project_op in update.project_ops")
+							div(ng-if="project_op.rename")
+								.action #{translate("file_action_renamed")}
+								.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
+							div(ng-if="project_op.add")
+								.action #{translate("file_action_created")} 
+								.doc {{ project_op.add.pathname }}
+							div(ng-if="project_op.remove")
+								.action #{translate("file_action_deleted")}
+								.doc {{ project_op.remove.pathname }}
+						div.users
+							div.user(ng-repeat="update_user in update.meta.users")
+								.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
+								.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
+								.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") 
+								.name(ng-if="update_user && update_user.id == user.id") You
+								.name(ng-if="update_user == null") #{translate("anonymous")}
+							div.user(ng-if="update.meta.users.length == 0")
+								.color-square(style="background-color: hsl(100, 100%, 50%)")
+								span #{translate("anonymous")}
+
+			.loading(ng-show="history.loading")
+				i.fa.fa-spin.fa-refresh
+				| &nbsp;&nbsp; #{translate("loading")}...

+ 174 - 0
services/web/app/views/project/editor/history/entriesListV2.pug

@@ -0,0 +1,174 @@
+aside.change-list(
+	ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+	ng-controller="HistoryV2ListController"
+)
+	history-entries-list(
+		entries="history.updates"
+		current-user="user"
+		load-entries="loadMore()"
+		load-disabled="history.loading || history.atEnd"
+		load-initialize="ui.view == 'history'"
+		is-loading="history.loading"
+		on-entry-select="handleEntrySelect(selectedEntry)"
+	)
+
+aside.change-list(
+	ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
+	ng-controller="HistoryListController"
+	infinite-scroll="loadMore()"
+	infinite-scroll-disabled="history.loading || history.atEnd"
+	infinite-scroll-initialize="ui.view == 'history'"
+)
+	.infinite-scroll-inner
+		ul.list-unstyled(
+			ng-class="{\
+				'hover-state': history.hoveringOverListSelectors\
+			}"
+		)
+			li.change(
+				ng-repeat="update in history.updates"
+				ng-class="{\
+					'first-in-day': update.meta.first_in_day,\
+					'selected': update.inSelection,\
+					'selected-to': update.selectedTo,\
+					'selected-from': update.selectedFrom,\
+					'hover-selected': update.inHoverSelection,\
+					'hover-selected-to': update.hoverSelectedTo,\
+					'hover-selected-from': update.hoverSelectedFrom,\
+				}"
+				ng-controller="HistoryListItemController"
+			)
+
+				div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
+
+				div.selectors
+					div.range
+					form
+						input.selector-from(
+							type="radio"
+							name="fromVersion"
+							ng-model="update.selectedFrom"
+							ng-value="true"
+							ng-mouseover="mouseOverSelectedFrom()"
+							ng-mouseout="mouseOutSelectedFrom()"
+							ng-show="update.afterSelection || update.inSelection"
+						)
+					form
+						input.selector-to(
+							type="radio"
+							name="toVersion"
+							ng-model="update.selectedTo"
+							ng-value="true"
+							ng-mouseover="mouseOverSelectedTo()"
+							ng-mouseout="mouseOutSelectedTo()"
+							ng-show="update.beforeSelection || update.inSelection"
+						)
+
+				div.description(ng-click="select()")
+					div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
+					div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
+						| #{translate("file_action_edited")}
+					div.docs(ng-repeat="pathname in update.pathnames")
+						.doc {{ pathname }}
+					div.docs(ng-repeat="project_op in update.project_ops")
+						div(ng-if="project_op.rename")
+							.action #{translate("file_action_renamed")} 
+							.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
+						div(ng-if="project_op.add")
+							.action #{translate("file_action_created")}
+							.doc {{ project_op.add.pathname }}
+						div(ng-if="project_op.remove")
+							.action #{translate("file_action_deleted")}
+							.doc {{ project_op.remove.pathname }}
+					div.users
+						div.user(ng-repeat="update_user in update.meta.users")
+							.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
+							.color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
+							.name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") 
+							.name(ng-if="update_user && update_user.id == user.id") You
+							.name(ng-if="update_user == null") #{translate("anonymous")}
+						div.user(ng-if="update.meta.users.length == 0")
+							.color-square(style="background-color: hsl(100, 100%, 50%)")
+							span #{translate("anonymous")}
+
+		.loading(ng-show="history.loading")
+			i.fa.fa-spin.fa-refresh
+			| &nbsp;&nbsp; #{translate("loading")}...
+			
+script(type="text/ng-template", id="historyEntriesListTpl")
+	.history-entries(
+		infinite-scroll="$ctrl.loadEntries()"
+		infinite-scroll-disabled="$ctrl.loadDisabled"
+		infinite-scroll-initialize="$ctrl.loadInitialize"
+	)
+		.infinite-scroll-inner
+			history-entry(
+				ng-repeat="entry in $ctrl.entries"
+				entry="entry"
+				current-user="$ctrl.currentUser"
+				on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
+				ng-show="!$ctrl.isLoading"
+			)
+			.loading(ng-show="$ctrl.isLoading")
+				i.fa.fa-spin.fa-refresh
+				| &nbsp;&nbsp; #{translate("loading")}...
+
+script(type="text/ng-template", id="historyEntryTpl")
+	.history-entry(
+		ng-class="{\
+			'history-entry-first-in-day': $ctrl.entry.meta.first_in_day,\
+			'history-entry-selected': $ctrl.entry.inSelection,\
+			'history-entry-selected-to': $ctrl.entry.selectedTo,\
+			'history-entry-selected-from': $ctrl.entry.selectedFrom,\
+			'history-entry-hover-selected': $ctrl.entry.inHoverSelection,\
+			'history-entry-hover-selected-to': $ctrl.entry.hoverSelectedTo,\
+			'history-entry-hover-selected-from': $ctrl.entry.hoverSelectedFrom,\
+		}"
+	)
+
+		time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }}
+
+		.history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })")
+			ol.history-entry-changes
+				li.history-entry-change(
+					ng-repeat="pathname in ::$ctrl.entry.pathnames"
+				)
+					span.history-entry-change-action #{translate("file_action_edited")} 
+					span.history-entry-change-doc {{ ::pathname }}
+				li.history-entry-change(
+					ng-repeat="project_op in ::$ctrl.entry.project_ops"
+				)
+					span.history-entry-change-action(
+						ng-if="::project_op.rename"
+					) #{translate("file_action_renamed")}
+					span.history-entry-change-action(
+						ng-if="::project_op.add"
+					) #{translate("file_action_created")}
+					span.history-entry-change-action(
+						ng-if="::project_op.remove"
+					) #{translate("file_action_deleted")}
+					span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
+			.history-entry-metadata
+				time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
+				span
+					|
+					| &bull;
+					|
+				ol.history-entry-metadata-users
+					li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users")
+						span.name(
+							ng-if="::update_user && update_user.id != $ctrl.currentUser.id"
+							ng-style="$ctrl.getUserCSSStyle(update_user);"
+						) {{ ::$ctrl.displayName(update_user) }} 
+						span.name(
+							ng-if="::update_user && update_user.id == $ctrl.currentUser.id"
+							ng-style="$ctrl.getUserCSSStyle(update_user);"
+						) You
+						span.name(
+							ng-if="::update_user == null"
+							ng-style="$ctrl.getUserCSSStyle(update_user);"
+						) #{translate("anonymous")}
+					li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
+						span.name(
+							ng-style="$ctrl.getUserCSSStyle();"
+						) #{translate("anonymous")}

+ 70 - 0
services/web/app/views/project/editor/history/fileTreeV2.pug

@@ -0,0 +1,70 @@
+aside.file-tree.full-size(
+	ng-controller="HistoryV2FileTreeController"
+	ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+	.history-file-tree-inner
+		history-file-tree(
+			file-tree="currentFileTree"
+			selected-pathname="history.selection.pathname"
+			on-selected-file-change="handleFileSelection(file)"
+			is-loading="history.loadingFileTree"
+		)
+
+aside.file-tree.file-tree-history.full-size(
+	ng-controller="FileTreeController"
+	ng-class="{ 'multi-selected': multiSelectedCount > 0 }"
+	ng-show="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.COMPARE")
+	.toolbar.toolbar-filetree
+		span Modified files
+
+	.file-tree-inner
+		ul.list-unstyled.file-tree-list
+			li(
+				ng-repeat="(pathname, doc) in history.selection.docs"
+				ng-class="{ 'selected': history.selection.pathname == pathname }"
+			)
+				.entity
+					.entity-name.entity-name-history(
+						ng-click="history.selection.pathname = pathname",
+						ng-class="{ 'deleted': !!doc.deletedAtV }"
+					)
+						i.fa.fa-fw.fa-pencil
+						span  {{ pathname }}
+
+
+
+
+script(type="text/ng-template", id="historyFileTreeTpl")
+	.history-file-tree
+		history-file-entity(
+			ng-repeat="fileEntity in $ctrl.fileTree"
+			file-entity="fileEntity"
+			ng-show="!$ctrl.isLoading"
+		)
+
+
+script(type="text/ng-template", id="historyFileEntityTpl")
+	.history-file-entity-wrapper
+		a.history-file-entity-link(
+			href
+			ng-click="$ctrl.handleClick()"
+			ng-class="{ 'history-file-entity-link-selected': $ctrl.isSelected }"
+		)
+			span.history-file-entity-name
+				i.history-file-entity-icon.history-file-entity-icon-folder-state.fa.fa-fw(
+					ng-class="{\
+						'fa-chevron-down': ($ctrl.fileEntity.type === 'folder' && $ctrl.isOpen),\
+						'fa-chevron-right': ($ctrl.fileEntity.type === 'folder' && !$ctrl.isOpen)\
+					}"
+				)
+				i.history-file-entity-icon.fa(
+					ng-class="$ctrl.iconClass"
+				)
+				| {{ ::$ctrl.fileEntity.name }}
+		div(
+			ng-show="$ctrl.isOpen"
+		)
+			history-file-entity(
+				ng-repeat="childEntity in $ctrl.fileEntity.children"
+				file-entity="childEntity"
+			)

+ 34 - 3
services/web/app/views/project/editor/history/diffPanelV2.pug → services/web/app/views/project/editor/history/previewPanelV2.pug

@@ -1,4 +1,7 @@
-.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController")
+.diff-panel.full-size(
+	ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
+	ng-controller="HistoryV2DiffController"
+)
 	.diff(
 	.diff(
 		ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
 		ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
 		ng-class="{ 'diff-binary': history.diff.binary }"
 		ng-class="{ 'diff-binary': history.diff.binary }"
@@ -16,8 +19,13 @@
 					}"
 					}"
 				)
 				)
 				|  in <strong>{{history.diff.pathname}}</strong>
 				|  in <strong>{{history.diff.pathname}}</strong>
+			.history-toolbar-btn(
+				ng-click="toggleHistoryViewMode();"
+			) 
+				i.fa
+				| #{translate("view_single_version")}
 			.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
 			.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
-				button.btn.btn-danger.btn-sm(
+				button.btn.btn-danger.btn-xs(
 					ng-click="restoreDeletedFile()"
 					ng-click="restoreDeletedFile()"
 					ng-show="!restoreState.error"
 					ng-show="!restoreState.error"
 					ng-disabled="restoreState.inflight"
 					ng-disabled="restoreState.inflight"
@@ -47,4 +55,27 @@
 		i.fa.fa-spin.fa-refresh
 		i.fa.fa-spin.fa-refresh
 		| &nbsp;&nbsp;#{translate("loading")}...
 		| &nbsp;&nbsp;#{translate("loading")}...
 	.error-panel(ng-show="history.diff.error")
 	.error-panel(ng-show="history.diff.error")
-		.alert.alert-danger #{translate("generic_something_went_wrong")}
+		.alert.alert-danger #{translate("generic_something_went_wrong")}
+
+.point-in-time-panel.full-size(
+	ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+	.point-in-time-editor-container(
+		ng-if="!!history.selectedFile && !history.selectedFile.loading && !history.selectedFile.error"
+	)
+		.hide-ace-cursor(
+			ng-if="!history.selectedFile.binary"
+			ace-editor="history-pointintime",
+			theme="settings.theme",
+			font-size="settings.fontSize",
+			text="history.selectedFile.text",
+			read-only="true",
+			resize-on="layout:main:resize",
+		)
+		.alert.alert-info(ng-if="history.selectedFile.binary")
+			| We're still working on showing image and binary changes, sorry. Stay tuned!
+	.loading-panel(ng-show="history.selectedFile.loading")
+		i.fa.fa-spin.fa-refresh
+		| &nbsp;&nbsp;#{translate("loading")}...
+	.error-panel(ng-show="history.selectedFile.error")
+		.alert.alert-danger #{translate("generic_something_went_wrong")}

+ 13 - 0
services/web/app/views/project/editor/history/toolbarV2.pug

@@ -0,0 +1,13 @@
+.history-toolbar(
+	ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+	span(ng-show="history.loadingFileTree")
+		i.fa.fa-spin.fa-refresh
+		| &nbsp;&nbsp; #{translate("loading")}...
+	span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}&nbsp;
+		time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
+	.history-toolbar-btn(
+		ng-click="toggleHistoryViewMode();"
+	) 
+		i.fa
+		| #{translate("compare_to_another_version")}

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

@@ -62,6 +62,23 @@ aside#left-menu.full-size(
 			!= moduleIncludes("editorLeftMenu:editing_services", locals)
 			!= moduleIncludes("editorLeftMenu:editing_services", locals)
 
 
 
 
+	if showTestControls
+		h4 Test Controls
+		ul.list-unstyled.nav(ng-controller="TestControlsController")
+			li
+				a(href="#" ng-click="richText()")
+					i.fa.fa-exclamation.fa-fw
+					| Rich Text
+			li
+				a(href="#" ng-click="openProjectLinkedFileModal()")
+					i.fa.fa-exclamation.fa-fw
+					| Project-Linked-File Modal
+			li
+				a(href="#" ng-click="openLinkedFileModal()")
+					i.fa.fa-exclamation.fa-fw
+					| URL-Linked-File Modal
+
+
 	h4(ng-show="!anonymous") #{translate("settings")}
 	h4(ng-show="!anonymous") #{translate("settings")}
 	form.settings(ng-controller="SettingsController", ng-show="!anonymous")
 	form.settings(ng-controller="SettingsController", ng-show="!anonymous")
 		.containter-fluid
 		.containter-fluid
@@ -179,6 +196,7 @@ aside#left-menu.full-size(
 					option(value="pdfjs") #{translate("built_in")}
 					option(value="pdfjs") #{translate("built_in")}
 					option(value="native") #{translate("native")}
 					option(value="native") #{translate("native")}
 
 
+
 	h4 #{translate("hotkeys")}
 	h4 #{translate("hotkeys")}
 	ul.list-unstyled.nav
 	ul.list-unstyled.nav
 		li(ng-controller="HotkeysController")
 		li(ng-controller="HotkeysController")

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

@@ -0,0 +1,26 @@
+extends ../../layout
+
+block content
+  script.
+    $(document).ready(function(){
+      $('#create_form').submit();
+    });
+
+  .editor.full-size
+    .loading-screen()
+      .loading-screen-brand-container
+        .loading-screen-brand(
+          style="height: 20%;"
+        )
+
+      h3.loading-screen-label() #{translate("Opening template")}
+        span.loading-screen-ellip .
+        span.loading-screen-ellip .
+        span.loading-screen-ellip .
+
+  form(id='create_form' method='POST' action='/project/new/template/')
+    input(type="hidden", name="_csrf", value=csrfToken)
+    input(type="hidden" name="templateId" value=templateId)
+    input(type="hidden" name="templateVersionId" value=templateVersionId)
+    input(type="hidden" name="templateName" value=name)
+    input(type="hidden" name="compiler" value=compiler)

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

@@ -1,4 +1,7 @@
-.col-xs-6
+- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+div(class=titleClasses)
 	input.select-item(
 	input.select-item(
 		select-individual,
 		select-individual,
 		type="checkbox",
 		type="checkbox",
@@ -37,8 +40,50 @@
 			tooltip-placement="right"
 			tooltip-placement="right"
 			tooltip-append-to-body="true"
 			tooltip-append-to-body="true"
 		)
 		)
-.col-xs-4
+
+div(class=lastUpdatedClasses)
 	if settings.overleaf
 	if settings.overleaf
 		span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
 		span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
 	else
 	else
 		span.last-modified {{project.lastUpdated | formatDate}}
 		span.last-modified {{project.lastUpdated | formatDate}}
+
+if settings.overleaf
+	.hidden-xs.col-sm-3.col-md-2.action-btn-row
+		button.btn.btn-link.action-btn(
+			tooltip=translate('copy'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="clone($event)"
+		)
+			i.icon.fa.fa-files-o
+		button.btn.btn-link.action-btn(
+			tooltip=translate('download'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="download($event)"
+		)
+			i.icon.fa.fa-cloud-download
+		button.btn.btn-link.action-btn(
+			ng-if="!project.archived && isOwner()"
+			tooltip=translate('archive'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="archiveOrLeave($event)"
+		)
+			i.icon.fa.fa-inbox
+		button.btn.btn-link.action-btn(
+			ng-if="!project.archived && !isOwner()"
+			tooltip=translate('leave'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="archiveOrLeave($event)"
+		)
+			i.icon.fa.fa-sign-out
+		button.btn.btn-link.action-btn(
+			ng-if="project.archived"
+			tooltip=translate('unarchive'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="restore($event)"
+		)
+			i.icon.fa.fa-reply

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

@@ -131,7 +131,10 @@
 			)
 			)
 				li.container-fluid
 				li.container-fluid
 					.row
 					.row
-						.col-xs-6
+						- var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+						- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+						div(class=titleClasses)
 							input.select-all(
 							input.select-all(
 								select-all,
 								select-all,
 								type="checkbox"
 								type="checkbox"
@@ -142,9 +145,12 @@
 						.col-xs-2
 						.col-xs-2
 							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
 							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
 								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
 								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
-						.col-xs-4
+						div(class=lastUpdatedClasses)
 							span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
 							span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
 								i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
 								i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
+						if settings.overleaf
+							.hidden-xs.col-sm-3.col-md-2.action-btn-row-header
+								span.header #{translate("actions")}
 				li.project_entry.container-fluid(
 				li.project_entry.container-fluid(
 					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
 					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
 					ng-controller="ProjectListItemController"
 					ng-controller="ProjectListItemController"

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

@@ -1,4 +1,4 @@
-.col-xs-6
+.col-xs-6.col-sm-4.col-md-6
 	.select-item
 	.select-item
 		span.v1-badge(
 		span.v1-badge(
 			aria-label=translate("v1_badge")
 			aria-label=translate("v1_badge")
@@ -21,5 +21,5 @@
 .col-xs-2
 .col-xs-2
 	span.owner {{ownerName()}}
 	span.owner {{ownerName()}}
 
 
-.col-xs-4
+.col-xs-4.col-sm-3.col-md-2
 	span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
 	span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

+ 118 - 0
services/web/app/views/subscriptions/_plans_page_details_less.pug

@@ -0,0 +1,118 @@
+.row
+	.col-md-12
+		.page-header.centered.plans-header.text-centered
+			h1 #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
+.row
+	.col-md-8.col-md-offset-2
+		p.text-centered #{translate("sl_benefits_plans")}
+
+.row.top-switch
+	.col-md-6.col-md-offset-3
+		+plan_switch('card')
+	.col-md-2.text-right
+		+currency_dropdown
+
+div(ng-show="showPlans")
+	.row
+		.col-md-10.col-md-offset-1
+			.row
+				.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
+					.col-md-4
+						.card.card-first
+							.card-header
+								h2 #{translate("personal")}
+							.circle #{translate("free")}
+							+features_free
+					.col-md-4
+						.card.card-highlighted
+							.card-header
+								h2 #{translate("collaborator")}
+							.circle
+								+price_collaborator
+							+features_collaborator
+					.col-md-4
+						.card.card-last
+							.card-header
+								h2 #{translate("professional")}
+							.circle
+								+price_professional
+							+features_professional
+
+				.card-group.text-centered(ng-if="ui.view == 'student'")
+					.col-md-4
+						.card.card-first
+							.card-header
+								h2 #{translate("personal")}
+							.circle #{translate("free")}
+							+features_free
+
+					.col-md-4
+						.card.card-highlighted
+							+card_student_monthly
+
+					.col-md-4
+						.card.card-last
+							+card_student_annual
+
+.row.row-spaced
+	p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
+
+.row
+	.col-md-8.col-md-offset-2
+		.alert.alert-info.text-centered
+			| #{translate("interested_in_group_licence")}
+			br
+			a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
+
+	script(type="text/ng-template", id="groupPlanModalTemplate")
+		.modal-header
+			h3 #{translate("group_plan_enquiry")}
+		.modal-body
+			form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()")
+				span(ng-show="sent == false && error == false")
+					.form-group
+						label#title9(for='Field9')
+							| Name
+						input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
+					label#title11.desc(for='Field11')
+						| Email
+					.form-group
+						input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
+					label#title12.desc(for='Field12')
+						| University / Company
+					.form-group
+						input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
+					label#title13.desc(for='Field13')
+						| Position
+					.form-group
+						input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
+					.form-group
+						input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
+					.form-group.text-center
+						input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending",  value='Request a quote')
+				span(ng-show="sent == true && error == false")
+					p Request Sent, Thank you.
+				span(ng-show="error")
+					p Error sending request.
+
+.row
+	.col-md-12
+		.page-header.plans-header.plans-subheader.text-centered
+			h2 #{translate("enjoy_these_features")}
+	.col-md-4
+		.card.features.text-centered
+			i.fa.fa-file-text-o.fa-5x
+			h4 #{translate("unlimited_projects")}
+			p #{translate("create_unlimited_projects")}
+	.col-md-4
+		.card.features.text-centered
+			i.fa.fa-clock-o.fa-5x
+			h4 #{translate("full_doc_history")}
+			p #{translate("never_loose_work")}
+	.col-md-4
+		.card.features.text-centered
+			i.fa.fa-dropbox.fa-5x
+			|  &nbsp; &nbsp;
+			i.fa.fa-github.fa-5x
+			h4 #{translate("sync_to_dropbox_and_github")}
+			p #{translate("access_projects_anywhere")}

+ 160 - 0
services/web/app/views/subscriptions/_plans_page_details_more.pug

@@ -0,0 +1,160 @@
+.row
+	.col-md-12
+		.page-header.centered.plans-header.text-centered
+			h1.text-capitalize #{translate('instant_access')}
+.row
+	.col-md-8.col-md-offset-2
+		p.text-centered #{translate("sl_benefits_plans")}
+
+.row.top-switch
+	.col-md-6.col-md-offset-3
+		+plan_switch('card')
+	.col-md-2.text-right
+		+currency_dropdown
+
+div(ng-show="showPlans")
+	.row
+		.col-md-10.col-md-offset-1
+			.row
+				.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
+					.col-md-4
+						.card.card-first
+							.card-header
+								h2 #{translate("personal")}
+							h5.tagline #{translate("tagline_personal")}
+							.circle #{translate("free")}
+							+features_free
+					.col-md-4
+						.card.card-highlighted
+							.best-value
+								strong #{translate('best_value')}
+							.card-header
+								h2 #{translate("collaborator")}
+							h5.tagline #{translate("tagline_collaborator")}
+							.circle
+								+price_collaborator
+							+features_collaborator
+					.col-md-4
+						.card.card-last
+							.card-header
+								h2 #{translate("professional")}
+							h5.tagline #{translate("tagline_professional")}
+							.circle
+								+price_professional
+							+features_professional
+
+				.card-group.text-centered(ng-if="ui.view == 'student'")
+					.col-md-4
+						.card.card-first
+							.card-header
+								h2 #{translate("personal")}
+							h5.tagline #{translate("tagline_personal")}
+							.circle #{translate("free")}
+							+features_free
+
+					.col-md-4
+						.card.card-highlighted
+							+card_student_annual
+
+					.col-md-4
+						.card.card-last
+							+card_student_monthly
+
+.row.row-spaced-large.text-centered
+	i.fa.fa-cc-mastercard.fa-2x &nbsp;
+	i.fa.fa-cc-visa.fa-2x &nbsp;
+	i.fa.fa-cc-amex.fa-2x &nbsp;
+	i.fa.fa-cc-paypal.fa-2x &nbsp;
+	div.text-centered #{translate('change_plans_any_time')}<br/> #{translate('billed_after_x_days', {len:'{{trial_len}}'})}
+
+.row.row-spaced-large
+	.col-md-8.col-md-offset-2
+		.card.text-centered
+			.card-header
+				h2 #{translate('looking_multiple_licenses')}
+			span #{translate('reduce_costs_group_licenses')}
+			br
+			br
+			a.btn.btn-info(href="/i/university/groups") #{translate('find_out_more')}
+
+div
+	.row.row-spaced-large
+		.col-sm-12
+			.page-header.plans-header.plans-subheader.text-centered
+				h2 #{translate('compare_plan_features')}
+	.row
+		.col-md-6.col-md-offset-3
+			+plan_switch('table')
+		.col-md-3.text-right
+			+currency_dropdown
+	.row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll"  event-tracking-send-once="true")
+		.col-sm-12(ng-if="ui.view != 'student'")
+			+table_premium
+		.col-sm-12(ng-if="ui.view == 'student'")
+			+table_student
+
+	.row.row-spaced-large
+		.col-md-12
+			.page-header.plans-header.plans-subheader.text-centered
+				h2 #{translate('in_good_company')}
+	.row
+		.col-md-6
+			div
+				.row
+					.col-md-3
+						.circle-img
+							img(src=buildImgPath('advocates/erdogmus.jpg') alt="Professor Erdogmus")
+					.col-md-9
+						blockquote
+							p The ability to track changes and the real-time collaborative nature is what sets ShareLaTeX apart.
+							footer Professor Erdogmus, Northeastern University
+		.col-md-6
+			div
+				.row
+					.col-md-3
+						.circle-img
+							img(src=buildImgPath('advocates/henderson.jpg') alt="Rob Henderson")
+					.col-md-9
+						blockquote
+							p ShareLaTeX has proven to be a powerful and robust collaboration tool that is widely used in our School.
+							footer Rob Henderson, School Of Informatics And Computing - Indiana University
+
+	.faq
+		.row.row-spaced-large
+			.col-md-12
+				.page-header.plans-header.plans-subheader.text-centered
+					h2 FAQ
+		.row
+			.col-md-6
+				h3 #{translate("faq_how_free_trial_works_question")}
+				p #{translate('faq_how_free_trial_works_answer', { len:'{{trial_len}}' })}
+			.col-md-6
+				h3 #{translate('faq_change_plans_question')}
+				p #{translate('faq_change_plans_answer')}
+		.row
+			.col-md-6
+				h3 #{translate('faq_do_collab_need_premium_question')}
+				p #{translate('faq_do_collab_need_premium_answer')}
+			.col-md-6
+				h3 #{translate('faq_need_more_collab_question')}
+				p !{translate('faq_need_more_collab_answer', { referFriendsLink: '<a href="/user/bonus">' + translate('referring_your_friends') + '</a>'})}
+		.row
+			.col-md-6
+				h3 #{translate('faq_purchase_more_licenses_question')}
+				p !{translate('faq_purchase_more_licenses_answer', { groupLink: '<a href="/i/university/groups">' + translate('discounted_group_accounts') + '</a>' })}
+			.col-md-6
+				h3 #{translate('faq_monthly_or_annual_question')}
+				p #{translate('faq_monthly_or_annual_answer')}
+		.row
+			.col-md-6
+				h3 #{translate('faq_how_to_pay_question')}
+				p #{translate('faq_how_to_pay_answer')}
+			.col-md-6
+				h3 #{translate('faq_pay_by_invoice_question')}
+				p !{translate('faq_pay_by_invoice_answer', { groupLink: '<a href="/i/university/groups">' + translate('discounted_group_accounts') + '</a>' })}
+		.row.row-spaced-large.text-centery
+			.col-md-12
+				.plans-header.plans-subheader.text-centered
+					h2 #{translate('still_have_questions')}
+						button.btn.btn-info.btn-header.text-capitalize(ng-controller="ContactGeneralModal" ng-click="openModal()") #{translate('get_in_touch')}
+						!= moduleIncludes("contactModalGeneral", locals)

+ 162 - 0
services/web/app/views/subscriptions/_plans_page_mixins.pug

@@ -0,0 +1,162 @@
+//- Buy Buttons
+mixin btn_buy_collaborator(location)
+	a.btn.btn-info(
+		ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}",
+		ng-click="signUpNowClicked('collaborator','" + location + "')"
+	)
+		span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+		span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+mixin btn_buy_free(location)
+	a.btn.btn-info(
+		href="/register"
+		style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
+		ng-click="signUpNowClicked('free','" + location + "')"
+	)
+		span(ng-if="plansVariant !== 'more-details'") #{translate('sign_up_now')}
+		span.text-capitalize(ng-if="plansVariant === 'more-details'") #{translate('get_started_now')}
+mixin btn_buy_professional(location)
+	a.btn.btn-info(
+		ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}"
+		ng-click="signUpNowClicked('professional','" + location + "')"
+	)
+		span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+		span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+mixin btn_buy_student(location, plan)
+	if plan == 'annual'
+		a.btn.btn-info(
+			ng-href="/user/subscription/new?planCode=student-annual&currency={{currencyCode}}",
+			ng-click="signUpNowClicked('student-annual','" + location + "')"
+		) #{translate("buy_now")}
+	else
+		//- planQueryString will contain _free_trial_7_days
+		a.btn.btn-info(
+			ng-href="/user/subscription/new?planCode=student{{planQueryString}}&currency={{currencyCode}}",
+			ng-click="signUpNowClicked('student-monthly','" + location + "')"
+		) #{translate("start_free_trial")}
+
+//- Cards
+mixin card_student_annual
+	.best-value(ng-if="plansVariant == 'more-details'")
+		strong #{translate('best_value')}
+	.card-header
+		h2 #{translate("student")} (#{translate("annual")})
+	h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_annual')}
+	.circle
+		span
+			+price_student_annual
+	+features_student('card', 'annual')
+mixin card_student_monthly
+	.card-header
+		h2 #{translate("student")}
+	h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_monthly')}
+	.circle
+		span
+			+price_student_monthly
+	+features_student('card', 'monthly')
+
+//- Features Lists
+mixin features_collaborator
+	ul.list-unstyled
+		li
+			strong #{translate("collabs_per_proj", {collabcount:10})}
+		+features_premium
+		li
+			br
+			+btn_buy_collaborator('card')
+mixin features_free
+	ul.list-unstyled
+		li #{translate("one_collaborator")}
+		li(class="hidden-xs hidden-sm") &nbsp;
+		li(class="hidden-xs hidden-sm") &nbsp;
+		li(class="hidden-xs hidden-sm") &nbsp;
+		li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
+		li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
+		li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'") &nbsp;
+		li
+			br
+			+btn_buy_free('card')
+mixin features_premium
+	li(ng-if="plansVariant != 'more-details'") #{translate("full_doc_history")}
+	li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_dropbox")}
+	li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_github")}
+	li(ng-if="plansVariant === 'more-details'") &nbsp;
+	li(ng-if="plansVariant === 'more-details'")
+		strong #{translate('all_premium_features')}
+	li(ng-if="plansVariant === 'more-details'") #{translate('sync_dropbox_github')}
+	li(ng-if="plansVariant === 'more-details'") #{translate('full_doc_history')}
+	li(ng-if="plansVariant === 'more-details'") #{translate('track_changes')}
+	li(ng-if="plansVariant === 'more-details'") + #{translate('more').toLowerCase()}
+mixin features_professional
+	ul.list-unstyled
+		li
+			strong #{translate("unlimited_collabs")}
+			+features_premium
+		li
+			br
+			+btn_buy_professional('card')
+mixin features_student(location, plan)
+	ul.list-unstyled
+		li
+			strong #{translate("collabs_per_proj", {collabcount:6})}
+			+features_premium
+			li
+				br
+				+btn_buy_student(location, plan)
+
+//- Prices
+mixin price_collaborator
+	span(ng-if="ui.view == 'monthly'")
+		| {{plans[currencyCode]['collaborator']['monthly']}}
+		span.small /mo
+	span(ng-if="ui.view == 'annual'")
+		| {{plans[currencyCode]['collaborator']['annual']}}
+		span.small /yr
+mixin price_professional
+	span(ng-if="ui.view == 'monthly'")
+		| {{plans[currencyCode]['professional']['monthly']}}
+		span.small /mo
+	span(ng-if="ui.view == 'annual'")
+		| {{plans[currencyCode]['professional']['annual']}}
+		span.small /yr
+mixin price_student_annual
+	| {{plans[currencyCode]['student']['annual']}}
+	span.small /yr
+mixin price_student_monthly
+	| {{plans[currencyCode]['student']['monthly']}}
+	span.small /mo
+
+//- UI Control
+mixin currency_dropdown
+	.dropdown.currency-dropdown(dropdown)
+		a.btn.btn-default.dropdown-toggle(
+			href="#",
+			data-toggle="dropdown",
+			dropdown-toggle
+		)
+			| {{currencyCode}} ({{plans[currencyCode]['symbol']}})
+			span.caret
+
+		ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
+			li(ng-repeat="(currency, value) in plans")
+				a(
+					href="#",
+					ng-click="changeCurreny($event, currency)"
+				) {{currency}} ({{value['symbol']}})
+mixin plan_switch(location)
+	ul.nav.nav-pills
+		li(ng-class="{'active': ui.view == 'monthly'}")
+			a(
+				href="#"
+				ng-click="switchToMonthly($event,'" + location + "')"
+			) #{translate("monthly")}
+		li(ng-class="{'active': ui.view == 'annual'}")
+			a(
+				href="#"
+				ng-click="switchToAnnual($event,'" + location + "')"
+			) #{translate("annual")}
+		li(ng-class="{'active': ui.view == 'student'}")
+			a(
+				href="#"
+				ng-click="switchToStudent($event,'" + location + "')"
+			) #{translate("half_price_student")}
+

+ 107 - 0
services/web/app/views/subscriptions/_plans_page_tables.pug

@@ -0,0 +1,107 @@
+
+//- Features Tables
+mixin table_premium
+	table.card.plans-table
+		tr
+			th
+			th #{translate("personal")}
+			th #{translate("collaborator")}
+				.outer.outer-top
+					.outer-content
+						.best-value
+							strong #{translate('best_value')}
+			th #{translate("professional")}
+
+		tr
+			td #{translate("price")}
+			td #{translate("free")}
+			td
+				+price_collaborator
+			td
+				+price_professional
+
+		for feature in planFeatures
+			tr
+				td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
+					if feature.info
+						span(tooltip=translate(feature.info)) #{translate(feature.feature)}
+					else
+						| #{translate(feature.feature)}
+				for plan in feature.plans
+					td
+						if feature.value == 'str'
+							| #{plan}
+						else if plan
+							i.fa.fa-check
+						else
+							i.fa.fa-times
+
+		tr
+			td
+			td
+				+btn_buy_free('table')
+			td
+				+btn_buy_collaborator('table')
+				.outer.outer-btm
+					.outer-content &nbsp;
+			td
+				+btn_buy_professional('table')
+
+mixin table_cell_student(feature)
+	if feature.value == 'str'
+		| #{feature.student}
+	else if feature.student
+		i.fa.fa-check
+	else
+		i.fa.fa-times
+
+mixin table_student
+	table.card.plans-table
+		tr
+			th
+			th #{translate("personal")}
+			th #{translate("student")} (#{translate("annual")})
+				.outer.outer-top
+					.outer-content
+						.best-value
+							strong Best Value
+			th #{translate("student")}
+
+		tr
+			td #{translate("price")}
+			td #{translate("free")}
+			td
+				+price_student_annual
+			td
+				+price_student_monthly
+
+		for feature in planFeatures
+			tr
+				td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
+					if feature.info
+						span(tooltip=translate(feature.info)) #{translate(feature.feature)}
+					else
+						| #{translate(feature.feature)}
+				td
+					if feature.value == 'str'
+						| #{feature.plans.free}
+					else if feature.plans.free
+						i.fa.fa-check
+					else
+						i.fa.fa-times
+				td
+					+table_cell_student(feature)
+				td
+					+table_cell_student(feature)
+
+		tr
+			td
+			td
+				+btn_buy_free('table')
+			td
+				+btn_buy_student('table', 'annual')
+				.outer.outer-btm
+					.outer-content &nbsp;
+			td
+				+btn_buy_student('table', 'monthly')
+

+ 4 - 1
services/web/app/views/subscriptions/new.pug

@@ -31,7 +31,10 @@ block content
 											li(ng-repeat="(currency, value) in plans")
 											li(ng-repeat="(currency, value) in plans")
 												a(
 												a(
 													ng-click="changeCurrency(currency)",
 													ng-click="changeCurrency(currency)",
-												) {{currency}} ({{value['symbol']}})
+												) {{currency}} ({{value['symbol']}}) 
+							.row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'")
+								.col-xs-12
+									p.student-disclaimer #{translate('student_disclaimer')}
 							hr.thin
 							hr.thin
 							.row
 							.row
 								.col-md-12.text-center
 								.col-md-12.text-center

+ 8 - 243
services/web/app/views/subscriptions/plans.pug

@@ -1,253 +1,18 @@
 extends ../layout
 extends ../layout
+
+include _plans_page_mixins
+include _plans_page_tables
+
 block scripts
 block scripts
 	script(type='text/javascript').
 	script(type='text/javascript').
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.abCurrencyFlag = '#{abCurrencyFlag}'
 		window.abCurrencyFlag = '#{abCurrencyFlag}'
 		window.shouldABTestPlans = #{shouldABTestPlans || false}
 		window.shouldABTestPlans = #{shouldABTestPlans || false}
 
 
-	script(type='text/javascript').
-		(function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; 
-		s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js'; 
-		var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})(); 
-		
 block content
 block content
 	.content.content-alt
 	.content.content-alt
 		.content.plans(ng-controller="PlansController")
 		.content.plans(ng-controller="PlansController")
-			.container
-				.row
-					.col-md-12
-						.page-header.centered.plans-header.text-centered
-							h1(ng-cloak) #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
-				.row
-					.col-md-8.col-md-offset-2
-						p.text-centered #{translate("sl_benefits_plans")}
-
-				.row(ng-cloak)
-					.col-md-6.col-md-offset-3
-						ul.nav.nav-pills
-							li(ng-class="{'active': ui.view == 'monthly'}")
-								a(
-									href,
-									ng-click="switchToMonthly()"
-								) #{translate("monthly")}
-							li(ng-class="{'active': ui.view == 'annual'}")
-								a(
-									href
-									ng-click="switchToAnnual()"
-								) #{translate("annual")}
-							li(ng-class="{'active': ui.view == 'student'}")
-								a(
-									href,
-									ng-click="switchToStudent()"
-								) #{translate("half_price_student")} 
-					.col-md-2.text-right
-						.dropdown.currency-dropdown(dropdown)
-							a.btn.btn-default.dropdown-toggle#currenyDropdown(
-								href="#",
-								data-toggle="dropdown",
-								dropdown-toggle
-							)
-								| {{currencyCode}} ({{plans[currencyCode]['symbol']}}) 
-								span.caret
-
-							ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
-								li(ng-repeat="(currency, value) in plans")
-									a(
-										href,
-										ng-click="changeCurreny(currency)"
-									) {{currency}} ({{value['symbol']}})
-
-				div(ng-show="showPlans")
-					.row(ng-cloak)
-						.col-md-10.col-md-offset-1
-							.row
-								.card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
-									.col-md-4
-										.card.card-first
-											.card-header
-												h2 #{translate("personal")} 
-											.circle #{translate("free")} 
-											ul.list-unstyled
-												li #{translate("one_collaborator")}
-												li &nbsp;
-												li &nbsp;
-												li &nbsp;
-												li
-													br
-													a.btn.btn-info(
-														href="/register"
-														style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
-													) #{translate("sign_up_now")}
-									.col-md-4
-										.card.card-highlighted
-											.card-header
-												h2 #{translate("collaborator")}
-											.circle
-												span(ng-if="ui.view == 'monthly'")
-													| {{plans[currencyCode]['collaborator']['monthly']}}
-													span.small /mo
-												span(ng-if="ui.view == 'annual'")
-													| {{plans[currencyCode]['collaborator']['annual']}}
-													span.small /yr
-											ul.list-unstyled
-												li
-													strong #{translate("collabs_per_proj", {collabcount:10})}
-												li #{translate("full_doc_history")}
-												li #{translate("sync_to_dropbox")}
-												li #{translate("sync_to_github")}
-												li
-													br
-													a.btn.btn-info(
-														ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}&currency={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
-													)
-														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
-														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
-									.col-md-4
-										.card.card-last
-											.card-header
-												h2 #{translate("professional")}
-											.circle 
-												span(ng-if="ui.view == 'monthly'")
-													| {{plans[currencyCode]['professional']['monthly']}}
-													span.small /mo
-												span(ng-if="ui.view == 'annual'")
-													| {{plans[currencyCode]['professional']['annual']}}
-													span.small /yr
-											ul.list-unstyled
-												li
-													strong #{translate("unlimited_collabs")}
-												li #{translate("full_doc_history")}
-												li #{translate("sync_to_dropbox")}
-												li #{translate("sync_to_github")}
-												li
-													br
-													a.btn.btn-info(
-														ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}&currency={{currencyCode}}", ng-click="signUpNowClicked('professional')"
-													)
-														span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
-														span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
-
-								.card-group.text-centered(ng-if="ui.view == 'student'")
-									.col-md-4
-										.card.card-first
-											.card-header
-												h2 #{translate("personal")}
-											.circle #{translate("free")}
-											ul.list-unstyled
-												li #{translate("one_collaborator")}
-												li &nbsp;
-												li &nbsp;
-												li &nbsp;
-												li
-													br
-													a.btn.btn-info(
-														href="/register"
-														style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
-													) #{translate("sign_up_now")}
-
-									.col-md-4
-										.card.card-highlighted
-											.card-header
-												h2 #{translate("student")}
-											.circle
-												span
-													| {{plans[currencyCode]['student']['monthly']}}
-													span.small /mo
-											ul.list-unstyled
-												li
-													strong #{translate("collabs_per_proj", {collabcount:6})}
-												li #{translate("full_doc_history")}
-												li #{translate("sync_to_dropbox")}
-												li #{translate("sync_to_github")}
-												li
-													br
-													a.btn.btn-info(
-														ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}&currency={{currencyCode}}",
-														ng-click="signUpNowClicked('student-monthly')"
-													) #{translate("start_free_trial")}
-
-									.col-md-4
-										.card.card-last
-											.card-header
-												h2 #{translate("student")} (#{translate("annual")})
-											.circle
-												span
-													| {{plans[currencyCode]['student']['annual']}}
-													span.small /yr
-											ul.list-unstyled
-												li
-													strong #{translate("collabs_per_proj", {collabcount:6})}
-												li #{translate("full_doc_history")}
-												li #{translate("sync_to_dropbox")}
-												li #{translate("sync_to_github")}
-												li
-													br
-													a.btn.btn-info(
-														ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}&currency={{currencyCode}}",
-														ng-click="signUpNowClicked('student-annual')"
-													) #{translate("buy_now")}
-
-
-
-				.row.row-spaced(ng-cloak)
-					p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
-					
-				.row(ng-cloak)
-					.col-md-8.col-md-offset-2
-						.alert.alert-info.text-centered
-							| #{translate("interested_in_group_licence")}
-							br
-							a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
-						
-					script(type="text/ng-template", id="groupPlanModalTemplate")
-						.modal-header
-							h3 #{translate("group_plan_enquiry")}
-						.modal-body
-							form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak)
-								span(ng-show="sent == false && error == false")
-									.form-group
-										label#title9(for='Field9')
-											| Name
-										input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
-									label#title11.desc(for='Field11')
-										| Email
-									.form-group
-										input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
-									label#title12.desc(for='Field12')
-										| University / Company
-									.form-group
-										input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
-									label#title13.desc(for='Field13')
-										| Position
-									.form-group
-										input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
-									.form-group
-										input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
-									.form-group.text-center
-										input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending",  value='Request a quote')
-								span(ng-show="sent == true && error == false")
-									p Request Sent, Thank you.
-								span(ng-show="error")
-									p Error sending request.
-
-				.row
-					.col-md-12
-						.page-header.plans-header.plans-subheader.text-centered
-							h2 #{translate("enjoy_these_features")}
-					.col-md-4
-						.card.features.text-centered
-							i.fa.fa-file-text-o.fa-5x
-							h4 #{translate("unlimited_projects")}
-							p #{translate("create_unlimited_projects")}
-					.col-md-4
-						.card.features.text-centered
-							i.fa.fa-clock-o.fa-5x
-							h4 #{translate("full_doc_history")}
-							p #{translate("never_loose_work")}
-					.col-md-4
-						.card.features.text-centered
-							i.fa.fa-dropbox.fa-5x
-							|  &nbsp; &nbsp; 
-							i.fa.fa-github.fa-5x
-							h4 #{translate("sync_to_dropbox_and_github")}
-							p #{translate("access_projects_anywhere")}
+			.container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
+				include _plans_page_details_more
+			.container(ng-cloak ng-if="plansVariant != 'more-details'")
+				include _plans_page_details_less

+ 2 - 2
services/web/config/settings.defaults.coffee

@@ -146,8 +146,8 @@ module.exports = settings =
 			url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036"
 			url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036"
 		sixpack:
 		sixpack:
 			url: ""
 			url: ""
-		# references:
-		# 	url: "http://localhost:3040"
+		references:
+			url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040"
 		notifications:
 		notifications:
 			url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042"
 			url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042"
 		analytics:
 		analytics:

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

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

+ 39 - 5
services/web/public/coffee/directives/eventTracking.coffee

@@ -4,11 +4,23 @@
 #   event not sent to MB.
 #   event not sent to MB.
 #     for MB, add event-tracking-mb='true'
 #     for MB, add event-tracking-mb='true'
 #     by default, event sent to MB via sendMB
 #     by default, event sent to MB via sendMB
-#       this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute
 #   event not sent to GA.
 #   event not sent to GA.
 #     for GA, add event-tracking-ga attribute, where the value is the GA category
 #     for GA, add event-tracking-ga attribute, where the value is the GA category
+# Either GA or MB can use the attribute event-tracking-send-once='true' to
+# send event just once
+#   MB will use the key and GA will use the action to determine if the event
+#   has been sent
 # event-tracking-trigger attribute is required to send event
 # event-tracking-trigger attribute is required to send event
 
 
+isInViewport = (element) ->
+	elTop = element.offset().top
+	elBtm = elTop + element.outerHeight()
+
+	viewportTop = $(window).scrollTop()
+	viewportBtm = viewportTop + $(window).height()
+
+	elBtm > viewportTop && elTop < viewportBtm
+
 define [
 define [
 	'base'
 	'base'
 ], (App) ->
 ], (App) ->
@@ -22,20 +34,42 @@ define [
 				sendGA = attrs.eventTrackingGa || false
 				sendGA = attrs.eventTrackingGa || false
 				sendMB = attrs.eventTrackingMb || false
 				sendMB = attrs.eventTrackingMb || false
 				sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
 				sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
+				sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send'
 				segmentation = scope.eventSegmentation || {}
 				segmentation = scope.eventSegmentation || {}
-
 				segmentation.page = window.location.pathname
 				segmentation.page = window.location.pathname
 
 
-				sendEvent = () ->
+				sendEvent = (scrollEvent) ->
+					###
+						@param {boolean}		scrollEvent		Use to unbind scroll event
+					###
 					if sendMB
 					if sendMB
 						event_tracking[sendMBFunction] scope.eventTracking, segmentation
 						event_tracking[sendMBFunction] scope.eventTracking, segmentation
 					if sendGA
 					if sendGA
-						event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
+						event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
+					if scrollEvent
+						$(window).unbind('resize scroll')
 
 
 				if attrs.eventTrackingTrigger == 'load'
 				if attrs.eventTrackingTrigger == 'load'
 					sendEvent()
 					sendEvent()
 				else if attrs.eventTrackingTrigger == 'click'
 				else if attrs.eventTrackingTrigger == 'click'
 					element.on 'click', (e) ->
 					element.on 'click', (e) ->
 						sendEvent()
 						sendEvent()
+				else if attrs.eventTrackingTrigger == 'hover'
+					timer = null
+					timeoutAmt = 500
+					if attrs.eventHoverAmt
+						timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
+					element.on 'mouseenter', () ->
+						timer = setTimeout((-> sendEvent()), timeoutAmt)
+						return
+					.on 'mouseleave', () ->
+						clearTimeout(timer)
+				else if attrs.eventTrackingTrigger == 'scroll'
+					if !event_tracking.eventInCache(scope.eventTracking)
+						$(window).on 'resize scroll', () ->
+							_.throttle(
+								if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking)
+									sendEvent(true)
+							, 500)
 		}
 		}
-	]
+	]

+ 5 - 4
services/web/public/coffee/ide.coffee

@@ -18,6 +18,7 @@ define [
 	"ide/chat/index"
 	"ide/chat/index"
 	"ide/clone/index"
 	"ide/clone/index"
 	"ide/hotkeys/index"
 	"ide/hotkeys/index"
+	"ide/test-controls/index"
 	"ide/wordcount/index"
 	"ide/wordcount/index"
 	"ide/directives/layout"
 	"ide/directives/layout"
 	"ide/directives/validFile"
 	"ide/directives/validFile"
@@ -34,6 +35,7 @@ define [
 	"directives/videoPlayState"
 	"directives/videoPlayState"
 	"services/queued-http"
 	"services/queued-http"
 	"services/validateCaptcha"
 	"services/validateCaptcha"
+	"services/wait-for"
 	"filters/formatDate"
 	"filters/formatDate"
 	"main/event"
 	"main/event"
 	"main/account-upgrade"
 	"main/account-upgrade"
@@ -54,7 +56,7 @@ define [
 	SafariScrollPatcher
 	SafariScrollPatcher
 ) ->
 ) ->
 
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) ->
 		# Don't freak out if we're already in an apply callback
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->
 		$scope.$apply = (fn = () ->) ->
@@ -211,11 +213,10 @@ define [
 		try
 		try
 			chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null;
 			chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null;
 			browserIsChrome61or62 = (
 			browserIsChrome61or62 = (
-				chromeVersion? &&
-				(chromeVersion == 61 || chromeVersion == 62)
+				chromeVersion?
 			)
 			)
 			if browserIsChrome61or62
 			if browserIsChrome61or62
-				document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; }", 1)
+				document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1)
 		catch err
 		catch err
 			console.error err
 			console.error err
 
 

+ 24 - 7
services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee

@@ -2,7 +2,7 @@ define [
 	"base"
 	"base"
 	"moment"
 	"moment"
 ], (App, moment) ->
 ], (App, moment) ->
-	App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) ->
+	App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) ->
 
 
 		TWO_MEGABYTES = 2 * 1024 * 1024
 		TWO_MEGABYTES = 2 * 1024 * 1024
 
 
@@ -31,6 +31,7 @@ define [
 			data: null
 			data: null
 
 
 		$scope.refreshing = false
 		$scope.refreshing = false
+		$scope.refreshError = null
 
 
 		MAX_URL_LENGTH = 60
 		MAX_URL_LENGTH = 60
 		FRONT_OF_URL_LENGTH = 35
 		FRONT_OF_URL_LENGTH = 35
@@ -48,9 +49,27 @@ define [
 
 
 		$scope.refreshFile = (file) ->
 		$scope.refreshFile = (file) ->
 			$scope.refreshing = true
 			$scope.refreshing = true
+			$scope.refreshError = null
 			ide.fileTreeManager.refreshLinkedFile(file)
 			ide.fileTreeManager.refreshLinkedFile(file)
-				.then () ->
-					loadTextFileFilePreview()
+				.then (response) ->
+					{ data } = response
+					{ new_file_id } = data
+					$timeout(
+						() ->
+							waitFor(
+								() ->
+									ide.fileTreeManager.findEntityById(new_file_id)
+								5000
+							)
+								.then (newFile) ->
+									ide.binaryFilesManager.openFile(newFile)
+								.catch (err) ->
+									console.warn(err)
+						, 0
+					)
+					$scope.refreshError = null
+				.catch (response) ->
+					$scope.refreshError = response.data
 				.finally () ->
 				.finally () ->
 					$scope.refreshing = false
 					$scope.refreshing = false
 
 
@@ -86,11 +105,9 @@ define [
 					# show dots when payload is closs to cutoff
 					# show dots when payload is closs to cutoff
 					if data.length >= (TWO_MEGABYTES - 200)
 					if data.length >= (TWO_MEGABYTES - 200)
 						$scope.textPreview.shouldShowDots = true
 						$scope.textPreview.shouldShowDots = true
-					try
 						# remove last partial line
 						# remove last partial line
-						data = data.replace(/\n.*$/, '')
-					finally
-						$scope.textPreview.data = data
+						data = data?.replace?(/\n.*$/, '')
+					$scope.textPreview.data = data
 					$timeout(setHeight, 0)
 					$timeout(setHeight, 0)
 				.catch (error) ->
 				.catch (error) ->
 					console.error(error)
 					console.error(error)

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

@@ -14,7 +14,7 @@ define [
 				opening: true
 				opening: true
 				trackChanges: false
 				trackChanges: false
 				wantTrackChanges: false
 				wantTrackChanges: false
-				richText: false
+				showRichText: false
 			}
 			}
 
 
 			@$scope.$on "entity:selected", (event, entity) =>
 			@$scope.$on "entity:selected", (event, entity) =>

+ 5 - 0
services/web/public/coffee/ide/file-tree/FileTreeManager.coffee

@@ -335,6 +335,11 @@ define [
 
 
 			return null
 			return null
 
 
+		projectContainsFolder: () ->
+			for entity in @$scope.rootFolder.children
+				return true if entity.type == 'folder'
+			return false
+
 		existsInThisFolder: (folder, name) ->
 		existsInThisFolder: (folder, name) ->
 			for entity in folder?.children or []
 			for entity in folder?.children or []
 				return true if entity.name is name
 				return true if entity.name is name

+ 124 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee

@@ -43,6 +43,19 @@ define [
 				}
 				}
 			)
 			)
 
 
+		$scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () ->
+			unless 'project_file' in window.data.enabledLinkedFileTypes
+				console.warn("Project linked files are not enabled")
+				return
+			$modal.open(
+				templateUrl: "projectLinkedFileModalTemplate"
+				controller:  "ProjectLinkedFileModalController"
+				scope: $scope
+				resolve: {
+					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+				}
+			)
+
 		$scope.orderByFoldersFirst = (entity) ->
 		$scope.orderByFoldersFirst = (entity) ->
 			return '0' if entity?.type == "folder"
 			return '0' if entity?.type == "folder"
 			return '1'
 			return '1'
@@ -201,6 +214,117 @@ define [
 				$modalInstance.dismiss('cancel')
 				$modalInstance.dismiss('cancel')
 	]
 	]
 
 
+	App.controller "ProjectLinkedFileModalController", [
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+			$scope.data =
+				projects: null # or []
+				selectedProjectId: null
+				projectEntities: null # or []
+				selectedProjectEntity: null
+				name: null
+			$scope.state =
+				inFlight:
+					projects: false
+					entities: false
+					create: false
+				error: false
+
+			$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
+				return if !newVal
+				$scope.data.selectedProjectEntity = null
+				$scope.getProjectEntities($scope.data.selectedProjectId)
+
+			# auto-set filename based on selected file
+			$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
+				return if !newVal
+				fileName = newVal.split('/').reverse()[0]
+				if fileName
+					$scope.data.name = fileName
+
+			_setInFlight = (type) ->
+				$scope.state.inFlight[type] = true
+
+			_reset = (opts) ->
+				isError = opts.err == true
+				inFlight = $scope.state.inFlight
+				inFlight.projects = inFlight.entities = inFlight.create = false
+				$scope.state.error = isError
+
+			$scope.shouldEnableProjectSelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && data.projects
+
+			$scope.shouldEnableProjectEntitySelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
+
+			$scope.shouldEnableCreateButton = () ->
+				state = $scope.state
+				data = $scope.data
+				return !state.inFlight.projects &&
+					!state.inFlight.entities &&
+					data.projects &&
+					data.selectedProjectId &&
+					data.projectEntities &&
+					data.selectedProjectEntity &&
+					data.name
+
+			$scope.getUserProjects = () ->
+				_setInFlight('projects')
+				ide.$http.get("/user/projects", {
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					$scope.data.projectEntities = null
+					$scope.data.projects = resp.data.projects.filter (p) ->
+						p._id != ide.project_id
+					_reset(err: false)
+				.catch (err) ->
+					_reset(err: true)
+
+			$scope.getProjectEntities = (project_id) =>
+				_setInFlight('entities')
+				ide.$http.get("/project/#{project_id}/entities", {
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					if $scope.data.selectedProjectId == resp.data.project_id
+						$scope.data.projectEntities = resp.data.entities
+						_reset(err: false)
+				.catch (err) ->
+					_reset(err: true)
+
+			$scope.init = () ->
+				$scope.getUserProjects()
+			$timeout($scope.init, 0)
+
+			$scope.create = () ->
+				projectId = $scope.data.selectedProjectId
+				path = $scope.data.selectedProjectEntity
+				name = $scope.data.name
+				if !name || !path || !projectId
+					_reset(err: true)
+					return
+				_setInFlight('create')
+				ide.fileTreeManager
+					.createLinkedFile(name, parent_folder, 'project_file', {
+						source_project_id: projectId,
+						source_entity_path: path
+					})
+					.then () ->
+						_reset(err: false)
+						$modalInstance.close()
+					.catch (response)->
+						{ data } = response
+						_reset(err: true)
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+
+	]
+
+	# TODO: rename all this to UrlLinkedFilModalController
 	App.controller "LinkedFileModalController", [
 	App.controller "LinkedFileModalController", [
 		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
 		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
 		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
 		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->

+ 3 - 13
services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee

@@ -1,6 +1,7 @@
 define [
 define [
 	"base"
 	"base"
-], (App) ->
+	"ide/file-tree/util/iconTypeFromName"
+], (App, iconTypeFromName) ->
 	App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
 	App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
 		$scope.select = (e) ->
 		$scope.select = (e) ->
 			if e.ctrlKey or e.metaKey
 			if e.ctrlKey or e.metaKey
@@ -70,18 +71,7 @@ define [
 		$scope.$on "delete:selected", () ->
 		$scope.$on "delete:selected", () ->
 			$scope.openDeleteModal() if $scope.entity.selected
 			$scope.openDeleteModal() if $scope.entity.selected
 
 
-		$scope.iconTypeFromName = (name) ->
-			ext = name.split(".").pop()?.toLowerCase()
-			if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
-				return "image"
-			else if ext in ["csv", "xls", "xlsx"]
-				return "table"
-			else if ext in ["py", "r"]
-				return "file-text"
-			else if ext in ['bib']
-				return 'book'
-			else
-				return "file"
+		$scope.iconTypeFromName = iconTypeFromName
 	]
 	]
 
 
 	App.controller "DeleteEntityModalController", [
 	App.controller "DeleteEntityModalController", [

+ 13 - 0
services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee

@@ -0,0 +1,13 @@
+define [], () ->
+	return iconTypeFromName = (name) ->
+		ext = name.split(".").pop()?.toLowerCase()
+		if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
+			return "image"
+		else if ext in ["csv", "xls", "xlsx"]
+			return "table"
+		else if ext in ["py", "r"]
+			return "file-text"
+		else if ext in ['bib']
+			return 'book'
+		else
+			return "file"

+ 0 - 1
services/web/public/coffee/ide/history/HistoryManager.coffee

@@ -4,7 +4,6 @@ define [
 	"ide/history/util/displayNameForUser"
 	"ide/history/util/displayNameForUser"
 	"ide/history/controllers/HistoryListController"
 	"ide/history/controllers/HistoryListController"
 	"ide/history/controllers/HistoryDiffController"
 	"ide/history/controllers/HistoryDiffController"
-	"ide/history/controllers/HistoryV2DiffController"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/directives/infiniteScroll"
 ], (moment, ColorManager, displayNameForUser) ->
 ], (moment, ColorManager, displayNameForUser) ->
 	class HistoryManager
 	class HistoryManager

+ 86 - 10
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -2,13 +2,20 @@ define [
 	"moment"
 	"moment"
 	"ide/colors/ColorManager"
 	"ide/colors/ColorManager"
 	"ide/history/util/displayNameForUser"
 	"ide/history/util/displayNameForUser"
-	"ide/history/controllers/HistoryListController"
-	"ide/history/controllers/HistoryDiffController"
+	"ide/history/util/HistoryViewModes"
+	"ide/history/controllers/HistoryV2ListController"
+	"ide/history/controllers/HistoryV2DiffController"
+	"ide/history/controllers/HistoryV2FileTreeController"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/directives/infiniteScroll"
-], (moment, ColorManager, displayNameForUser) ->
+	"ide/history/components/historyEntriesList"
+	"ide/history/components/historyEntry"
+	"ide/history/components/historyFileTree"
+	"ide/history/components/historyFileEntity"
+], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
 	class HistoryManager
 	class HistoryManager
 		constructor: (@ide, @$scope) ->
 		constructor: (@ide, @$scope) ->
 			@reset()
 			@reset()
+			@$scope.HistoryViewModes = HistoryViewModes
 
 
 			@$scope.toggleHistory = () =>
 			@$scope.toggleHistory = () =>
 				if @$scope.ui.view == "history"
 				if @$scope.ui.view == "history"
@@ -16,17 +23,31 @@ define [
 				else
 				else
 					@show()
 					@show()
 
 
+			@$scope.toggleHistoryViewMode = () =>
+				if @$scope.history.viewMode == HistoryViewModes.COMPARE
+					@reset()
+					@$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
+				else
+					@reset()
+					@$scope.history.viewMode = HistoryViewModes.COMPARE
+
 			@$scope.$watch "history.selection.updates", (updates) =>
 			@$scope.$watch "history.selection.updates", (updates) =>
-				if updates? and updates.length > 0
-					@_selectDocFromUpdates()
+				if @$scope.history.viewMode == HistoryViewModes.COMPARE
+					if updates? and updates.length > 0
+						@_selectDocFromUpdates()
+						@reloadDiff()
+			
+			@$scope.$watch "history.selection.pathname", (pathname) =>
+				if @$scope.history.viewMode == HistoryViewModes.POINT_IN_TIME
+					if pathname?
+						@loadFileAtPointInTime()
+				else 
 					@reloadDiff()
 					@reloadDiff()
 
 
-			@$scope.$watch "history.selection.pathname", () =>
-				@reloadDiff()
-
 		show: () ->
 		show: () ->
 			@$scope.ui.view = "history"
 			@$scope.ui.view = "history"
 			@reset()
 			@reset()
+			@$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
 
 
 		hide: () ->
 		hide: () ->
 			@$scope.ui.view = "editor"
 			@$scope.ui.view = "editor"
@@ -35,6 +56,7 @@ define [
 			@$scope.history = {
 			@$scope.history = {
 				isV2: true
 				isV2: true
 				updates: []
 				updates: []
+				viewMode: null
 				nextBeforeTimestamp: null
 				nextBeforeTimestamp: null
 				atEnd: false
 				atEnd: false
 				selection: {
 				selection: {
@@ -46,16 +68,33 @@ define [
 						toV: null
 						toV: null
 					}
 					}
 				}
 				}
-				diff: null
+				files: []
+				diff: null # When history.viewMode == HistoryViewModes.COMPARE
+				selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
 			}
 			}
 
 
 		restoreFile: (version, pathname) ->
 		restoreFile: (version, pathname) ->
 			url = "/project/#{@$scope.project_id}/restore_file"
 			url = "/project/#{@$scope.project_id}/restore_file"
+
 			@ide.$http.post(url, {
 			@ide.$http.post(url, {
 				version, pathname,
 				version, pathname,
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			})
 			})
 
 
+		loadFileTreeForUpdate: (update) ->
+			{fromV, toV} = update
+			url = "/project/#{@$scope.project_id}/filetree/diff"
+			query = [ "from=#{toV}", "to=#{toV}" ]
+			url += "?" + query.join("&")
+			@$scope.history.loadingFileTree = true
+			@$scope.history.selectedFile = null
+			@$scope.history.selection.pathname = null
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					@$scope.history.files = response.data.diff
+					@$scope.history.loadingFileTree = false
+
 		MAX_RECENT_UPDATES_TO_SELECT: 5
 		MAX_RECENT_UPDATES_TO_SELECT: 5
 		autoSelectRecentUpdates: () ->
 		autoSelectRecentUpdates: () ->
 			return if @$scope.history.updates.length == 0
 			return if @$scope.history.updates.length == 0
@@ -70,12 +109,28 @@ define [
 
 
 			@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
 			@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
 
 
+		autoSelectLastUpdate: () ->
+			return if @$scope.history.updates.length == 0
+			@selectUpdate @$scope.history.updates[0]
+
+		selectUpdate: (update) ->
+			selectedUpdateIndex = @$scope.history.updates.indexOf update
+			if selectedUpdateIndex == -1
+				selectedUpdateIndex = 0
+			for update in @$scope.history.updates
+				update.selectedTo = false
+				update.selectedFrom = false
+			@$scope.history.updates[selectedUpdateIndex].selectedTo = true
+			@$scope.history.updates[selectedUpdateIndex].selectedFrom = true
+			@loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex]
+
 		BATCH_SIZE: 10
 		BATCH_SIZE: 10
 		fetchNextBatchOfUpdates: () ->
 		fetchNextBatchOfUpdates: () ->
 			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
 			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
 			if @$scope.history.nextBeforeTimestamp?
 			if @$scope.history.nextBeforeTimestamp?
 				url += "&before=#{@$scope.history.nextBeforeTimestamp}"
 				url += "&before=#{@$scope.history.nextBeforeTimestamp}"
 			@$scope.history.loading = true
 			@$scope.history.loading = true
+			@$scope.history.loadingFileTree = true
 			@ide.$http
 			@ide.$http
 				.get(url)
 				.get(url)
 				.then (response) =>
 				.then (response) =>
@@ -86,6 +141,23 @@ define [
 						@$scope.history.atEnd = true
 						@$scope.history.atEnd = true
 					@$scope.history.loading = false
 					@$scope.history.loading = false
 
 
+		loadFileAtPointInTime: () ->
+			pathname = @$scope.history.selection.pathname
+			toV =  @$scope.history.selection.updates[0].toV
+			url = "/project/#{@$scope.project_id}/diff"
+			query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
+			url += "?" + query.join("&")
+			@$scope.history.selectedFile =
+				loading: true
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					{text, binary} = @_parseDiff(response.data.diff)
+					@$scope.history.selectedFile.binary = binary
+					@$scope.history.selectedFile.text = text
+					@$scope.history.selectedFile.loading = false
+				.catch () ->
+
 		reloadDiff: () ->
 		reloadDiff: () ->
 			diff = @$scope.history.diff
 			diff = @$scope.history.diff
 			{updates} = @$scope.history.selection
 			{updates} = @$scope.history.selection
@@ -200,7 +272,11 @@ define [
 			@$scope.history.updates =
 			@$scope.history.updates =
 				@$scope.history.updates.concat(updates)
 				@$scope.history.updates.concat(updates)
 
 
-			@autoSelectRecentUpdates() if firstLoad
+			if firstLoad 
+				if @$scope.history.viewMode == HistoryViewModes.COMPARE
+					@autoSelectRecentUpdates()
+				else 
+					@autoSelectLastUpdate()
 
 
 		_perDocSummaryOfUpdates: (updates) ->
 		_perDocSummaryOfUpdates: (updates) ->
 			# Track current_pathname -> original_pathname
 			# Track current_pathname -> original_pathname

+ 19 - 0
services/web/public/coffee/ide/history/components/historyEntriesList.coffee

@@ -0,0 +1,19 @@
+define [
+	"base"
+], (App) ->
+	historyEntriesListController = ($scope, $element, $attrs) ->
+		ctrl = @ 
+		return
+
+	App.component "historyEntriesList", {
+		bindings:
+			entries: "<"
+			loadEntries: "&"
+			loadDisabled: "<"
+			loadInitialize: "<"
+			isLoading: "<"
+			currentUser: "<"
+			onEntrySelect: "&"
+		controller: historyEntriesListController
+		templateUrl: "historyEntriesListTpl"
+	}

+ 27 - 0
services/web/public/coffee/ide/history/components/historyEntry.coffee

@@ -0,0 +1,27 @@
+define [
+	"base"
+	"ide/history/util/displayNameForUser"
+], (App, displayNameForUser) ->
+	historyEntryController = ($scope, $element, $attrs) ->
+		ctrl = @
+		ctrl.displayName = displayNameForUser
+		ctrl.getProjectOpDoc = (projectOp) ->
+			if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }"
+			else if projectOp.add? then "#{ projectOp.add.pathname}"
+			else if projectOp.remove? then "#{ projectOp.remove.pathname}"
+		ctrl.getUserCSSStyle = (user) ->
+			hue = user?.hue or 100
+			if ctrl.entry.inSelection 
+				color : "#FFF" 
+			else 
+				color: "hsl(#{ hue }, 70%, 50%)"
+		return
+
+	App.component "historyEntry", {
+		bindings:
+			entry: "<"
+			currentUser: "<"
+			onSelect: "&"
+		controller: historyEntryController
+		templateUrl: "historyEntryTpl"
+	}

+ 34 - 0
services/web/public/coffee/ide/history/components/historyFileEntity.coffee

@@ -0,0 +1,34 @@
+define [
+	"base"
+	"ide/file-tree/util/iconTypeFromName"
+], (App, iconTypeFromName) ->
+	# TODO Add arrows in folders
+	historyFileEntityController = ($scope, $element, $attrs) ->
+		ctrl = @
+		_handleFolderClick = () ->
+			ctrl.isOpen = !ctrl.isOpen
+			ctrl.iconClass = _getFolderIcon()
+		_handleFileClick = () ->
+			ctrl.historyFileTreeController.handleEntityClick ctrl.fileEntity
+		_getFolderIcon = () ->
+			if ctrl.isOpen then "fa-folder-open" else "fa-folder"
+		ctrl.$onInit = () ->
+			if ctrl.fileEntity.type == "folder"
+				ctrl.isOpen = true
+				ctrl.iconClass = _getFolderIcon()
+				ctrl.handleClick = _handleFolderClick
+			else
+				ctrl.iconClass = "fa-#{ iconTypeFromName(ctrl.fileEntity.name) }"
+				ctrl.handleClick = _handleFileClick
+				$scope.$watch (() -> ctrl.historyFileTreeController.selectedPathname), (newPathname) ->
+					ctrl.isSelected = ctrl.fileEntity.pathname == newPathname
+		return
+
+	App.component "historyFileEntity", {
+		require: 
+			historyFileTreeController: "^historyFileTree"
+		bindings:
+			fileEntity: "<"
+		controller: historyFileEntityController
+		templateUrl: "historyFileEntityTpl"
+	}

+ 18 - 0
services/web/public/coffee/ide/history/components/historyFileTree.coffee

@@ -0,0 +1,18 @@
+define [
+	"base"
+], (App) ->
+	historyFileTreeController = ($scope, $element, $attrs) ->
+		ctrl = @
+		ctrl.handleEntityClick = (file) ->
+			ctrl.onSelectedFileChange file: file
+		return
+
+	App.component "historyFileTree", {
+		bindings:
+			fileTree: "<"
+			selectedPathname: "<"
+			onSelectedFileChange: "&"
+			isLoading: "<"
+		controller: historyFileTreeController
+		templateUrl: "historyFileTreeTpl"
+	}

+ 1 - 1
services/web/public/coffee/ide/history/controllers/HistoryListController.coffee

@@ -5,7 +5,7 @@ define [
 
 
 	App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
 	App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
 		$scope.hoveringOverListSelectors = false
 		$scope.hoveringOverListSelectors = false
-
+		
 		$scope.loadMore = () =>
 		$scope.loadMore = () =>
 			ide.historyManager.fetchNextBatchOfUpdates()
 			ide.historyManager.fetchNextBatchOfUpdates()
 
 

+ 13 - 14
services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee

@@ -1,7 +1,7 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
+	App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) ->
 		$scope.restoreState =
 		$scope.restoreState =
 			inflight: false
 			inflight: false
 			error: false
 			error: false
@@ -24,17 +24,16 @@ define [
 					$scope.restoreState.inflight = false
 					$scope.restoreState.inflight = false
 
 
 		openEntity = (data) ->
 		openEntity = (data) ->
-			iterations = 0
 			{id, type} = data
 			{id, type} = data
-			do tryOpen = () ->
-				if iterations > 5
-					return
-				iterations += 1
-				entity = ide.fileTreeManager.findEntityById(id)
-				if entity? and type == 'doc'
-					ide.editorManager.openDoc(entity)
-				else if entity? and type == 'file'
-					ide.binaryFilesManager.openFile(entity)
-				else
-					setTimeout(tryOpen, 500)
-			
+			waitFor(
+				() ->
+					ide.fileTreeManager.findEntityById(id)
+				3000
+			)
+				.then (entity) ->
+					if type == 'doc'
+						ide.editorManager.openDoc(entity)
+					else if type == 'file'
+						ide.binaryFilesManager.openFile(entity)
+				.catch (err) ->
+					console.warn(err)

+ 54 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee

@@ -0,0 +1,54 @@
+define [
+	"base"
+], (App) ->
+
+	App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) ->
+		_previouslySelectedPathname = null
+		$scope.currentFileTree = []
+
+		_pathnameExistsInFiles = (pathname, files) -> 
+			_.any files, (file) -> file.pathname == pathname
+
+		_getSelectedDefaultPathname = (files) ->
+			selectedPathname = null
+			if _previouslySelectedPathname? and _pathnameExistsInFiles _previouslySelectedPathname, files
+				selectedPathname = _previouslySelectedPathname
+			else 
+				mainFile = _.find files, (file) -> /main\.tex$/.test file.pathname
+				if mainFile?
+					selectedPathname = _previouslySelectedPathname = mainFile.pathname
+				else
+					selectedPathname = _previouslySelectedPathname = files[0].pathname
+			return selectedPathname
+
+		$scope.handleFileSelection = (file) ->
+			$scope.history.selection.pathname = _previouslySelectedPathname = file.pathname
+
+		$scope.$watch 'history.files', (files) ->
+			if files? and files.length > 0
+				$scope.currentFileTree = _.reduce files, _reducePathsToTree, []
+				$scope.history.selection.pathname = _getSelectedDefaultPathname(files)
+
+		_reducePathsToTree = (currentFileTree, fileObject) ->
+			filePathParts = fileObject.pathname.split "/"
+			currentFileTreeLocation = currentFileTree
+			for pathPart, index in filePathParts
+				isFile = index == filePathParts.length - 1
+				if isFile
+					fileTreeEntity =
+						name: pathPart
+						pathname: fileObject.pathname
+						type: "file"
+						operation: fileObject.operation || "edited"
+					currentFileTreeLocation.push fileTreeEntity
+				else
+					fileTreeEntity = _.find currentFileTreeLocation, (entity) => entity.name == pathPart
+					if !fileTreeEntity?
+						fileTreeEntity = 
+							name: pathPart
+							type: "folder"
+							children: []
+						currentFileTreeLocation.push fileTreeEntity
+					currentFileTreeLocation = fileTreeEntity.children				
+			return currentFileTree
+	]

+ 76 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee

@@ -0,0 +1,76 @@
+define [
+	"base",
+	"ide/history/util/displayNameForUser"
+], (App, displayNameForUser) ->
+
+	App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) ->
+		$scope.hoveringOverListSelectors = false
+		
+		$scope.loadMore = () =>
+			ide.historyManager.fetchNextBatchOfUpdates()
+
+		$scope.handleEntrySelect = (entry) ->
+			# $scope.$applyAsync () ->
+			ide.historyManager.selectUpdate(entry)
+			$scope.recalculateSelectedUpdates()
+
+		$scope.recalculateSelectedUpdates = () ->
+			beforeSelection = true
+			afterSelection = false
+			$scope.history.selection.updates = []
+			for update in $scope.history.updates
+				if update.selectedTo
+					inSelection = true
+					beforeSelection = false
+
+				update.beforeSelection = beforeSelection
+				update.inSelection = inSelection
+				update.afterSelection = afterSelection
+
+				if inSelection
+					$scope.history.selection.updates.push update
+
+				if update.selectedFrom
+					inSelection = false
+					afterSelection = true
+
+		$scope.recalculateHoveredUpdates = () ->
+			hoverSelectedFrom = false
+			hoverSelectedTo = false
+			for update in $scope.history.updates
+				# Figure out whether the to or from selector is hovered over
+				if update.hoverSelectedFrom
+					hoverSelectedFrom = true
+				if update.hoverSelectedTo
+					hoverSelectedTo = true
+
+			if hoverSelectedFrom
+				# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
+				inHoverSelection = false
+				for update in $scope.history.updates
+					if update.selectedTo
+						update.hoverSelectedTo = true
+						inHoverSelection = true
+					update.inHoverSelection = inHoverSelection
+					if update.hoverSelectedFrom
+						inHoverSelection = false
+			if hoverSelectedTo
+				# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
+				inHoverSelection = false
+				for update in $scope.history.updates
+					if update.hoverSelectedTo
+						inHoverSelection = true
+					update.inHoverSelection = inHoverSelection
+					if update.selectedFrom
+						update.hoverSelectedFrom = true
+						inHoverSelection = false
+
+		$scope.resetHoverState = () ->
+			for update in $scope.history.updates
+				delete update.hoverSelectedFrom
+				delete update.hoverSelectedTo
+				delete update.inHoverSelection
+
+		$scope.$watch "history.updates.length", () ->
+			$scope.recalculateSelectedUpdates()
+	]

+ 4 - 0
services/web/public/coffee/ide/history/util/HistoryViewModes.coffee

@@ -0,0 +1,4 @@
+define [], () ->
+	HistoryViewModes = 
+		POINT_IN_TIME : 'point_in_time'
+		COMPARE       : 'compare'

+ 16 - 0
services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee

@@ -0,0 +1,16 @@
+define [
+	"base"
+	"ace/ace"
+], (App) ->
+	App.controller "TestControlsController", ($scope) ->
+
+		$scope.openProjectLinkedFileModal = () ->
+			window.openProjectLinkedFileModal()
+
+		$scope.openLinkedFileModal = () ->
+			window.openLinkedFileModal()
+
+		$scope.richText = () ->
+			current = window.location.toString()
+			target = "#{current}#{if window.location.search then '&' else '?'}rt=true"
+			window.location.href = target

+ 3 - 0
services/web/public/coffee/ide/test-controls/index.coffee

@@ -0,0 +1,3 @@
+define [
+	"ide/test-controls/controllers/TestControlsController"
+], () ->

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

@@ -1,79 +1,7 @@
 define [
 define [
 	"base"
 	"base"
 	"libs/platform"
 	"libs/platform"
-	"services/algolia-search"
 ], (App, platform) ->
 ], (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) ->
 	App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
 
 
 		$scope.form = {}
 		$scope.form = {}

+ 7 - 0
services/web/public/coffee/main/event.coffee

@@ -50,6 +50,10 @@ define [
 			send: (category, action, label, value)->
 			send: (category, action, label, value)->
 				ga('send', 'event', category, action, label, value)
 				ga('send', 'event', category, action, label, value)
 
 
+			sendGAOnce: (category, action, label, value) ->
+				if ! _eventInCache(action)
+					_addEventToCache(action)
+					@send category, action, label, value
 
 
 			editingSessionHeartbeat: () ->
 			editingSessionHeartbeat: () ->
 				return unless nextHeartbeat <= new Date()
 				return unless nextHeartbeat <= new Date()
@@ -86,6 +90,9 @@ define [
 				if ! _eventInCache(key)
 				if ! _eventInCache(key)
 					_addEventToCache(key)
 					_addEventToCache(key)
 					@sendMB key, segmentation
 					@sendMB key, segmentation
+
+			eventInCache: (key) ->
+				_eventInCache(key)
 		}
 		}
 
 
 
 

+ 4 - 0
services/web/public/coffee/main/new-subscription.coffee

@@ -9,6 +9,7 @@ define [
 
 
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.currencyCode = MultiCurrencyPricing.currencyCode
 		$scope.plans = MultiCurrencyPricing.plans
 		$scope.plans = MultiCurrencyPricing.plans
+		$scope.planCode = window.plan_code
 
 
 		$scope.switchToStudent = ()->
 		$scope.switchToStudent = ()->
 			currentPlanCode = window.plan_code
 			currentPlanCode = window.plan_code
@@ -234,3 +235,6 @@ define [
 			{code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'},
 			{code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'},
 			{code:'ZM',name:'Zambia'},{code:'AX',name:'&angst;land Islandscode:'}
 			{code:'ZM',name:'Zambia'},{code:'AX',name:'&angst;land Islandscode:'}
 		]
 		]
+
+		sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)->
+			$scope.plansVariant = chosenVariation

+ 40 - 29
services/web/public/coffee/main/plans.coffee

@@ -3,7 +3,6 @@ define [
 	"libs/recurly-4.8.5"
 	"libs/recurly-4.8.5"
 ], (App, recurly) ->
 ], (App, recurly) ->
 
 
-
 	App.factory "MultiCurrencyPricing", () ->
 	App.factory "MultiCurrencyPricing", () ->
 
 
 		currencyCode = window.recomendedCurrency
 		currencyCode = window.recomendedCurrency
@@ -146,17 +145,16 @@ define [
 		}
 		}
 
 
 
 
-	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) ->
+	App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) ->
 
 
 		$scope.showPlans = false
 		$scope.showPlans = false
-
-		$scope.plansVariant = 'default'
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 
 
 		if $scope.shouldABTestPlans
 		if $scope.shouldABTestPlans
-				$scope.showPlans = true
-		else
-			$scope.showPlans = true
+			sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)->
+				$scope.plansVariant = chosenVariation
+
+		$scope.showPlans = true
 
 
 		$scope.plans = MultiCurrencyPricing.plans
 		$scope.plans = MultiCurrencyPricing.plans
 
 
@@ -169,44 +167,57 @@ define [
 		$scope.ui =
 		$scope.ui =
 			view: "monthly"
 			view: "monthly"
 
 
-		$scope.changeCurreny = (newCurrency)->
+		$scope.changeCurreny = (e, newCurrency)->
+			e.preventDefault()
 			$scope.currencyCode = newCurrency
 			$scope.currencyCode = newCurrency
 
 
 		# because ternary logic in angular bindings is hard
 		# because ternary logic in angular bindings is hard
 		$scope.getCollaboratorPlanCode = () ->
 		$scope.getCollaboratorPlanCode = () ->
 			view = $scope.ui.view
 			view = $scope.ui.view
-			variant = $scope.plansVariant
 			if view == "annual"
 			if view == "annual"
-				if variant == "default"
-					return "collaborator-annual"
-				else
-					return "collaborator-annual_#{variant}"
+				return "collaborator-annual"
 			else
 			else
-				if variant == "default"
-					return "collaborator#{$scope.planQueryString}"
-				else
-					return "collaborator_#{variant}"
+				return "collaborator#{$scope.planQueryString}"
 
 
-		$scope.signUpNowClicked = (plan, annual)->
-			event_tracking.sendMB 'plans-page-start-trial', {plan}
+		$scope.signUpNowClicked = (plan, location)->
 			if $scope.ui.view == "annual"
 			if $scope.ui.view == "annual"
 				plan = "#{plan}_annual"
 				plan = "#{plan}_annual"
-			event_tracking.send   'subscription-funnel', 'sign_up_now_button', plan
+			plan = eventLabel(plan, location)
+			event_tracking.sendMB 'plans-page-start-trial', {plan}
+			event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
+			if $scope.shouldABTestPlans
+				sixpack.convert 'plans-details'
 
 
-		$scope.switchToMonthly = ->
-			$scope.ui.view = "monthly"
-			event_tracking.send 'subscription-funnel', 'plans-page', 'monthly-prices'
+		$scope.switchToMonthly = (e, location) ->
+			uiView = 'monthly'
+			switchEvent(e, uiView + '-prices', location)
+			$scope.ui.view = uiView
 
 
-		$scope.switchToStudent = ->
-			$scope.ui.view = "student"
-			event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices'
+		$scope.switchToStudent = (e, location) ->
+			uiView = 'student'
+			switchEvent(e, uiView + '-prices', location)
+			$scope.ui.view = uiView
 
 
-		$scope.switchToAnnual = ->
-			$scope.ui.view = "annual"
-			event_tracking.send 'subscription-funnel', 'plans-page', 'annual-prices'
+		$scope.switchToAnnual = (e, location) ->
+			uiView = 'annual'
+			switchEvent(e, uiView + '-prices', location)
+			$scope.ui.view = uiView
 
 
 		$scope.openGroupPlanModal = () ->
 		$scope.openGroupPlanModal = () ->
 			$modal.open {
 			$modal.open {
 				templateUrl: "groupPlanModalTemplate"
 				templateUrl: "groupPlanModalTemplate"
 			}
 			}
 			event_tracking.send 'subscription-funnel', 'plans-page', 'group-inquiry-potential'
 			event_tracking.send 'subscription-funnel', 'plans-page', 'group-inquiry-potential'
+
+		eventLabel = (label, location) ->
+			if location && $scope.plansVariant != 'default'
+				label = label + '-' + location
+			if $scope.plansVariant != 'default'
+				label += '-exp-' + $scope.plansVariant
+			label
+
+		switchEvent = (e, label, location) ->
+			e.preventDefault()
+			gaLabel = eventLabel(label, location)
+			event_tracking.send 'subscription-funnel', 'plans-page', gaLabel
+

+ 39 - 14
services/web/public/coffee/main/project-list/project-list.coffee

@@ -320,6 +320,9 @@ define [
 						name: cloneName
 						name: cloneName
 						id: data.project_id
 						id: data.project_id
 						accessLevel: "owner"
 						accessLevel: "owner"
+						owner: {
+							_id: user_id
+						}
 						# TODO: Check access level if correct after adding it in
 						# TODO: Check access level if correct after adding it in
 						# to the rest of the app
 						# to the rest of the app
 					}
 					}
@@ -350,14 +353,15 @@ define [
 				$scope.archiveOrLeaveSelectedProjects()
 				$scope.archiveOrLeaveSelectedProjects()
 
 
 		$scope.archiveOrLeaveSelectedProjects = () ->
 		$scope.archiveOrLeaveSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.archiveOrLeaveProjects($scope.getSelectedProjects())
 
 
+		$scope.archiveOrLeaveProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
 			# Remove project from any tags
 			# Remove project from any tags
 			for tag in $scope.tags
 			for tag in $scope.tags
-				$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
+				$scope._removeProjectIdsFromTagArray(tag, projectIds)
 
 
-			for project in selected_projects
+			for project in projects
 				project.tags = []
 				project.tags = []
 				if project.accessLevel == "owner"
 				if project.accessLevel == "owner"
 					project.archived = true
 					project.archived = true
@@ -414,16 +418,17 @@ define [
 			$scope.updateVisibleProjects()
 			$scope.updateVisibleProjects()
 
 
 		$scope.restoreSelectedProjects = () ->
 		$scope.restoreSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.restoreProjects($scope.getSelectedProjects())
 
 
-			for project in selected_projects
+		$scope.restoreProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
+			for project in projects
 				project.archived = false
 				project.archived = false
 
 
-			for project_id in selected_project_ids
+			for projectId in projectIds
 				queuedHttp {
 				queuedHttp {
 					method: "POST"
 					method: "POST"
-					url: "/project/#{project_id}/restore"
+					url: "/project/#{projectId}/restore"
 					headers:
 					headers:
 						"X-CSRF-Token": window.csrfToken
 						"X-CSRF-Token": window.csrfToken
 				}
 				}
@@ -437,13 +442,14 @@ define [
 			)
 			)
 
 
 		$scope.downloadSelectedProjects = () ->
 		$scope.downloadSelectedProjects = () ->
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.downloadProjectsById($scope.getSelectedProjectIds())
+
+		$scope.downloadProjectsById = (projectIds) ->
 			event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
 			event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
-			if selected_project_ids.length > 1
-				path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}"
+			if projectIds.length > 1
+				path = "/project/download/zip?project_ids=#{projectIds.join(',')}"
 			else
 			else
-				path = "/project/#{selected_project_ids[0]}/download/zip"
-
+				path = "/project/#{projectIds[0]}/download/zip"
 			window.location = path
 			window.location = path
 
 
 		$scope.openV1ImportModal = (project) ->
 		$scope.openV1ImportModal = (project) ->
@@ -487,6 +493,25 @@ define [
 			else
 			else
 				return "None"
 				return "None"
 
 
+		$scope.isOwner = () ->
+			window.user_id == $scope.project.owner._id
+
 		$scope.$watch "project.selected", (value) ->
 		$scope.$watch "project.selected", (value) ->
 			if value?
 			if value?
 				$scope.updateSelectedProjects()
 				$scope.updateSelectedProjects()
+
+		$scope.clone = (e) ->
+			e.stopPropagation()
+			$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
+
+		$scope.download = (e) ->
+			e.stopPropagation()
+			$scope.downloadProjectsById([$scope.project.id])
+
+		$scope.archiveOrLeave = (e) ->
+			e.stopPropagation()
+			$scope.archiveOrLeaveProjects([$scope.project])
+
+		$scope.restore = (e) ->
+			e.stopPropagation()
+			$scope.restoreProjects([$scope.project])

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

@@ -8,7 +8,7 @@ define [
 			kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
 			kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
 
 
 		service =
 		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
 		return service

+ 20 - 0
services/web/public/coffee/services/wait-for.coffee

@@ -0,0 +1,20 @@
+define [
+	"base"
+], (App) ->
+	App.factory "waitFor", ($q) ->
+		waitFor = (testFunction, timeout, pollInterval=500) ->
+			iterationLimit = Math.floor(timeout / pollInterval)
+			iterations = 0
+			$q(
+				(resolve, reject) ->
+					do tryIteration = () ->
+						if iterations > iterationLimit
+							return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}"))
+						iterations += 1
+						result = testFunction()
+						if result?
+							resolve(result)
+						else
+							setTimeout(tryIteration, pollInterval)
+			)
+		return waitFor

BIN
services/web/public/img/advocates/erdogmus.jpg


BIN
services/web/public/img/advocates/henderson.jpg


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

@@ -80,6 +80,7 @@
 @import "app/review-features-page.less";
 @import "app/review-features-page.less";
 @import "app/error-pages.less";
 @import "app/error-pages.less";
 @import "app/v1-badge.less";
 @import "app/v1-badge.less";
+@import "app/editor/history-v2.less";
 @import "app/metrics.less";
 @import "app/metrics.less";
 
 
 // Vendor CSS
 // Vendor CSS

+ 6 - 1
services/web/public/stylesheets/app/editor.less

@@ -74,9 +74,13 @@
 
 
 #ide-body {
 #ide-body {
 	.full-size;
 	.full-size;
-	top: 40px;
+	top: @ide-body-top-offset;
+	&.ide-history-open {
+		top: @ide-body-top-offset + @editor-toolbar-height;
+	}
 }
 }
 
 
+
 #editor, #editor-rich-text {
 #editor, #editor-rich-text {
 	.full-size;
 	.full-size;
 }
 }
@@ -88,6 +92,7 @@
 .toolbar-editor {
 .toolbar-editor {
 	height: @editor-toolbar-height;
 	height: @editor-toolbar-height;
 	background-color: @editor-toolbar-bg;
 	background-color: @editor-toolbar-bg;
+	overflow: hidden;
 }
 }
 
 
 .loading-screen {
 .loading-screen {

+ 498 - 0
services/web/public/stylesheets/app/editor/history-v2.less

@@ -0,0 +1,498 @@
+.history-toolbar {
+	display: flex;
+	align-items: center;
+	position: absolute;
+	width: 100%;
+	top: @ide-body-top-offset;
+	height: @editor-toolbar-height;
+	line-height: 1;
+	font-size: @font-size-small;
+	background-color: @history-toolbar-bg-color;
+	z-index: 1;
+	color: @history-toolbar-color;
+	padding-left: (@line-height-computed / 2);
+}
+.history-toolbar when (@is-overleaf = false) {
+	border-bottom: @toolbar-border-bottom;
+}
+	.history-toolbar-time {
+		font-weight: bold;
+	}
+	.history-toolbar-btn {
+		.btn;
+		.btn-info;
+		.btn-xs;
+		padding-left: @padding-small-horizontal;
+		padding-right: @padding-small-horizontal;
+		margin-left: (@line-height-computed / 2);
+	}
+
+.history-entries {
+	font-size: @history-base-font-size;
+	color: @history-base-color;
+	height: 100%;
+	background-color: @history-base-bg;
+}
+
+.history-entry-day {
+	display: block;
+	background-color: @history-entry-day-bg;
+	color: #FFF;
+	padding: 5px 10px;
+	line-height: 1;
+}
+
+.history-entry-details {
+	background-color: #FFF;
+	margin-bottom: 2px;
+	padding: 5px 10px;
+	cursor: pointer;
+
+	.history-entry-selected & {
+		background-color: @history-entry-selected-bg;
+		color: #FFF;
+	}
+}
+	.history-entry-changes {
+		.list-unstyled;
+		margin-bottom: 3px;
+	}
+		.history-entry-change {
+
+		}
+			.history-entry-change-action {
+				margin-right: 0.5em;
+			}
+
+			.history-entry-change-doc {
+				color: @history-highlight-color;
+				font-weight: bold;
+				word-break: break-all;
+				.history-entry-selected & {
+					color: #FFF;
+				}
+			}
+	.history-entry-metadata {
+
+	}	
+		.history-entry-metadata-time {
+			white-space: nowrap;
+		}
+
+		.history-entry-metadata-users {
+			display: inline;
+			padding: 0;
+		}
+			.history-entry-metadata-user {
+				display: inline;
+				&::after {
+					content: ', ';
+				}
+				&:last-of-type::after {
+					content: none;
+				}
+			}
+
+.history-file-tree-inner {
+	.full-size;
+	overflow-y: auto;
+	background-color: @file-tree-bg;
+
+	.loading {
+		color: #FFF;
+		font-size: @history-base-font-size;
+		text-align: center;
+		font-family: @font-family-serif;
+	}
+}
+
+.history-file-tree-inner when (@is-overleaf = false) {
+	font-size: 0.8rem;
+}
+
+	.history-file-entity-wrapper {
+		color: #FFF;
+		margin-left: (@line-height-computed / 2);
+	}
+		.history-file-entity-link {
+			display: block;
+			position: relative;
+			color: @file-tree-item-color;
+			line-height: @file-tree-line-height;
+			&:hover {
+				background-color: @file-tree-item-hover-bg;
+				color: @file-tree-item-color;
+				text-decoration: none;
+			}
+			&:focus {
+				color: @file-tree-item-color;
+				outline: none;
+				text-decoration: none;
+			}
+			&:hover when (@is-overleaf = true) {
+				.fake-full-width-bg(@file-tree-item-hover-bg);
+			}
+		}
+		.history-file-entity-link-selected {
+			background-color: @file-tree-item-selected-bg;
+			font-weight: bold;
+			padding-right: 32px;
+			color: #FFF;
+			.fake-full-width-bg(@file-tree-item-selected-bg);
+			&:hover {
+				background-color: @file-tree-item-hover-bg;
+			}
+		}
+			.history-file-entity-icon {
+				color: @file-tree-item-icon-color;
+				font-size: 14px;
+				margin-right: .5em;
+				.history-file-entity-link-selected & {
+					color: #FFF;
+				}
+			}
+			.history-file-entity-name {
+				display: block;
+				max-width: 100%;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+			}
+
+		.history-file-entity-link-selected when (@is-overleaf = false) {
+			color: @brand-primary;
+			&:hover,
+			&:focus {
+				color: @brand-primary;
+			}
+			.history-file-entity-icon {
+				color: @brand-primary;
+			}
+		}
+// @changesListWidth:   250px;
+// @changesListPadding: @line-height-computed / 2;
+
+// @selector-padding-vertical: 10px;
+// @selector-padding-horizontal: @line-height-computed / 2;
+// @day-header-height: 24px;
+
+// @range-bar-color: @link-color;
+// @range-bar-selected-offset: 14px;
+
+// #history {
+// 	.upgrade-prompt {
+// 		position: absolute;
+// 		top: 0;
+// 		bottom: 0;
+// 		left: 0;
+// 		right: 0;
+// 		z-index: 100;
+// 		background-color: rgba(128,128,128,0.4);
+// 		.message {
+// 			margin: auto;
+// 			margin-top: 100px;
+// 			padding: (@line-height-computed / 2) @line-height-computed;
+// 			width: 400px;
+// 			background-color: white;
+// 			border-radius: 8px;
+// 		}
+// 		.message-wider {
+// 			width: 650px;
+// 			margin-top: 60px;
+// 			padding: 0;
+// 		}
+
+// 			.message-header {
+// 				.modal-header;
+// 			}
+
+// 			.message-body {
+// 				.modal-body;
+// 			}
+// 	}
+
+// 	.diff-panel {
+// 		.full-size;
+// 		margin-right: @changesListWidth;
+// 	}
+
+// 	.diff {
+// 		.full-size;
+// 		.toolbar {
+// 			padding: 3px;
+// 			.name {
+// 				float: left;
+// 				padding: 3px @line-height-computed / 4;
+// 				display: inline-block;
+// 			}
+// 		}
+// 		.diff-editor {
+// 			.full-size;
+// 			top: 40px;
+// 		}
+// 		.hide-ace-cursor {
+// 			.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
+// 				display: none;
+// 			}
+// 		}
+// 		.diff-deleted {
+// 			padding: @line-height-computed;
+// 		}
+// 		.deleted-warning {
+// 			background-color: @brand-danger;
+// 			color: white;
+// 			padding: @line-height-computed / 2;
+// 			margin-right: @line-height-computed / 4;
+// 		}
+// 		&-binary {
+// 			.alert {
+// 				margin: @line-height-computed / 2;
+// 			}
+// 		}
+// 	}
+
+// 	aside.change-list {
+// 		border-left: 1px solid @editor-border-color;
+// 		height: 100%;
+// 		width: @changesListWidth;
+// 		position: absolute;
+// 		right: 0;
+
+// 		.loading {
+// 			text-align: center;
+// 			font-family: @font-family-serif;
+// 		}
+
+// 		ul {
+// 			li.change {
+// 				position: relative;
+// 				user-select: none;
+// 				-ms-user-select: none;
+// 				-moz-user-select: none;
+// 				-webkit-user-select: none;
+
+// 				.day {
+// 					background-color: #fafafa;
+// 					border-bottom: 1px solid @editor-border-color;
+// 					padding: 4px;
+// 					font-weight: bold;
+// 					text-align: center;
+// 					height: @day-header-height;
+// 					font-size: 14px;
+// 					line-height: 1;
+// 				}
+// 				.selectors {
+// 					input {
+// 						margin: 0;
+// 					}
+// 					position: absolute;
+// 					left: @selector-padding-horizontal;
+// 					top: 0;
+// 					bottom: 0;
+// 					width: 24px;
+// 					.selector-from {
+// 						position: absolute;
+// 						bottom: @selector-padding-vertical;
+// 						left: 0;
+// 						opacity: 0.8;
+// 					}
+// 					.selector-to {
+// 						position: absolute;
+// 						top: @selector-padding-vertical;
+// 						left: 0;
+// 						opacity: 0.8;
+// 					}
+// 					.range {
+// 						position: absolute;
+// 						left: 5px;
+// 						width: 4px;
+// 						top: 0;
+// 						bottom: 0;
+// 					}
+// 				}
+// 				.description {
+// 					padding: (@line-height-computed / 4);
+// 					padding-left: 38px;
+// 					min-height: 38px;
+// 					border-bottom: 1px solid @editor-border-color;
+// 					cursor: pointer;
+// 					&:hover {
+// 						background-color: @gray-lightest;
+// 					}
+// 				}
+// 				.users {
+// 					.user {
+// 						font-size: 0.8rem;
+// 						color: @gray;
+// 						text-transform: capitalize;
+// 						position: relative;
+// 						padding-left: 16px;
+// 						.color-square {
+// 							height: 12px;
+// 							width: 12px;
+// 							border-radius: 3px;
+// 							position: absolute;
+// 							left: 0;
+// 							bottom: 3px;
+// 						}
+// 						.name {
+// 							width: 94%;
+// 							white-space: nowrap;
+// 							overflow: hidden;
+// 							text-overflow: ellipsis;
+// 						}
+// 					}
+// 				}
+// 				.time {
+// 					float: right;
+// 					color: @gray;
+// 					display: inline-block;
+// 					padding-right: (@line-height-computed / 2);
+// 					font-size: 0.8rem;
+// 					line-height: @line-height-computed;
+// 				}
+// 				.doc {
+// 					font-size: 0.9rem;
+// 					font-weight: bold;
+// 				}
+// 				.action {
+// 					color: @gray;
+// 					text-transform: uppercase;
+// 					font-size: 0.7em;
+// 					margin-bottom: -2px;
+// 					margin-top: 2px;
+// 					&-edited {
+// 						margin-top: 0;
+// 					}
+// 				}
+// 			}
+// 			li.loading-changes, li.empty-message {
+// 				padding: 6px;
+// 				cursor: default;
+// 				&:hover {
+// 					background-color: inherit;
+// 				}
+// 			}
+// 			li.selected {
+// 				border-left: 4px solid @range-bar-color;
+// 				.day {
+// 					padding-left: 0;
+// 				}
+// 				.description {
+// 					padding-left: 34px;
+// 				}
+// 				.selectors {
+// 					left: @selector-padding-horizontal - 4px;
+// 					.range {
+// 						background-color: @range-bar-color;
+// 					}
+// 				}
+// 			}
+// 			li.selected-to {
+// 				.selectors {
+// 					.range {
+// 						top: @range-bar-selected-offset;
+// 					}
+// 					.selector-to {
+// 						opacity: 1;
+// 					}
+// 				}
+// 			}
+// 			li.selected-from {
+// 				.selectors {
+// 					.range {
+// 						bottom: @range-bar-selected-offset;
+// 					}
+// 					.selector-from {
+// 						opacity: 1;
+// 					}
+// 				}
+// 			}
+// 			li.first-in-day {
+// 				.selectors {
+// 					.selector-to {
+// 						top: @day-header-height + @selector-padding-vertical;
+// 					}
+// 				}
+// 			}
+// 			li.first-in-day.selected-to {
+// 				.selectors {
+// 					.range {
+// 						top: @day-header-height + @range-bar-selected-offset;
+// 					}
+// 				}
+// 			}
+// 		}
+// 		ul.hover-state {
+// 			li {
+// 				.selectors {
+// 					.range {
+// 						background-color: transparent;
+// 						top: 0;
+// 						bottom: 0;
+// 					}
+// 				}
+// 			}
+// 			li.hover-selected {
+// 				.selectors {
+// 					.range {
+// 						top: 0;
+// 						background-color: @gray-light;
+// 					}
+// 				}
+// 			}
+// 			li.hover-selected-to {
+// 				.selectors {
+// 					.range {
+// 						top: @range-bar-selected-offset;
+// 					}
+// 					.selector-to {
+// 						opacity: 1;
+// 					}
+// 				}
+// 			}
+// 			li.hover-selected-from {
+// 				.selectors {
+// 					.range {
+// 						bottom: @range-bar-selected-offset;
+// 					}
+// 					.selector-from {
+// 						opacity: 1;
+// 					}
+// 				}
+// 			}
+// 			li.first-in-day.hover-selected-to {
+// 				.selectors {
+// 					.range {
+// 						top: @day-header-height + @range-bar-selected-offset;
+// 					}
+// 				}
+// 			}
+// 		}
+// 	}
+// }
+
+// .diff-deleted {
+// 	padding-top: 15px;
+// }
+
+// .editor-dark {
+// 	#history {
+// 		aside.change-list {
+// 			border-color: @editor-dark-toolbar-border-color;
+			
+// 			ul li.change {
+// 				.day {
+// 					background-color: darken(@editor-dark-background-color, 10%);
+// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
+// 				}
+// 				.description {
+// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
+// 					&:hover {
+// 						background-color: black;
+// 					}
+// 				}
+// 			}
+// 		}
+// 	}
+// }

+ 13 - 8
services/web/public/stylesheets/app/editor/history.less

@@ -1,4 +1,4 @@
-@changesListWidth:   250px;
+@changesListWidth: 250px;
 @changesListPadding: @line-height-computed / 2;
 @changesListPadding: @line-height-computed / 2;
 
 
 @selector-padding-vertical: 10px;
 @selector-padding-vertical: 10px;
@@ -40,7 +40,8 @@
 			}
 			}
 	}
 	}
 
 
-	.diff-panel {
+	.diff-panel,
+	.point-in-time-panel {
 		.full-size;
 		.full-size;
 		margin-right: @changesListWidth;
 		margin-right: @changesListWidth;
 	}
 	}
@@ -49,6 +50,7 @@
 		.full-size;
 		.full-size;
 		.toolbar {
 		.toolbar {
 			padding: 3px;
 			padding: 3px;
+			height: 32px;
 			.name {
 			.name {
 				float: left;
 				float: left;
 				padding: 3px @line-height-computed / 4;
 				padding: 3px @line-height-computed / 4;
@@ -57,13 +59,9 @@
 		}
 		}
 		.diff-editor {
 		.diff-editor {
 			.full-size;
 			.full-size;
-			top: 40px;
-		}
-		.hide-ace-cursor {
-			.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
-				display: none;
-			}
+			top: 32px;
 		}
 		}
+
 		.diff-deleted {
 		.diff-deleted {
 			padding: @line-height-computed;
 			padding: @line-height-computed;
 		}
 		}
@@ -90,6 +88,7 @@
 		.loading {
 		.loading {
 			text-align: center;
 			text-align: center;
 			font-family: @font-family-serif;
 			font-family: @font-family-serif;
+			margin-top: (@line-height-computed / 2);
 		}
 		}
 
 
 		ul {
 		ul {
@@ -305,6 +304,12 @@
 	padding-top: 15px;
 	padding-top: 15px;
 }
 }
 
 
+.hide-ace-cursor {
+	.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
+		display: none;
+	}
+}
+
 .editor-dark {
 .editor-dark {
 	#history {
 	#history {
 		aside.change-list {
 		aside.change-list {

+ 90 - 1
services/web/public/stylesheets/app/editor/toolbar.less

@@ -184,8 +184,12 @@
 	}
 	}
 }
 }
 
 
+/**************************************
+	Toggle Switch
+***************************************/
+
 .toggle-wrapper {
 .toggle-wrapper {
-	width: 200px;
+	min-width: 200px;
 	height: 24px;
 	height: 24px;
 }
 }
 
 
@@ -241,3 +245,88 @@
   transform: translate(100%);
   transform: translate(100%);
   border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
   border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
 }
 }
+
+/**************************************
+	Formatting buttons
+***************************************/
+.formatting-buttons {
+	width: 100%;
+	overflow: hidden;
+}
+
+.formatting-buttons-wrapper {
+	display: flex;
+}
+
+.formatting-btn {
+	color: @formatting-btn-color;
+	background-color: @formatting-btn-bg;
+	padding: 0;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-shadow: none;
+	border: none;
+	border-left: 1px solid @formatting-btn-border;
+	border-radius: 0;
+
+	&:hover {
+		color: @formatting-btn-color;
+	}
+}
+
+.formatting-btn--icon {
+	min-width: 32px;
+	width: 32px;
+}
+
+.formatting-btn--icon:last-of-type {
+	border-right: 1px solid @formatting-btn-border;
+}
+
+.formatting-btn--more {
+	padding-left: 9px;
+	padding-right: 9px;
+
+	.caret {
+		margin-top: 1px;
+	}
+}
+
+.formatting-icon {
+	font-style: normal;
+	line-height: 1.5;
+}
+
+.formatting-icon--small {
+	font-size: small;
+	line-height: 1.9;
+}
+
+.formatting-icon--serif {
+	font-family: @font-family-serif;
+}
+
+.formatting-more {
+	margin-left: auto;
+}
+
+.formatting-menu {
+	min-width: auto;
+	max-width: 130px;
+	background-color: @formatting-menu-bg;
+}
+
+.formatting-menu-item {
+	float: left;
+}
+
+.formatting-menu-item > .formatting-btn {
+	border-right: none;
+}
+
+// Disable border on left-most icon in menu
+.formatting-menu-item:nth-of-type(4n + 1) > .formatting-btn {
+	border-left: none;
+}

+ 264 - 1
services/web/public/stylesheets/app/plans.less

@@ -32,7 +32,7 @@
 			padding-bottom: @line-height-computed * 2;
 			padding-bottom: @line-height-computed * 2;
 		}
 		}
 	}
 	}
-	
+
 	.circle {
 	.circle {
 		font-size: 1.5rem;
 		font-size: 1.5rem;
 		font-weight: 700;
 		font-weight: 700;
@@ -63,6 +63,12 @@
 	}
 	}
 
 
 	.card .btn { white-space:normal; }
 	.card .btn { white-space:normal; }
+
+	.top-switch {
+		.currency-dropdown {
+			margin-right: -15px;
+		}
+	}
 }
 }
 
 
 #changePlanSection {
 #changePlanSection {
@@ -127,4 +133,261 @@ input.paymentTypeOption.ng-valid {
 	text-align: right;
 	text-align: right;
 }
 }
 
 
+/**
+	Plans Test
+*/
+@best-val-height: 35px;
+@highlight-border: 3px;
+@highlight-color: #d3584b;
+@gray-med: #6d6d6d;
+@white-med: #fdfdfd;
+.more-details {
+	.best-value {
+		color: @red;
+		line-height: @line-height-computed;
+	}
+	blockquote {
+		footer{
+			/* accessibility fix */
+			color: @gray-med;
+		}
+	}
+	.btn-header {
+		font-family: @font-family-sans-serif;
+		margin-left: 10px;
+		margin-top: -10px;
+		text-shadow: 0 0 0;
+	}
+	.card-first, .card-last {
+		background: @white-med;
+	}
+	.card-highlighted {
+		border: @highlight-border solid @gray-lighter;
+		padding-top: 10px!important;
+		.best-value {
+			margin-bottom: 15px;
+		}
+		.card-header {
+			padding-bottom: 22px; /* align hr with other plans */
+		}
+	}
+	.card-header {
+		margin-bottom: 15px;
+	}
+	.circle {
+		/* accessibility fix */
+		span.small {
+			color: rgba(255, 255, 255, 0.85)
+		}
+	}
+	.circle-img {
+		border-radius: 50%;
+		float: right;
+		height: 100px;
+		overflow: hidden;
+		position: relative;
+		width: 100px;
+		img {
+			display: inline;
+			margin: 0 auto;
+			width: 100%;
+		}
+	}
+	.faq:last-child {
+		p {
+			margin-bottom: 0;
+		}
+	}
+	.questions-header {
+		color: @red;
+		line-height: 37px;
+		margin: 0;
+		text-align: right;
+	}
+	.tagline {
+		margin-bottom: 20px;
+	}
+	/* Media Queries */
+	@media (max-width: @screen-md-min) {
+		.card-highlighted {
+			/*override style in cards.less */
+			margin-top: @line-height-computed!important;
+		}
+		.circle-img {
+			float: left;
+			margin: 0 15px;
+		}
+	}
+	@media (min-width: @screen-md-min) {
+		blockquote {
+			margin-bottom: 0;
+		}
+		.faq {
+			.row:nth-child(2) {
+				h3 {
+					margin-top: 0;
+				}
+			}
+		}
+	}
+}
+
+.student-disclaimer {
+	font-size: 14px; /* match .paymentPageFeatures p */
+	color: @gray; /* match .paymentPageFeatures p */
+	margin: 12.5px 0 0 0;
+}
+
+/**
+	Plans Table
+*/
+.plans-table {
+	border: 1px solid @gray-lighter;
+	background-color: @white-med;
+	margin: @best-val-height 0 15px 0;
+	table-layout: fixed;
+	width: 100%;
+
+	th, td {
+		-moz-background-clip: padding;
+		-webkit-background-clip: padding;
+		background-clip: padding-box; /* needed for firefox when there is bg color */
+		border: 1px solid @gray-lighter;
+		padding: 6px;
+		text-align: center;
+		vertical-align: middle;
+	}
+
+	td {
+		font-weight: bold;
+	}
+
+	th {
+		border-top: 0;
+		font-family: @headings-font-family;
+		font-size: @font-size-h2;
+	  font-weight: @headings-font-weight;
+	  line-height: @headings-line-height;
+		padding: 18px;
+	}
+
+	th:first-child, td:first-child {
+		border-left: 0;
+	}
 
 
+	th:last-child, td:last-child {
+		border-right: 0;
+	}
+
+	td:first-child {
+		font-weight: bold;
+		padding-left: 18px;
+		text-align: left;
+	}
+
+	tr:first-child {
+		th {
+			position: relative;
+			/* keep here position here, otherwise messes up border on safari */
+		}
+	}
+
+	tr:last-child {
+		td {
+			border-bottom: 0;
+			padding: 18px;
+		}
+			/* highlighted column */
+		td:nth-child(3) {
+			position: relative;
+			/* keep here position here, otherwise messes up border on safari when there is a bg color */
+			&:before {
+				/* needed for safafi */
+				border-top: 1px solid @gray-lighter;
+				content: '';
+				left: 0;
+				position: absolute;
+				top: -1px;
+				width: 100%;
+			}
+		}
+		td:first-child {
+			border: 0;
+		}
+	}
+
+	.fa-check {
+		color: @green;
+	}
+
+	/* accessibility fixes */
+	.small {
+		color: @gray-med;
+	}
+
+	/* highlighted column */
+	td:nth-child(3), th:nth-child(3) {
+		background-color: white;
+		border-left: @highlight-border solid @gray-lighter;
+		border-right: @highlight-border solid @gray-lighter;
+	}
+	.outer {
+		left: -@highlight-border;
+		right: -@highlight-border;
+		position: absolute;
+
+		.outer-content {
+			background: white;
+			border: @highlight-border solid @gray-lighter;
+			border-radius: @border-radius-base;
+			font-size: @font-size-base;
+			font-family: @font-family-sans-serif;
+			font-weight: bold;
+			height: @best-val-height;
+			padding-top: 10px;
+		}
+	}
+	.outer.outer-top {
+		top: -@best-val-height;
+		.outer-content {
+			border-bottom-left-radius: 0;
+			border-bottom-right-radius: 0;
+			border-bottom: 0;
+		}
+	}
+	.outer.outer-btm {
+		bottom: -@best-val-height/2;
+		.outer-content {
+			border-top-left-radius: 0;
+			border-top-right-radius: 0;
+			border-top: 0;
+			height: @best-val-height/2;
+		}
+	}
+
+	/* highlight rows on hover */
+	tr:hover {
+		td {
+			background-color: @gray-lightest;
+		}
+	}
+	tr:first-child:hover {
+		background-color: transparent;
+	}
+	tr:last-child:hover {
+		background-color: transparent;
+		td {
+			background-color: transparent;
+		}
+	}
+
+	/* tooltip */
+	sup {
+		color: @red;
+		cursor: pointer;
+		margin-left: 5px;
+	}
+	.tooltip.in {
+		min-width: 200px
+	}
+}

+ 10 - 0
services/web/public/stylesheets/app/project-list.less

@@ -369,6 +369,16 @@ ul.project-list {
 		.v1-badge {
 		.v1-badge {
 			margin-left: -4px;
 			margin-left: -4px;
 		}
 		}
+
+		.action-btn-row-header, .action-btn-row {
+			padding-right: 20px;
+			text-align: right;
+		}
+
+		.action-btn {
+			padding: 0 0.3em;
+			margin-left: 0.2em;
+		}
 	}
 	}
 	i.tablesort {
 	i.tablesort {
 		padding-left: 8px;
 		padding-left: 8px;

+ 6 - 1
services/web/public/stylesheets/components/forms.less

@@ -145,10 +145,15 @@ output {
     opacity: 1; // iOS fix for unreadable disabled content
     opacity: 1; // iOS fix for unreadable disabled content
   }
   }
 
 
-  // Reset height for `textarea`s
+  // Reset height for `textarea`s, and smaller border-radius
   textarea& {
   textarea& {
     height: auto;
     height: auto;
+    border-radius: @border-radius-base;
   }
   }
+	// Smaller border-radius for `select` inputs
+	select& {
+    border-radius: @border-radius-base;
+	}
 }
 }
 
 
 
 

+ 19 - 1
services/web/public/stylesheets/core/_common-variables.less

@@ -888,6 +888,7 @@
 @footer-padding                : 2em;
 @footer-padding                : 2em;
 
 
 // Editor header
 // Editor header
+@ide-body-top-offset             : 40px;
 @toolbar-header-bg-color         : transparent;
 @toolbar-header-bg-color         : transparent;
 @toolbar-header-shadow           : 0 0 2px #ccc;
 @toolbar-header-shadow           : 0 0 2px #ccc;
 @toolbar-btn-color               : @link-color;
 @toolbar-btn-color               : @link-color;
@@ -940,6 +941,12 @@
 @toggle-switch-bg                  : @gray-lightest;
 @toggle-switch-bg                  : @gray-lightest;
 @toggle-switch-highlight-color     : @brand-primary;
 @toggle-switch-highlight-color     : @brand-primary;
 
 
+// Formatting buttons
+@formatting-btn-color             : @btn-default-color;
+@formatting-btn-bg                : @btn-default-bg;
+@formatting-btn-border            : @btn-default-border;
+@formatting-menu-bg               : @btn-default-bg;
+
 // Chat
 // Chat
 @chat-bg                          : transparent;
 @chat-bg                          : transparent;
 @chat-message-color               : @text-color;
 @chat-message-color               : @text-color;
@@ -972,4 +979,15 @@
 // System messages
 // System messages
 @sys-msg-background : @state-warning-bg;
 @sys-msg-background : @state-warning-bg;
 @sys-msg-color      : #333;
 @sys-msg-color      : #333;
-@sys-msg-border     : 1px solid @common-border-color;
+@sys-msg-border     : 1px solid @common-border-color;
+
+// v2 History
+@history-base-font-size           : @font-size-small;
+@history-base-bg                  : @gray-lightest;
+@history-entry-day-bg             : @gray;
+@history-entry-selected-bg        : @red;
+@history-base-color               : @gray-light;
+@history-highlight-color          : @gray;
+@history-toolbar-bg-color         : @toolbar-alt-bg-color;
+@history-toolbar-color            : @text-color;
+

+ 17 - 0
services/web/public/stylesheets/core/ol-variables.less

@@ -244,6 +244,12 @@
 @toggle-switch-radius-left         : @btn-border-radius-base 0 0 @btn-border-radius-base;
 @toggle-switch-radius-left         : @btn-border-radius-base 0 0 @btn-border-radius-base;
 @toggle-switch-radius-right        : 0 @btn-border-radius-base @btn-border-radius-base 0;
 @toggle-switch-radius-right        : 0 @btn-border-radius-base @btn-border-radius-base 0;
 
 
+// Formatting buttons
+@formatting-btn-color             : #FFF;
+@formatting-btn-bg                : @ol-blue-gray-5;
+@formatting-btn-border            : @ol-blue-gray-4;
+@formatting-menu-bg               : @ol-blue-gray-5;
+
 // Chat
 // Chat
 @chat-bg                          : @ol-blue-gray-5;
 @chat-bg                          : @ol-blue-gray-5;
 @chat-message-color               : #FFF;
 @chat-message-color               : #FFF;
@@ -265,6 +271,17 @@
 @log-line-no-color              : #FFF;
 @log-line-no-color              : #FFF;
 @log-hints-color                : @ol-blue-gray-4;
 @log-hints-color                : @ol-blue-gray-4;
 
 
+
+// v2 History
+@history-base-font-size          : @font-size-small;
+@history-base-bg                 : @ol-blue-gray-1;
+@history-entry-day-bg            : @ol-blue-gray-2;
+@history-entry-selected-bg       : @ol-green;
+@history-base-color              : @ol-blue-gray-2;
+@history-highlight-color         : @ol-type-color;
+@history-toolbar-bg-color        : @editor-toolbar-bg;
+@history-toolbar-color           : #FFF;
+
 // System messages
 // System messages
 @sys-msg-background             : @ol-blue;
 @sys-msg-background             : @ol-blue;
 @sys-msg-color                  : #FFF;
 @sys-msg-color                  : #FFF;

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