Sfoglia il codice sorgente

Merge branch 'master' into ns-no-duplicate-packages

Nate Stemen 8 anni fa
parent
commit
cf4d6c1165
80 ha cambiato i file con 1543 aggiunte e 639 eliminazioni
  1. 6 0
      services/web/.gitignore
  2. 4 4
      services/web/Gruntfile.coffee
  3. 7 10
      services/web/Jenkinsfile
  4. 14 7
      services/web/Makefile
  5. 14 4
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  6. 4 4
      services/web/app/coffee/Features/Editor/EditorController.coffee
  7. 2 1
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  8. 8 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  9. 3 25
      services/web/app/coffee/Features/History/HistoryController.coffee
  10. 26 0
      services/web/app/coffee/Features/History/HistoryManager.coffee
  11. 3 2
      services/web/app/coffee/Features/Project/ProjectController.coffee
  12. 2 2
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  13. 1 1
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  14. 72 51
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  15. 21 6
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  16. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee
  17. 2 2
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  18. 51 46
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  19. 1 0
      services/web/app/coffee/models/Project.coffee
  20. 5 0
      services/web/app/coffee/router.coffee
  21. 13 8
      services/web/app/views/layout.pug
  22. 11 11
      services/web/app/views/project/editor.pug
  23. 2 5
      services/web/app/views/project/editor/chat.pug
  24. 13 4
      services/web/app/views/project/editor/history.pug
  25. 8 0
      services/web/app/views/project/list.pug
  26. 1 0
      services/web/app/views/project/list/item.pug
  27. 39 21
      services/web/app/views/project/list/modals.pug
  28. 5 3
      services/web/app/views/subscriptions/plans.pug
  29. 2 2
      services/web/app/views/v1-tooltip.pug
  30. 0 0
      services/web/bin/compile_backend
  31. 5 0
      services/web/bin/compile_frontend
  32. 5 0
      services/web/bin/compile_frontend_tests
  33. 5 0
      services/web/bin/frontend_test
  34. 2 1
      services/web/config/settings.defaults.coffee
  35. 3 5
      services/web/docker-shared.template.yml
  36. 7 3
      services/web/package.json
  37. 1 1
      services/web/public/coffee/base.coffee
  38. 4 1
      services/web/public/coffee/directives/selectAll.coffee
  39. 6 1
      services/web/public/coffee/ide.coffee
  40. 14 1
      services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee
  41. 1 1
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  42. 3 0
      services/web/public/coffee/ide/history/HistoryManager.coffee
  43. 280 0
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  44. 3 9
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  45. 0 5
      services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee
  46. 0 1
      services/web/public/coffee/libraries.coffee
  47. 7 4
      services/web/public/coffee/main/account-upgrade.coffee
  48. 14 7
      services/web/public/coffee/main/contact-us.coffee
  49. 3 0
      services/web/public/coffee/main/project-list/project-list.coffee
  50. 1 1
      services/web/public/coffee/utils/underscore.coffee
  51. 0 84
      services/web/public/js/libs/groove.js
  52. 1 0
      services/web/public/stylesheets/_ol_style_includes.less
  53. 17 9
      services/web/public/stylesheets/app/editor/chat.less
  54. 12 2
      services/web/public/stylesheets/app/editor/history.less
  55. 32 19
      services/web/public/stylesheets/app/editor/pdf.less
  56. 3 3
      services/web/public/stylesheets/app/editor/toolbar.less
  57. 26 0
      services/web/public/stylesheets/app/list/v1-import-modal.less
  58. 0 1
      services/web/public/stylesheets/app/project-list.less
  59. 27 0
      services/web/public/stylesheets/app/sidebar-v2-dash-pane.less
  60. 1 1
      services/web/public/stylesheets/components/alerts.less
  61. 0 1
      services/web/public/stylesheets/components/card.less
  62. 39 19
      services/web/public/stylesheets/core/_common-variables.less
  63. 71 14
      services/web/public/stylesheets/core/ol-variables.less
  64. 5 1
      services/web/public/stylesheets/ol-style.less
  65. 3 0
      services/web/public/stylesheets/style.less
  66. 140 1
      services/web/test/acceptance/coffee/ProjectStructureTests.coffee
  67. 3 0
      services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
  68. 10 0
      services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee
  69. 13 5
      services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  70. 24 29
      services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
  71. 2 2
      services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee
  72. 48 151
      services/web/test/unit/coffee/History/HistoryControllerTests.coffee
  73. 86 0
      services/web/test/unit/coffee/History/HistoryManagerTests.coffee
  74. 4 4
      services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
  75. 6 6
      services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee
  76. 98 20
      services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee
  77. 25 0
      services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
  78. 5 3
      services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
  79. 3 3
      services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee
  80. 134 0
      services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee

+ 6 - 0
services/web/.gitignore

@@ -39,6 +39,7 @@ data/*
 app.js
 app/js/*
 test/unit/js/*
+test/unit_frontend/js/*
 test/smoke/js/*
 test/acceptance/js/*
 cookies.txt
@@ -67,6 +68,11 @@ public/minjs/
 
 Gemfile.lock
 
+public/stylesheets/ol-style.*.css
+public/stylesheets/style.*.css
+public/js/libs/require*.js
+
+
 *.swp
 .DS_Store
 

+ 4 - 4
services/web/Gruntfile.coffee

@@ -205,12 +205,12 @@ module.exports = (grunt) ->
 					modules: [
 						{
 							name: "main",
-							exclude: ["libs"]
+							exclude: ["libraries"]
 						}, {
 							name: "ide",
-							exclude: ["libs", "pdfjs-dist/build/pdf"]
-						}, {
-							name: "libs"
+							exclude: ["pdfjs-dist/build/pdf", "libraries"]
+						},{
+							name: "libraries"
 						},{
 							name: "ace/mode-latex"
 						},{

+ 7 - 10
services/web/Jenkinsfile

@@ -60,7 +60,7 @@ pipeline {
         sh 'git config --global core.logallrefupdates false'
         sh 'mv app/views/external/robots.txt public/robots.txt'
         sh 'mv app/views/external/googlebdb0f8f7f4a17241.html public/googlebdb0f8f7f4a17241.html'
-        sh 'npm install'
+        sh 'npm --quiet install'
         sh 'npm rebuild'
         // It's too easy to end up shrinkwrapping to an outdated version of translations.
         // Ensure translations are always latest, regardless of shrinkwrap
@@ -71,16 +71,9 @@ pipeline {
       }
     }
     
-    stage('Unit Tests') {
+    stage('Test') {
       steps {
-        sh 'make clean install' // Removes js files, so do before compile
-        sh 'make test_unit MOCHA_ARGS="--reporter=tap"'
-      }
-    }
-    
-    stage('Acceptance Tests') {
-      steps {
-        sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"'
+        sh 'make ci'
       }
     }
 
@@ -155,6 +148,10 @@ pipeline {
   }
   
   post {
+    always {
+      sh 'make ci_clean'
+    }
+
     failure {
       mail(from: "${EMAIL_ALERT_FROM}", 
            to: "${EMAIL_ALERT_TO}", 

+ 14 - 7
services/web/Makefile

@@ -16,9 +16,10 @@ add_dev: docker-shared.yml
 	$(NPM) install --save-dev ${P}
 
 install: docker-shared.yml
+	bin/generate_volumes_file
 	$(NPM) install
 
-clean:
+clean: ci_clean
 	rm -f app.js
 	rm -rf app/js
 	rm -rf test/unit/js
@@ -30,9 +31,8 @@ clean:
 		rm -rf $$dir/test/unit/js; \
 		rm -rf $$dir/test/acceptance/js; \
 	done
-	# Regenerate docker-shared.yml - not stictly a 'clean',
-	# but lets `make clean install` work nicely
-	bin/generate_volumes_file
+
+ci_clean:
 	# Deletes node_modules volume
 	docker-compose down --volumes
 
@@ -40,11 +40,14 @@ clean:
 docker-shared.yml:
 	bin/generate_volumes_file
 
-test: test_unit test_acceptance
+test: test_unit test_frontend test_acceptance
 
 test_unit: docker-shared.yml
 	docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:unit -- ${MOCHA_ARGS}
 
+test_frontend: docker-shared.yml
+	docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:frontend -- ${MOCHA_ARGS}
+
 test_acceptance: test_acceptance_app test_acceptance_modules
 
 test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service
@@ -71,7 +74,11 @@ test_acceptance_modules: docker-shared.yml
 test_acceptance_module: docker-shared.yml
 	cd $(MODULE) && make test_acceptance
 
+ci:
+	MOCHA_ARGS="--reporter tap" \
+	$(MAKE) install test
+
 .PHONY:
-	all add install update test test_unit test_acceptance \
+	all add install update test test_unit test_frontend test_acceptance \
 	test_acceptance_start_service test_acceptance_stop_service \
-	test_acceptance_run
+	test_acceptance_run ci ci_clean

+ 14 - 4
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -205,10 +205,10 @@ module.exports = DocumentUpdaterHandler =
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 	updateProjectStructure : (project_id, userId, changes, callback = (error) ->)->
-		return callback() if !settings.apis.project_history?.enabled
+		return callback() if !settings.apis.project_history?.sendProjectStructureOps
 
-		docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', changes.oldDocs, changes.newDocs)
-		fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', changes.oldFiles, changes.newFiles)
+		docUpdates = DocumentUpdaterHandler._getUpdates('doc', changes.oldDocs, changes.newDocs)
+		fileUpdates = DocumentUpdaterHandler._getUpdates('file', changes.oldFiles, changes.newFiles)
 
 		timer = new metrics.Timer("set-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}"
@@ -230,7 +230,7 @@ module.exports = DocumentUpdaterHandler =
 				logger.error {project_id, url}, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
-	_getRenameUpdates: (entityType, oldEntities, newEntities) ->
+	_getUpdates: (entityType, oldEntities, newEntities) ->
 		oldEntities ||= []
 		newEntities ||= []
 		updates = []
@@ -255,6 +255,16 @@ module.exports = DocumentUpdaterHandler =
 					pathname: oldEntity.path
 					newPathname: newEntity.path
 
+		for id, oldEntity of oldEntitiesHash
+			newEntity = newEntitiesHash[id]
+
+			if !newEntity?
+				# entity deleted
+				updates.push
+					id: id
+					pathname: oldEntity.path
+					newPathname: ''
+
 		updates
 
 PENDINGUPDATESKEY = "PendingUpdates"

+ 4 - 4
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -105,19 +105,19 @@ module.exports = EditorController =
 			async.series jobs, (err)->
 				callback err, newFolders, lastFolder
 
-	deleteEntity : (project_id, entity_id, entityType, source, callback)->
+	deleteEntity : (project_id, entity_id, entityType, source, userId, callback)->
 		LockManager.getLock project_id, (err)->
 			if err?
 				logger.err err:err, project_id:project_id, "could not get lock to deleteEntity"
 				return callback(err)
-			EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, (err)->
+			EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, userId, (err)->
 				LockManager.releaseLock project_id, ()->
 					callback(err)
 
-	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)->
+	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, userId, callback)->
 		logger.log {project_id, entity_id, entityType, source}, "start delete process of entity"
 		Metrics.inc "editor.delete-entity"
-		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)->
+		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, userId, (err)->
 			if err?
 				logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity"
 				return callback(err)

+ 2 - 1
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -147,6 +147,7 @@ module.exports = EditorHttpController =
 		project_id  = req.params.Project_id
 		entity_id   = req.params.entity_id
 		entity_type = req.params.entity_type
-		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", user_id, (error) ->
 			return next(error) if error?
 			res.sendStatus 204

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

@@ -26,8 +26,16 @@ InvalidNameError = (message) ->
 	return error
 InvalidNameError.prototype.__proto__ = Error.prototype
 
+UnsupportedFileTypeError = (message) ->
+	error = new Error(message)
+	error.name = "UnsupportedFileTypeError"
+	error.__proto__ = UnsupportedFileTypeError.prototype
+	return error
+UnsupportedFileTypeError.prototype.__proto___ = Error.prototype
+
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
 	TooManyRequestsError: TooManyRequestsError
 	InvalidNameError: InvalidNameError
+	UnsupportedFileTypeError: UnsupportedFileTypeError

+ 3 - 25
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -5,35 +5,13 @@ AuthenticationController = require "../Authentication/AuthenticationController"
 ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
 
 module.exports = HistoryController =
-	initializeProject: (callback = (error, history_id) ->) ->
-		return callback() if !settings.apis.project_history?.enabled
-		request.post {
-			url: "#{settings.apis.project_history.url}/project"
-		}, (error, res, body)->
-			return callback(error) if error?
-
-			if res.statusCode >= 200 and res.statusCode < 300
-				try
-					project = JSON.parse(body)
-				catch error
-					return callback(error)
-
-				overleaf_id = project?.project?.id
-				if !overleaf_id
-					error = new Error("project-history did not provide an id", project)
-					return callback(error)
-
-				callback null, { overleaf_id }
-			else
-				error = new Error("project-history returned a non-success status code: #{res.statusCode}")
-				callback error
-
 	selectHistoryApi: (req, res, next = (error) ->) ->
 		project_id = req.params?.Project_id
 		# find out which type of history service this project uses
 		ProjectDetailsHandler.getDetails project_id, (err, project) ->
 			return next(err) if err?
-			if project?.overleaf?.history?.display
+			history = project.overleaf?.history
+			if history?.id? and history?.display
 				req.useProjectHistory = true
 			else
 				req.useProjectHistory = false
@@ -58,7 +36,7 @@ module.exports = HistoryController =
 	buildHistoryServiceUrl: (useProjectHistory) ->
 		# choose a history service, either document-level (trackchanges)
 		# or project-level (project_history)
-		if settings.apis.project_history?.enabled && useProjectHistory
+		if useProjectHistory
 			return settings.apis.project_history.url
 		else
 			return settings.apis.trackchanges.url

+ 26 - 0
services/web/app/coffee/Features/History/HistoryManager.coffee

@@ -0,0 +1,26 @@
+request = require "request"
+settings = require "settings-sharelatex"
+
+module.exports = HistoryManager =
+	initializeProject: (callback = (error, history_id) ->) ->
+		return callback() if !settings.apis.project_history?.initializeHistoryForNewProjects
+		request.post {
+			url: "#{settings.apis.project_history.url}/project"
+		}, (error, res, body)->
+			return callback(error) if error?
+
+			if res.statusCode >= 200 and res.statusCode < 300
+				try
+					project = JSON.parse(body)
+				catch error
+					return callback(error)
+
+				overleaf_id = project?.project?.id
+				if !overleaf_id
+					error = new Error("project-history did not provide an id", project)
+					return callback(error)
+
+				callback null, { overleaf_id }
+			else
+				error = new Error("project-history returned a non-success status code: #{res.statusCode}")
+				callback error

+ 3 - 2
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -216,7 +216,7 @@ module.exports = ProjectController =
 			project: (cb)->
 				ProjectGetter.getProject(
 					project_id,
-					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1 },
+					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, 'overleaf.history.display': 1 },
 					cb
 				)
 			user: (cb)->
@@ -253,7 +253,7 @@ module.exports = ProjectController =
 				# Extract data from user's ObjectId
 				timestamp = parseInt(user_id.toString().substring(0, 8), 16)
 
-				rolloutPercentage = 40 # Percentage of users to roll out to
+				rolloutPercentage = 60 # Percentage of users to roll out to
 				if !ProjectController._isInPercentageRollout('autocompile', user_id, rolloutPercentage)
 					# Don't show if user is not part of roll out
 					return cb(null, { enabled: false, showOnboarding: false })
@@ -351,6 +351,7 @@ module.exports = ProjectController =
 					themes: THEME_LIST
 					maxDocLength: Settings.max_doc_length
 					showLinkSharingOnboarding: !!results.couldShowLinkSharingOnboarding
+					useV2History: !!project.overleaf?.history?.display
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 2 - 2
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -7,7 +7,7 @@ Project = require('../../models/Project').Project
 Folder = require('../../models/Folder').Folder
 ProjectEntityHandler = require('./ProjectEntityHandler')
 ProjectDetailsHandler = require('./ProjectDetailsHandler')
-HistoryController = require('../History/HistoryController')
+HistoryManager = require('../History/HistoryManager')
 User = require('../../models/User').User
 fs = require('fs')
 Path = require "path"
@@ -27,7 +27,7 @@ module.exports = ProjectCreationHandler =
 			if projectHistoryId?
 				ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, callback
 			else
-				HistoryController.initializeProject (error, history) ->
+				HistoryManager.initializeProject (error, history) ->
 					return callback(error) if error?
 					ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, callback
 

+ 1 - 1
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -21,7 +21,7 @@ module.exports = ProjectDuplicator =
 				if !doc?._id?
 					return callback()
 				content = docContents[doc._id.toString()]
-				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)->
+				projectEntityHandler.addDoc newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)->
 					if err?
 						logger.err err:err, "error copying doc"
 						return callback(err)

+ 72 - 51
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -149,14 +149,35 @@ module.exports = ProjectEntityHandler =
 		else
 			DocstoreManager.getDoc project_id, doc_id, options, callback
 
-	addDoc: (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
-		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
+	addDoc: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+		ProjectEntityHandler.addDocWithoutUpdatingHistory project_or_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path) ->
+			return callback(error) if error?
+			newDocs = [
+				doc: doc
+				path: path
+				docLines: docLines.join('\n')
+			]
+			project_id = project_or_id._id or project_or_id
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) ->
+				return callback(error) if error?
+				callback null, doc, folder_id
+
+	addDocWithoutUpdatingHistory: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+		# This method should never be called directly, except when importing a project
+		# from Overleaf. It skips sending updates to the project history, which will break
+		# the history unless you are making sure it is updated in some other way.
+		getProject = (cb) ->
+			if project_or_id._id? # project
+				return cb(null, project_or_id)
+			else # id
+				return ProjectGetter.getProjectWithOnlyFolders project_or_id, cb
+		getProject (error, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add doc"
 				return callback(err)
-			ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, userId, callback
+			ProjectEntityHandler._addDocWithProject project, folder_id, docName, docLines, userId, callback
 
-	addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+	_addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id, path) ->)=>
 		project_id = project._id
 		logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project"
 		confirmFolder project, folder_id, (folder_id)=>
@@ -176,14 +197,7 @@ module.exports = ProjectEntityHandler =
 						rev:          0
 					}, (err) ->
 						return callback(err) if err?
-						newDocs = [
-							doc: doc
-							path: result?.path?.fileSystem
-							docLines: docLines.join('\n')
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) ->
-							return callback(error) if error?
-							callback null, doc, folder_id
+						callback(null, doc, folder_id, result?.path?.fileSystem)
 
 	restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
 		# getDoc will return the deleted doc's lines, but we don't actually remove
@@ -192,37 +206,37 @@ module.exports = ProjectEntityHandler =
 			return callback(error) if error?
 			ProjectEntityHandler.addDoc project_id, null, name, lines, callback
 
-	addFile: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)->
+	addFileWithoutUpdatingHistory: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id, path, fileStoreUrl) ->)->
 		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add file"
 				return callback(err)
-			ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, userId, callback
-
-	addFileWithProject: (project, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)->
-		project_id = project._id
-		logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
-		return callback(err) if err?
-		confirmFolder project, folder_id, (folder_id)->
-			fileRef = new File name : fileName
-			FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)->
-				if err?
-					logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
-					return callback(err)
-				ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+			logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
+			return callback(err) if err?
+			confirmFolder project, folder_id, (folder_id)->
+				fileRef = new File name : fileName
+				FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)->
 					if err?
-						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project"
+						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
 						return callback(err)
-					tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) ->
-						return callback(err) if err?
-						newFiles = [
-							file: fileRef
-							path: result?.path?.fileSystem
-							url: fileStoreUrl
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
-							return callback(error) if error?
-							callback null, fileRef, folder_id
+					ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+						if err?
+							logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project"
+							return callback(err)
+						tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) ->
+							return callback(err) if err?
+							callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl)
+
+	addFile:  (project_id, folder_id, fileName, fsPath, userId, callback = (error, fileRef, folder_id) ->)->
+		ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, fsPath, userId, (error, fileRef, folder_id, path, fileStoreUrl) ->
+			newFiles = [
+				file: fileRef
+				path: path
+				url: fileStoreUrl
+			]
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
+				return callback(error) if error?
+				callback null, fileRef, folder_id
 
 	replaceFile: (project_id, file_id, fsPath, userId, callback)->
 		self = ProjectEntityHandler
@@ -412,7 +426,7 @@ module.exports = ProjectEntityHandler =
 				callback()
 
 
-	deleteEntity: (project_id, entity_id, entityType, callback = (error) ->)->
+	deleteEntity: (project_id, entity_id, entityType, userId, callback = (error) ->)->
 		self = @
 		logger.log entity_id:entity_id, entityType:entityType, project_id:project_id, "deleting project entity"
 		if !entityType?
@@ -423,7 +437,7 @@ module.exports = ProjectEntityHandler =
 			return callback(error) if error?
 			projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=>
 				return callback(error) if error?
-				ProjectEntityHandler._cleanUpEntity project, entity, entityType, (error) ->
+				ProjectEntityHandler._cleanUpEntity project, entity, entityType, path.fileSystem, userId, (error) ->
 					return callback(error) if error?
 					tpdsUpdateSender.deleteEntity project_id:project_id, path:path.fileSystem, project_name:project.name, (error) ->
 						return callback(error) if error?
@@ -456,17 +470,17 @@ module.exports = ProjectEntityHandler =
 							return callback(error) if error?
 							DocumentUpdaterHandler.updateProjectStructure project_id, userId, {oldDocs, newDocs, oldFiles, newFiles}, callback
 
-	_cleanUpEntity: (project, entity, entityType, callback = (error) ->) ->
+	_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
 		if(entityType.indexOf("file") != -1)
-			ProjectEntityHandler._cleanUpFile project, entity, callback
+			ProjectEntityHandler._cleanUpFile project, entity, path, userId, callback
 		else if (entityType.indexOf("doc") != -1)
-			ProjectEntityHandler._cleanUpDoc project, entity, callback
+			ProjectEntityHandler._cleanUpDoc project, entity, path, userId, callback
 		else if (entityType.indexOf("folder") != -1)
-			ProjectEntityHandler._cleanUpFolder project, entity, callback
+			ProjectEntityHandler._cleanUpFolder project, entity, path, userId, callback
 		else
 			callback()
 
-	_cleanUpDoc: (project, doc, callback = (error) ->) ->
+	_cleanUpDoc: (project, doc, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
 		doc_id = doc._id.toString()
 		unsetRootDocIfRequired = (callback) =>
@@ -483,26 +497,33 @@ module.exports = ProjectEntityHandler =
 					return callback(error) if error?
 					DocstoreManager.deleteDoc project_id, doc_id, (error) ->
 						return callback(error) if error?
-						callback()
+						changes = oldDocs: [ {doc, path} ]
+						DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
 
-	_cleanUpFile: (project, file, callback = (error) ->) ->
+	_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
 		file_id = file._id.toString()
-		FileStoreHandler.deleteFile project_id, file_id, callback
+		FileStoreHandler.deleteFile project_id, file_id, (error) ->
+			return callback(error) if error?
+			changes = oldFiles: [ {file, path} ]
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
 
-	_cleanUpFolder: (project, folder, callback = (error) ->) ->
+	_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
 		jobs = []
 		for doc in folder.docs
 			do (doc) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, callback
+				docPath = path.join(folderPath, doc.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, docPath, userId, callback
 
 		for file in folder.fileRefs
 			do (file) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, callback
+				filePath = path.join(folderPath, file.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, filePath, userId, callback
 
 		for childFolder in folder.folders
 			do (childFolder) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, callback
+				folderPath = path.join(folderPath, childFolder.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, folderPath, userId, callback
 
 		async.series jobs, callback
 

+ 21 - 6
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -62,6 +62,9 @@ module.exports = SubscriptionUpdater =
 			invited_emails: email
 		}, callback
 
+	refreshSubscription: (user_id, callback=(err)->) ->
+		SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
+
 	deleteSubscription: (subscription_id, callback = (error) ->) ->
 		SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
 			return callback(err) if err?
@@ -106,17 +109,29 @@ module.exports = SubscriptionUpdater =
 				SubscriptionLocator.getUsersSubscription user_id, cb
 			groupSubscription: (cb)->
 				SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb
+			v1PlanCode: (cb) ->
+				Modules = require '../../infrastructure/Modules'
+				Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) ->
+					cb(err, results?[0] || null)
 		async.series jobs, (err, results)->
 			if err?
-				logger.err err:err, user_id:user, "error getting subscription or group for _setUsersMinimumFeatures"
+				logger.err err:err, user_id:user_id,
+					"error getting subscription or group for _setUsersMinimumFeatures"
 				return callback(err)
-			{subscription, groupSubscription} = results
-			if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
-				logger.log user_id:user_id, "using users subscription plan code for features"
-				UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
-			else if groupSubscription? and groupSubscription.planCode?
+			{subscription, groupSubscription, v1PlanCode} = results
+			# Group Subscription
+			if groupSubscription? and groupSubscription.planCode?
 				logger.log user_id:user_id, "using group which user is memor of for features"
 				UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback
+			# Personal Subscription
+			else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
+				logger.log user_id:user_id, "using users subscription plan code for features"
+				UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
+			# V1 Subscription
+			else if v1PlanCode?
+				logger.log user_id: user_id, "using the V1 plan for features"
+				UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback
+			# Default
 			else
 				logger.log user_id:user_id, "using default features for user with no subscription or group"
 				UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)->

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee

@@ -47,7 +47,7 @@ module.exports =
 				logger.log user_id:user_id, filePath:path, projectName:projectName, project_id:project._id, "project found for delete update, path is root so marking project as deleted"
 				return projectDeleter.markAsDeletedByExternalSource project._id, callback
 			else
-				updateMerger.deleteUpdate project._id, path, source, (err)->
+				updateMerger.deleteUpdate user_id, project._id, path, source, (err)->
 					callback(err)
 
 

+ 2 - 2
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -32,13 +32,13 @@ module.exports =
 					else
 						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 
-	deleteUpdate: (project_id, path, source, callback)->
+	deleteUpdate: (user_id, project_id, path, source, callback)->
 		projectLocator.findElementByPath project_id, path, (err, element, type)->
 			if  err? || !element?
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				return callback()
 			logger.log project_id:project_id, path:path, type:type, element:element, "processing update to delete entity from tpds"
-			editorController.deleteEntity project_id, element._id, type, source, (err)->
+			editorController.deleteEntity project_id, element._id, type, source, user_id, (err)->
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				callback()
 

+ 51 - 46
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -12,7 +12,7 @@ Modules = require "./Modules"
 Url = require "url"
 PackageVersions = require "./PackageVersions"
 htmlEncoder = new require("node-html-encoder").Encoder("numerical")
-fingerprints = {}
+hashedFiles = {}
 Path = require 'path'
 Features = require "./Features"
 
@@ -30,43 +30,42 @@ getFileContent = (filePath)->
 	filePath = Path.join __dirname, "../../../", "public#{filePath}"
 	exists = fs.existsSync filePath
 	if exists
-		content = fs.readFileSync filePath
+		content = fs.readFileSync filePath, "UTF-8"
 		return content
 	else
-		logger.log filePath:filePath, "file does not exist for fingerprints"
+		logger.log filePath:filePath, "file does not exist for hashing"
 		return ""
 
-logger.log "Generating file fingerprints..."
 pathList = [
-	["#{jsPath}libs/#{fineuploader}.js"]
-	["#{jsPath}libs/require.js"]
-	["#{jsPath}ide.js"]
-	["#{jsPath}main.js"]
-	["#{jsPath}libs.js"]
-	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"]
-	["#{jsPath}libs/#{pdfjs}/pdf.js"]
-	["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
-	["#{jsPath}libs/#{pdfjs}/compatibility.js"]
-	["/stylesheets/style.css"]
-	["/stylesheets/ol-style.css"]
+	"#{jsPath}libs/require.js"
+	"#{jsPath}ide.js"
+	"#{jsPath}main.js"
+	"#{jsPath}libraries.js"
+	"/stylesheets/style.css"
+	"/stylesheets/ol-style.css"
 ]
 
-for paths in pathList
-	contentList = _.map(paths, getFileContent)
-	content = contentList.join("")
-	hash = crypto.createHash("md5").update(content).digest("hex")
-	_.each paths, (filePath)-> 
-		logger.log "#{filePath}: #{hash}"
-		fingerprints[filePath] = hash
-
-getFingerprint = (path) ->
-	if fingerprints[path]?
-		return fingerprints[path]
-	else
-		logger.err "No fingerprint for file: #{path}"
-		return ""
+if !Settings.useMinifiedJs 
+	logger.log "not using minified JS, not hashing static files"
+else
+	logger.log "Generating file hashes..."
+	for path in pathList
+		content = getFileContent(path)
+		hash = crypto.createHash("md5").update(content).digest("hex")
+		
+		splitPath = path.split("/")
+		filenameSplit = splitPath.pop().split(".")
+		filenameSplit.splice(filenameSplit.length-1, 0, hash)
+		splitPath.push(filenameSplit.join("."))
+
+		hashPath = splitPath.join("/")
+		hashedFiles[path] = hashPath
 
-logger.log "Finished generating file fingerprints"
+		fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}"
+		fs.writeFileSync(fsHashPath, content)
+
+
+		logger.log "Finished hashing static content"
 
 cdnAvailable = Settings.cdn?.web?.host?
 darkCdnAvailable = Settings.cdn?.web?.darkHost?
@@ -120,29 +119,35 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
 		res.locals.lib = PackageVersions.lib
 
+
+
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 
-			doFingerPrint = opts.fingerprint != false
+			if opts.hashedPath && hashedFiles[path]?
+				path = hashedFiles[path]
 
 			if !opts.qs?
 				opts.qs = {}
 
-			if !opts.qs?.fingerprint? and doFingerPrint
-				opts.qs.fingerprint = getFingerprint(path)
-
 			if opts.cdn != false
 				path = Url.resolve(staticFilesBase, path)
 
 			qs = querystring.stringify(opts.qs)
 
+			if opts.removeExtension == true
+				path = path.slice(0,-3)
+				
 			if qs? and qs.length > 0
 				path = path + "?" + qs
 			return path
 
-		res.locals.buildCssPath = (cssFile)->
+		res.locals.buildCssPath = (cssFile, opts)->
 			path = Path.join("/stylesheets/", cssFile)
-			return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
+			if opts?.hashedPath && hashedFiles[path]?
+				hashedPath = hashedFiles[path]
+				return Url.resolve(staticFilesBase, hashedPath)
+			return Url.resolve(staticFilesBase, path)
 
 		res.locals.buildImgPath = (imgFile)->
 			path = Path.join("/img/", imgFile)
@@ -227,10 +232,6 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			return req.query?[field]
 		next()
 
-	webRouter.use (req, res, next)->
-		res.locals.fingerprint = getFingerprint
-		next()
-
 	webRouter.use (req, res, next)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()
@@ -296,10 +297,14 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 	webRouter.use (req, res, next) ->
 		isOl = (Settings.brandPrefix == 'ol-')
 		res.locals.uiConfig = 
-			defaultResizerSizeOpen   : if isOl then 2 else 24
-			defaultResizerSizeClosed : if isOl then 2 else 24
-			eastResizerCursor        : if isOl then "ew-resize" else null
-			westResizerCursor        : if isOl then "ew-resize" else null
-			chatResizerSizeOpen      : if isOl then 2 else 12
-			chatResizerSizeClosed    : 0
+			defaultResizerSizeOpen     : if isOl then 2 else 24
+			defaultResizerSizeClosed   : if isOl then 2 else 24
+			eastResizerCursor          : if isOl then "ew-resize" else null
+			westResizerCursor          : if isOl then "ew-resize" else null
+			chatResizerSizeOpen        : if isOl then 2 else 12
+			chatResizerSizeClosed      : 0
+			chatMessageBorderSaturation: if isOl then "85%" else "70%"
+			chatMessageBorderLightness : if isOl then "40%" else "70%"
+			chatMessageBgSaturation    : if isOl then "85%" else "60%"
+			chatMessageBgLightness     : if isOl then "40%" else "97%"
 		next()

+ 1 - 0
services/web/app/coffee/models/Project.coffee

@@ -56,6 +56,7 @@ ProjectSchema = new Schema
 		read_token      : { type: String }
 		history         :
 			id            : { type: Number }
+			display       : { type: Boolean }
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	if project_or_id._id?

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

@@ -197,6 +197,7 @@ module.exports = class Router
 
 		webRouter.get  "/project/:Project_id/updates", 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.proxyToHistoryApi
 		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
@@ -324,6 +325,10 @@ module.exports = class Router
 				headers: req.headers
 			})
 
+		webRouter.get "/no-cache", (req, res, next)->
+			res.header("Cache-Control", "max-age=0")
+			res.sendStatus(404)
+
 		webRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error"))
 		webRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error")
 		webRouter.get '/oops-mongo', (req, res, next) ->

+ 13 - 8
services/web/app/views/layout.pug

@@ -23,7 +23,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		link(rel="apple-touch-icon-precomposed", href="/" + settings.brandPrefix + "apple-touch-icon-precomposed.png")
 		link(rel="mask-icon", href="/" + settings.brandPrefix + "mask-favicon.svg", color="#a93529")
 			
-		link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css"))
+		link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css", {hashedPath:true}))
 
 		block _headLinks
 
@@ -57,7 +57,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			
-		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
+		script(src=buildJsPath("libs/jquery-1.11.1.min.js"))
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
 			var cdnBlocked = typeof jQuery === 'undefined'
@@ -68,7 +68,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 
 		block scripts
 
-		script(src=buildJsPath("libs/angular-1.6.4.min.js", {fingerprint:false}))
+		script(src=buildJsPath("libs/angular-1.6.4.min.js"))
 
 		script.
 			window.sharelatex = {
@@ -95,7 +95,11 @@ html(itemscope, itemtype='http://schema.org/Product')
 					cdnDomain : '!{settings.templates.cdnDomain}',
 					indexName : '!{settings.templates.indexName}'
 				}
-				
+
+		- if (settings.overleaf && settings.overleaf.useOLFreeTrial)
+			script.
+				window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial'
+
 	body
 		if(settings.recaptcha)
 			script(src="https://www.google.com/recaptcha/api.js?render=explicit")
@@ -142,9 +146,10 @@ html(itemscope, itemtype='http://schema.org/Product')
 				window.requirejs = {
 					"paths" : {
 						"moment": "libs/#{lib('moment')}",
-						"fineuploader": "libs/#{lib('fineuploader')}"
+						"fineuploader": "libs/#{lib('fineuploader')}",
+						"main": "#{buildJsPath('main.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
+						"libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
 					},
-					"urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
 					"config":{
 						"moment":{
 							"noGlobal": true
@@ -152,9 +157,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 					}
 				};
 			script(
-				data-main=buildJsPath('main.js', {fingerprint:false}),
+				data-main=buildJsPath('main.js', {hashedPath:false}),
 				baseurl=fullJsPath,
-				src=buildJsPath('libs/require.js')
+				src=buildJsPath('libs/require.js', {hashedPath:true})
 			)
 
 		include contact-us-modal

+ 11 - 11
services/web/app/views/project/editor.pug

@@ -98,14 +98,14 @@ block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
 	//- don't use cdn for workers
-	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
-	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
-	- var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false,fingerprint:false})
+	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false})
+	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false})
+	- var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false})
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- and doesn't prematurely end the script tag.
 	script#data(type="application/json").
-		!{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState}).replace(/\//g, '\\/')}
+		!{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState, useV2History: useV2History}).replace(/\//g, '\\/')}
 
 	script(type="text/javascript").
 		window.data = JSON.parse($("#data").text());
@@ -126,14 +126,15 @@ block requirejs
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}",
+				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}",
 				"moment": "libs/#{lib('moment')}",
 				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
 				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",
 				"ace": "#{lib('ace')}",
-				"fineuploader": "libs/#{lib('fineuploader')}"
+				"fineuploader": "libs/#{lib('fineuploader')}",
+				"ide": "#{buildJsPath('ide.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
+				"libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
 			},
-			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"waitSeconds": 0,
 			"shim": {
 				"pdfjs-dist/build/pdf": {
@@ -155,14 +156,13 @@ block requirejs
 				}
 			}
 		};
-		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
 		window.aceWorkerPath = "#{aceWorkerPath}";
 		window.pdfCMapsPath = "#{pdfCMapsPath}"
 		window.uiConfig = JSON.parse('!{JSON.stringify(uiConfig).replace(/\//g, "\\/")}');
 
 	script(
-		data-main=buildJsPath("ide.js", {fingerprint:false}),
+		data-main=buildJsPath("ide.js", {hashedPath:false}),
 		baseurl=fullJsPath,
-		data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}),
-		src=buildJsPath('libs/require.js')
+		data-ace-base=buildJsPath(lib('ace')),
+		src=buildJsPath('libs/require.js', {hashedPath:true})
 	)

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

@@ -35,12 +35,9 @@ aside.chat(
 							span(ng-if="message.user.first_name") {{ message.user.first_name }}
 							span(ng-if="!message.user.first_name") {{ message.user.email }}
 						.message(
-							ng-style="{\
-								'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)',\
-								'background-color': 'hsl({{ hue(message.user) }}, 60%, 97%)'\
-							}"
+							ng-style="getMessageStyle(message.user);"
 						)
-							.arrow(ng-style="{'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)'}")
+							.arrow(ng-style="getArrowStyle(message.user)")
 							.message-content
 								p(
 									mathjax,

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

@@ -134,8 +134,17 @@ div#history(ng-show="ui.view == 'history'")
 
 					div.description(ng-click="select()")
 						div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
-						div.docs(ng-repeat="(doc_id, doc) in update.docs")
-							span.doc {{ doc.entity.name }}
+						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.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%)'}")
@@ -165,8 +174,8 @@ div#history(ng-show="ui.view == 'history'")
 							'other': 'changes'\
 						}"
 					)
-					|  in <strong>{{history.diff.doc.name}}</strong>
-				.toolbar-right
+					|  in <strong>{{history.diff.pathname}}</strong>
+				.toolbar-right(ng-if="!history.isV2")
 					a.btn.btn-danger.btn-sm(
 						href,
 						ng-click="openRestoreDiffModal()"

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

@@ -63,6 +63,14 @@ block content
 					aside.project-list-sidebar.col-md-2.col-xs-3
 						include ./list/side-bar
 
+					if isShowingV1Projects && settings.overleaf && settings.overleaf.host
+						.project-list-sidebar-v2-pane.col-md-2.col-xs-3
+							span Welcome to the Overleaf v2 alpha! #[a(href="https://www.overleaf.com/help/v2") Find out more].
+							span To tag or rename your v1 projects, please go back to Overleaf v1.
+							a.project-list-sidebar-v1-link(
+								href=settings.overleaf.host + "/dash?prefer-v1-dash=1"
+							) Go back to v1
+
 					.project-list-main.col-md-10.col-xs-9
 						include ./list/notifications
 						include ./list/project-list

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

@@ -2,6 +2,7 @@
 	input.select-item(
 		select-individual,
 		type="checkbox",
+		ng-disabled="shouldDisableCheckbox(project)",
 		ng-model="project.selected"
 		stop-propagation="click"
 		aria-label=translate('select_project') + " '{{ project.name }}'"

+ 39 - 21
services/web/app/views/project/list/modals.pug

@@ -317,38 +317,56 @@ script(type="text/ng-template", id="userProfileModalTemplate")
 script(type="text/ng-template", id="v1ImportModalTemplate")
 	.modal-header
 		button.close(ng-click="dismiss()") &times;
-		h3 #{translate("import_project_to_v2")}
+		h3 Move Project to Overleaf v2
 
 	.modal-body.v1-import-wrapper
 		.v1-import-step-1(ng-show="step === 1")
-			img.v1-import-img(
-				src="/img/v1-import/v2-editor.png"
-				alt="The new V2 Editor."
-			)
-			h2.v1-import-title Try importing your project to V2!
-			p Some exciting copy about the new features:
-			ul
-				li Some stuff
-				li Some more stuff
-				li Yet more stuff
+			.v1-import-row
+				.v1-import-col
+					img.v1-import-img(
+						src="/img/v1-import/v2-editor.png"
+						alt="The new V2 Editor."
+					)
+				.v1-import-col
+					h2.v1-import-title Try the Overleaf v2 Editor
+					p The Overleaf v2 editor has many great new features including:
+					ul
+						li Faster real-time collaboration
+						li See your coauthors’ cursors
+						li Chat with math support
+						li Tracked changes and commenting
+						li Improved LaTeX autocomplete
+						li Two-way Dropbox sync
+			p.v1-import-cta Would you like to move #[strong {{project.name}}] into Overleaf v2?
 
 		.v1-import-step-2(ng-show="step === 2")
-			div.v1-import-warning(aria-label="Warning symbol.")
-				i.fa.fa-exclamation-triangle
-			h2.v1-import-title #[strong Warning:] Overleaf V2 is in beta
-			p Once importing your project you will lose access to the some of the features of Overleaf V1. This includes the git bridge, journal integrations, WYSIWYG and linked files. We’re working on bringing these features to V2!
-			p Once you have imported a project to V2 you #[strong cannot go back to V1].
-			p Are you sure you want to import to V2?
+			.v1-import-row
+				.v1-import-warning.v1-import-col(aria-label="Warning symbol.")
+					i.fa.fa-exclamation-triangle
+				.v1-import-col
+					h2.v1-import-title #[strong Warning:] Overleaf v2 is Experimental
+					p We are still working hard to bring some Overleaf v1 features to the v2 editor. If you move this project to v2 now, you will:
+					ul
+						li Lose access your project via git
+						li Not be able to use the Journals and Services menu to submit directly to our partners
+						li Not be able to use the Rich Text (WYSIWYG) mode
+						li Not be able to use linked files (to URLs or to files in other Overleaf projects)
+						li Not be able to use some bibliography integrations (Zotero, CiteULike)
+						li Lose access to your labelled versions and not be able to create new labelled versions
+			.v1-import-cta
+				p
+					strong Please note: you cannot move this project back to v1 once you have moved it to v2. If this is an important project, please consider making a clone in v1 before you move the project to v2.
+				p Are you sure you want to move #[strong {{project.name}}] into Overleaf v2?
 
 	.modal-footer.v1-import-footer
 		div(ng-show="step === 1")
 			if settings.overleaf && settings.overleaf.host
 				a.btn.btn-primary.v1-import-btn(
 					ng-href=settings.overleaf.host + "/{{project.id}}"
-				) #{translate("open_in_v1")}
+				) No thanks, open in v1
 			button.btn.btn-primary.v1-import-btn(
 				ng-click="moveToConfirmation()"
-			) #{translate("import_to_v2")}
+			) Yes, move project to v2
 		div(ng-show="step === 2")
 			form(
 				async-form="v1Import",
@@ -363,9 +381,9 @@ script(type="text/ng-template", id="v1ImportModalTemplate")
 					a.btn.btn-primary.v1-import-btn(
 						ng-href=settings.overleaf.host + "/{{project.id}}"
 						ng-class="{disabled: v1ImportForm.inflight || v1ImportForm.success}"
-					) #{translate("never_mind_open_in_v1")}
+					) No thanks, open in v1
 				input.btn.btn-primary.v1-import-btn(
 					type="submit",
-					value=translate('yes_im_sure')
+					value="Yes, move project to v2"
 					ng-disabled="v1ImportForm.inflight || v1ImportForm.success"
 				)

+ 5 - 3
services/web/app/views/subscriptions/plans.pug

@@ -210,7 +210,7 @@ block content
 							h3 #{translate("group_plan_enquiry")}
 						.modal-body
 							form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak)
-								span(ng-show="sent == false")
+								span(ng-show="sent == false && error == false")
 									.form-group
 										label#title9(for='Field9')
 											| Name
@@ -228,11 +228,13 @@ block content
 									.form-group
 										input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
 									.form-group
-										input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'ShareLaTeX for Universities';")
+										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")
+								span(ng-show="sent == true && error == false")
 									p Request Sent, Thank you.
+								span(ng-show="error")
+									p Error sending request.
 
 				.row
 					.col-md-12

+ 2 - 2
services/web/app/views/v1-tooltip.pug

@@ -1,6 +1,6 @@
 script(type="text/ng-template", id="v1ProjectTooltipTemplate")
-	span This project is from Overleaf version 1 and has not been imported to the beta yet
+	span This project is from Overleaf v1 and has not been imported to v2 yet.
 
 
 script(type="text/ng-template", id="v1TagTooltipTemplate")
-	span This folder is from Overleaf version 1 and has not been imported to the beta yet
+	span This folder/tag is from Overleaf v1. Please go back to v1 to manage.

+ 0 - 0
services/web/bin/compile_app → services/web/bin/compile_backend


+ 5 - 0
services/web/bin/compile_frontend

@@ -0,0 +1,5 @@
+#!/bin/bash
+set -e;
+COFFEE=node_modules/.bin/coffee
+echo Compiling public/coffee;
+$COFFEE -o public/js -c public/coffee;

+ 5 - 0
services/web/bin/compile_frontend_tests

@@ -0,0 +1,5 @@
+#!/bin/bash
+set -e;
+COFFEE=node_modules/.bin/coffee
+echo Compiling test/unit_frontend/coffee;
+$COFFEE -o test/unit_frontend/js -c test/unit_frontend/coffee;

+ 5 - 0
services/web/bin/frontend_test

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

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

@@ -111,7 +111,8 @@ module.exports = settings =
 		trackchanges:
 			url : "http://localhost:3015"
 		project_history:
-			enabled: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
+			sendProjectStructureOps: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
+			initializeHistoryForNewProjects: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
 			url : "http://localhost:3054"
 		docstore:
 			url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016"

+ 3 - 5
services/web/docker-shared.template.yml

@@ -13,17 +13,15 @@ services:
       - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json
       - node_modules:/app/node_modules
       - ./bin:/app/bin
-      # Copying the whole public dir is fine for now, and needed for
-      # some unit tests to pass, but we will want to isolate the coffee
-      # and vendor js files, so that the compiled js files are not written
-      # back to the local filesystem.
-      - ./public:/app/public
+      - ./public/coffee:/app/public/coffee:ro
+      - ./public/js/ace-1.2.5:/app/public/js/ace-1.2.5
       - ./app.coffee:/app/app.coffee:ro
       - ./app/coffee:/app/app/coffee:ro
       - ./app/templates:/app/app/templates:ro
       - ./app/views:/app/app/views:ro
       - ./config:/app/config
       - ./test/unit/coffee:/app/test/unit/coffee:ro
+      - ./test/unit_frontend/coffee:/app/test/unit_frontend/coffee:ro
       - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro
       - ./test/acceptance/files:/app/test/acceptance/files:ro
       - ./test/smoke/coffee:/app/test/smoke/coffee:ro

+ 7 - 3
services/web/package.json

@@ -14,11 +14,15 @@
     "test:acceptance:run": "bin/acceptance_test $@",
     "test:acceptance:dir": "npm -q run compile:acceptance_tests && npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@",
     "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js",
-    "test:unit": "npm -q run compile:app && npm -q run compile:unit_tests && bin/unit_test $@",
+    "test:unit": "npm -q run compile:backend && npm -q run compile:unit_tests && bin/unit_test $@",
+    "test:frontend": "npm -q run compile:frontend && npm -q run compile:frontend_tests && bin/frontend_test $@",
     "compile:unit_tests": "bin/compile_unit_tests",
+    "compile:frontend_tests": "bin/compile_frontend_tests",
     "compile:acceptance_tests": "bin/compile_acceptance_tests",
-    "compile:app": "bin/compile_app",
-    "start": "npm -q run compile:app && node app.js"
+    "compile:frontend": "bin/compile_frontend",
+    "compile:backend": "bin/compile_backend",
+    "compile": "npm -q run compile:backend && npm -q run compile:frontend",
+    "start": "npm -q run compile && node app.js"
   },
   "dependencies": {
     "archiver": "0.9.0",

+ 1 - 1
services/web/public/coffee/base.coffee

@@ -1,5 +1,5 @@
 define [
-	"libs"
+	"libraries"
 	"modules/recursionHelper"
 	"modules/errorCatcher"
 	"modules/localStorage"

+ 4 - 1
services/web/public/coffee/directives/selectAll.coffee

@@ -49,18 +49,21 @@ define [
 						selectAllListController.clearSelectAllState()
 
 				scope.$on "select-all:select", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = true
 					ignoreChanges = false
 
 				scope.$on "select-all:deselect", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = false
 					ignoreChanges = false
 
 				scope.$on "select-all:row-clicked", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = !scope.ngModel
@@ -75,4 +78,4 @@ define [
 			link: (scope, element, attrs) ->
 				element.on "click", (e) ->
 					scope.$broadcast "select-all:row-clicked"
-		}
+		}

+ 6 - 1
services/web/public/coffee/ide.coffee

@@ -5,6 +5,7 @@ define [
 	"ide/editor/EditorManager"
 	"ide/online-users/OnlineUsersManager"
 	"ide/history/HistoryManager"
+	"ide/history/HistoryV2Manager"
 	"ide/permissions/PermissionsManager"
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
@@ -44,6 +45,7 @@ define [
 	EditorManager
 	OnlineUsersManager
 	HistoryManager
+	HistoryV2Manager
 	PermissionsManager
 	PdfManager
 	BinaryFilesManager
@@ -137,7 +139,10 @@ define [
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
 		ide.editorManager = new EditorManager(ide, $scope)
 		ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
-		ide.historyManager = new HistoryManager(ide, $scope)
+		if window.data.useV2History
+			ide.historyManager = new HistoryV2Manager(ide, $scope)
+		else
+			ide.historyManager = new HistoryManager(ide, $scope)
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)

+ 14 - 1
services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee

@@ -3,9 +3,22 @@ define [
 	"ide/colors/ColorManager"
 ], (App, ColorManager) ->
 	App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
-		$scope.hue = (user) ->
+		hslColorConfigs =
+			borderSaturation: window.uiConfig?.chatMessageBorderSaturation or "70%"
+			borderLightness : window.uiConfig?.chatMessageBorderLightness or "70%"
+			bgSaturation    : window.uiConfig?.chatMessageBgSaturation or "60%"
+			bgLightness     : window.uiConfig?.chatMessageBgLightness or "97%"
+
+		hue = (user) ->
 			if !user?
 				return 0
 			else
 				return ColorManager.getHueForUserId(user.id)
+
+		$scope.getMessageStyle = (user) ->
+			"border-color"     : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })"
+			"background-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.bgSaturation }, #{ hslColorConfigs.bgLightness })"
+
+		$scope.getArrowStyle = (user) ->
+			"border-color"     : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })"
 	]

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

@@ -33,7 +33,7 @@ define [
 	if !ace.config._moduleUrl?
 		ace.config._moduleUrl = ace.config.moduleUrl
 		ace.config.moduleUrl = (args...) ->
-			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
+			url = ace.config._moduleUrl(args...)
 			return url
 
 	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, metadata, graphics, preamble, files, $http, $q) ->

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

@@ -100,6 +100,7 @@ define [
 				end_ts:   end_ts
 				doc:      doc
 				error:    false
+				pathname: doc.name
 			}
 
 			if !doc.deleted
@@ -190,8 +191,10 @@ define [
 			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
 
 			for update in updates
+				update.pathnames = [] # Used for display
 				for doc_id, doc of update.docs or {}
 					doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true)
+					update.pathnames.push doc.entity.name
 
 				for user in update.meta.users or []
 					if user?

+ 280 - 0
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -0,0 +1,280 @@
+define [
+	"moment"
+	"ide/colors/ColorManager"
+	"ide/history/controllers/HistoryListController"
+	"ide/history/controllers/HistoryDiffController"
+	"ide/history/directives/infiniteScroll"
+], (moment, ColorManager) ->
+	class HistoryManager
+		constructor: (@ide, @$scope) ->
+			@reset()
+
+			@$scope.toggleHistory = () =>
+				if @$scope.ui.view == "history"
+					@hide()
+				else
+					@show()
+
+			@$scope.$watch "history.selection.updates", (updates) =>
+				if updates? and updates.length > 0
+					@_selectDocFromUpdates()
+					@reloadDiff()
+
+			@$scope.$on "entity:selected", (event, entity) =>
+				if (@$scope.ui.view == "history") and (entity.type == "doc")
+					@$scope.history.selection.pathname = _ide.fileTreeManager.getEntityPath(entity)
+					@reloadDiff()
+
+		show: () ->
+			@$scope.ui.view = "history"
+			@reset()
+
+		hide: () ->
+			@$scope.ui.view = "editor"
+			# Make sure we run the 'open' logic for whatever is currently selected
+			@$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity()
+
+		reset: () ->
+			@$scope.history = {
+				isV2: true
+				updates: []
+				nextBeforeTimestamp: null
+				atEnd: false
+				selection: {
+					updates: []
+					pathname: null
+					range: {
+						fromV: null
+						toV: null
+					}
+				}
+				diff: null
+			}
+
+		MAX_RECENT_UPDATES_TO_SELECT: 2
+		autoSelectRecentUpdates: () ->
+			return if @$scope.history.updates.length == 0
+
+			@$scope.history.updates[0].selectedTo = true
+
+			indexOfLastUpdateNotByMe = 0
+			for update, i in @$scope.history.updates
+				if @_updateContainsUserId(update, @$scope.user.id) or i > @MAX_RECENT_UPDATES_TO_SELECT
+					break
+				indexOfLastUpdateNotByMe = i
+
+			@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
+
+		BATCH_SIZE: 10
+		fetchNextBatchOfUpdates: () ->
+			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
+			if @$scope.history.nextBeforeTimestamp?
+				url += "&before=#{@$scope.history.nextBeforeTimestamp}"
+			@$scope.history.loading = true
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					{ data } = response
+					@_loadUpdates(data.updates)
+					@$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
+					if !data.nextBeforeTimestamp?
+						@$scope.history.atEnd = true
+					@$scope.history.loading = false
+
+		reloadDiff: () ->
+			diff = @$scope.history.diff
+			{updates} = @$scope.history.selection
+			{fromV, toV, pathname} = @_calculateDiffDataFromSelection()
+
+			if !pathname?
+				@$scope.history.diff = null
+				return
+
+			return if diff? and
+				diff.pathname == pathname and
+				diff.fromV    == fromV and
+				diff.toV      == toV
+
+			@$scope.history.diff = diff = {
+				fromV:    fromV
+				toV:      toV
+				pathname: pathname
+				error:    false
+			}
+
+			diff.loading = true
+			url = "/project/#{@$scope.project_id}/diff"
+			query = ["pathname=#{encodeURIComponent(pathname)}"]
+			if diff.fromV? and diff.toV?
+				query.push "from=#{diff.fromV}", "to=#{diff.toV}"
+			url += "?" + query.join("&")
+
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					{ data } = response
+					diff.loading = false
+					{text, highlights} = @_parseDiff(data)
+					diff.text = text
+					diff.highlights = highlights
+				.catch () ->
+					diff.loading = false
+					diff.error = true
+
+		_parseDiff: (diff) ->
+			row    = 0
+			column = 0
+			highlights = []
+			text   = ""
+			for entry, i in diff.diff or []
+				content = entry.u or entry.i or entry.d
+				content ||= ""
+				text += content
+				lines   = content.split("\n")
+				startRow    = row
+				startColumn = column
+				if lines.length > 1
+					endRow    = startRow + lines.length - 1
+					endColumn = lines[lines.length - 1].length
+				else
+					endRow    = startRow
+					endColumn = startColumn + lines[0].length
+				row    = endRow
+				column = endColumn
+
+				range = {
+					start:
+						row: startRow
+						column: startColumn
+					end:
+						row: endRow
+						column: endColumn
+				}
+
+				if entry.i? or entry.d?
+					if entry.meta.user?
+						name = "#{entry.meta.user.first_name} #{entry.meta.user.last_name}"
+					else
+						name = "Anonymous"
+					if entry.meta.user?.id == @$scope.user.id
+						name = "you"
+					date = moment(entry.meta.end_ts).format("Do MMM YYYY, h:mm a")
+					if entry.i?
+						highlights.push {
+							label: "Added by #{name} on #{date}"
+							highlight: range
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
+						}
+					else if entry.d?
+						highlights.push {
+							label: "Deleted by #{name} on #{date}"
+							strikeThrough: range
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
+						}
+
+			return {text, highlights}
+
+		_loadUpdates: (updates = []) ->
+			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
+
+			for update in updates or []
+				for user in update.meta.users or []
+					if user?
+						user.hue = ColorManager.getHueForUserId(user.id)
+
+				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
+					update.meta.first_in_day = true
+
+				update.selectedFrom = false
+				update.selectedTo = false
+				update.inSelection = false
+
+				previousUpdate = update
+
+			firstLoad = @$scope.history.updates.length == 0
+
+			@$scope.history.updates =
+				@$scope.history.updates.concat(updates)
+
+			@autoSelectRecentUpdates() if firstLoad
+
+		_perDocSummaryOfUpdates: (updates) ->
+			# Track current_pathname -> original_pathname
+			original_pathnames = {}
+
+			# Map of original pathname -> doc summary
+			docs_summary = {}
+
+			updatePathnameWithUpdateVersions = (pathname, update) ->
+				# docs_summary is indexed by the original pathname the doc
+				# had at the start, so we have to look this up from the current
+				# pathname via original_pathname first
+				if !original_pathnames[pathname]?
+						original_pathnames[pathname] = pathname
+				original_pathname = original_pathnames[pathname]
+				doc_summary = docs_summary[original_pathname] ?= {
+					fromV: update.fromV, toV: update.toV,
+				}
+				doc_summary.fromV = Math.min(
+					doc_summary.fromV,
+					update.fromV
+				)
+				doc_summary.toV = Math.max(
+					doc_summary.toV,
+					update.toV
+				)
+
+			# Put updates in ascending chronological order
+			updates = updates.slice().reverse()
+			for update in updates
+				for pathname in update.pathnames or []
+					updatePathnameWithUpdateVersions(pathname, update)
+				for project_op in update.project_ops or []
+					if project_op.rename?
+						rename = project_op.rename
+						updatePathnameWithUpdateVersions(rename.pathname, update)
+						original_pathnames[rename.newPathname] = original_pathnames[rename.pathname]
+						delete original_pathnames[rename.pathname]
+					if project_op.add?
+						add = project_op.add
+						updatePathnameWithUpdateVersions(add.pathname, update)
+
+			return docs_summary
+
+		_calculateDiffDataFromSelection: () ->
+			fromV = toV = pathname = null
+
+			selected_pathname = @$scope.history.selection.pathname
+
+			for pathname, doc of @_perDocSummaryOfUpdates(@$scope.history.selection.updates)
+				if pathname == selected_pathname
+					{fromV, toV} = doc
+					return {fromV, toV, pathname}
+
+			return {}
+
+		# Set the track changes selected doc to one of the docs in the range
+		# of currently selected updates. If we already have a selected doc
+		# then prefer this one if present.
+		_selectDocFromUpdates: () ->
+			affected_docs = @_perDocSummaryOfUpdates(@$scope.history.selection.updates)
+
+			selected_pathname = @$scope.history.selection.pathname
+			if selected_pathname? and affected_docs[selected_pathname]
+				# Selected doc is already open
+			else
+				# Set to first possible candidate
+				for pathname, doc of affected_docs
+					selected_pathname = pathname
+					break
+
+			@$scope.history.selection.pathname = selected_pathname
+			if selected_pathname?
+				entity = @ide.fileTreeManager.findEntityByPath(selected_pathname)
+				if entity?
+					@ide.fileTreeManager.selectEntity(entity)
+
+		_updateContainsUserId: (update, user_id) ->
+			for user in update.meta.users
+				return true if user?.id == user_id
+			return false

+ 3 - 9
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -90,7 +90,9 @@ define [
 				# to block auto compiles. It also causes problems where server-provided
 				# linting errors aren't cleared after typing
 				if (ide.$scope.settings.syntaxValidation and !ide.$scope.hasLintingError)
-					$scope.recompile(isAutoCompileOnChange: true)
+					$scope.recompile(isAutoCompileOnChange: true) # compile if no linting errors
+				else if !ide.$scope.settings.syntaxValidation
+					$scope.recompile(isAutoCompileOnChange: true) # always recompile
 			else
 				# Extend remainder of timeout
 				autoCompileTimeout = setTimeout () ->
@@ -533,14 +535,6 @@ define [
 		else
 			$scope.switchToSideBySideLayout()
 
-		$scope.startFreeTrial = (source) ->
-			ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
-
-			event_tracking.sendMB "subscription-start-trial", { source }
-
-			window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}")
-			$scope.startedFreeTrial = true
-
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
 		# enable per-user containers by default
 		perUserCompile = true

+ 0 - 5
services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee

@@ -4,8 +4,3 @@ define [
 	App.controller "TrackChangesUpgradeModalController", ($scope, $modalInstance) ->
 		$scope.cancel = () ->
 			$modalInstance.dismiss()
-			
-		$scope.startFreeTrial = (source) ->
-			ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
-			$scope.startedFreeTrial = true

+ 0 - 1
services/web/public/coffee/libs.coffee → services/web/public/coffee/libraries.coffee

@@ -9,7 +9,6 @@ define [
 	"libs/angular-cookie"
 	"libs/passfield"
 	"libs/sixpack"
-	"libs/groove"
 	"libs/angular-sixpack"
 	"libs/ng-tags-input-3.0.0"
 ], () ->

+ 7 - 4
services/web/public/coffee/main/account-upgrade.coffee

@@ -11,9 +11,12 @@ define [
 			w = window.open()
 			go = () ->
 				ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-				url = "/user/subscription/new?planCode=#{plan}&ssp=true"
-				if couponCode?
-					url = "#{url}&cc=#{couponCode}"
+				if window.redirectToOLFreeTrialUrl?
+					url = window.redirectToOLFreeTrialUrl
+				else
+					url = "/user/subscription/new?planCode=#{plan}&ssp=true"
+					if couponCode?
+						url = "#{url}&cc=#{couponCode}"
 				$scope.startedFreeTrial = true
 
 				switch source
@@ -27,7 +30,7 @@ define [
 
 					else
 						event_tracking.sendMB "subscription-start-trial", { source, plan }
-				
+
 				w.location = url
 
 			if $scope.shouldABTestPlans

+ 14 - 7
services/web/public/coffee/main/contact-us.coffee

@@ -74,27 +74,34 @@ define [
 			$modalInstance.close()
 
 
-	App.controller 'UniverstiesContactController', ($scope, $modal) ->
+	App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
 
 		$scope.form = {}
 		$scope.sent = false
 		$scope.sending = false
+		$scope.error = false
 		$scope.contactUs = ->
 			if !$scope.form.email?
 				console.log "email not set"
 				return
 			$scope.sending = true
 			ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
-			params =
+			data =
+				_csrf : window.csrfToken
 				name: $scope.form.name || $scope.form.email
 				email: $scope.form.email
 				labels: "#{$scope.form.source} accounts"
 				message: "Please contact me with more details"
-				subject: $scope.form.subject + " - [#{ticketNumber}]"
-				about : "#{$scope.form.position || ''} #{$scope.form.university || ''}"
+				subject: "#{$scope.form.name} - General Enquiry - #{$scope.form.position} - #{$scope.form.university}"
+				inbox: "accounts"
 
-			Groove.createTicket params, (err, json)->
-				$scope.sent = true
+			request = $http.post "/support", data
+			
+			request.catch ()->
+				$scope.error = true
 				$scope.$apply()
 
-
+			request.then (response)->
+				$scope.sent = true
+				$scope.error = (response.status != 200)
+				$scope.$apply()

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

@@ -462,6 +462,9 @@ define [
 
 	App.controller "ProjectListItemController", ($scope) ->
 
+		$scope.shouldDisableCheckbox = (project) ->
+			$scope.filter == 'archived' && project.accessLevel != 'owner'
+
 		$scope.projectLink = (project) ->
 			if project.accessLevel == 'readAndWrite' and project.source == 'token'
 				"/#{project.tokens.readAndWrite}"

+ 1 - 1
services/web/public/coffee/utils/underscore.coffee

@@ -1,5 +1,5 @@
 define [
-	"libs"
+	"libraries"
 ], () ->
 	angular.module('underscore', []).factory '_', ->
 		return window._

+ 0 - 84
services/web/public/js/libs/groove.js

@@ -1,84 +0,0 @@
-!function(window) {
-
-  window.Groove = {
-
-    init: function(options) {
-      this._options = options;
-      if (typeof grooveOnReady != 'undefined') {grooveOnReady();}
-    },
-
-    createTicket: function(params, callback) {
-      var postData = serialize({
-        "ticket[enduser_name]": params["name"],
-        "ticket[enduser_email]": params["email"],
-        "ticket[title]": params["subject"],
-        "ticket[enduser_about]": params["about"],
-        "ticket[label_string]": params["labels"],
-        "ticket[comments_attributes][0][body]": params["message"]
-      });
-
-      sendRequest(this._options.widget_ticket_url, function(req) {
-        if (callback) {callback(req);}
-      }, postData);
-    }
-  };
-
-  // http://www.quirksmode.org/js/xmlhttp.html
-  function sendRequest(url, callback, postData) {
-    var req = createXMLHTTPObject();
-    if (!req) return;
-    var method = (postData) ? "POST" : "GET";
-    req.open(method, url, true);
-    if (postData){
-      try {
-        req.setRequestHeader('Content-type','application/x-www-form-urlencoded');
-      }
-      catch(e) {
-        req.contentType = 'application/x-www-form-urlencoded';
-      };
-    };
-    req.onreadystatechange = function () {
-      if (req.readyState != 4) return;
-      callback(req);
-    }
-    if (req.readyState == 4) return;
-    req.send(postData);
-  }
-
-  var XMLHttpFactories = [
-    function () {return new XDomainRequest()},
-    function () {return new XMLHttpRequest()},
-    function () {return new ActiveXObject("Msxml2.XMLHTTP")},
-    function () {return new ActiveXObject("Msxml3.XMLHTTP")},
-    function () {return new ActiveXObject("Microsoft.XMLHTTP")}
-  ];
-
-  function createXMLHTTPObject() {
-    var xmlhttp = false;
-    for (var i = 0; i < XMLHttpFactories.length; i++) {
-      try {
-        xmlhttp = XMLHttpFactories[i]();
-      }
-      catch (e) {
-        continue;
-      }
-      break;
-    }
-    return xmlhttp;
-  }
-
-  function serialize(obj) {
-    var str = [];
-    for(var p in obj) {
-      if (obj[p]) {
-        str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
-      }
-    }
-    return str.join("&");
-}
-
-if (typeof grooveOnLoad != 'undefined') {grooveOnLoad();}
-}(window);
-
-Groove.init({"widget_ticket_url":"https://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket.json"});
-

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

@@ -0,0 +1 @@
+@import "app/sidebar-v2-dash-pane.less";

+ 17 - 9
services/web/public/stylesheets/app/editor/chat.less

@@ -31,13 +31,18 @@
 		right: 0;
 		bottom: @new-message-height;
 		overflow-x: hidden;
+		background-color: @chat-bg;
+
 		li.message {
 			margin: @line-height-computed / 2;
 			.date {
 				font-size: 12px;
-				color: @gray-light;
-				border-bottom: 1px solid @gray-lightest;
+				color: @chat-message-date-color;
 				margin-bottom: @line-height-computed / 2;
+				text-align: right;
+			}
+			.date when (@is-overleaf = false) {
+				border-bottom: 1px solid @gray-lightest;
 				text-align: center;
 			}
 			.avatar {
@@ -56,20 +61,22 @@
 
 				.name {
 					font-size: 12px;
-					color: @gray-light;
+					color: @chat-message-name-color;
 					margin-bottom: 4px;
 					min-height: 16px;
 				}
 				.message {
 					border-left: 3px solid transparent;
 					font-size: 14px;
-					box-shadow: -1px 2px 3px #ddd;
-					border-raduis: @border-radius-base;
+					box-shadow: @chat-message-box-shadow;
+					border-radius: @chat-message-border-radius;
 					position: relative;
 					
 					.message-content {
-						padding: @line-height-computed / 2;
+						padding: @chat-message-padding;
 						overflow-x: auto;
+						color: @chat-message-color;
+						font-weight: @chat-message-weight;
 					}
 
 					.arrow {
@@ -124,7 +131,7 @@
 		.full-size;
 		top: auto;
 		height: @new-message-height;
-		background-color: @gray-lightest;
+		background-color: @chat-new-message-bg;
 		padding: @line-height-computed / 4;
 		border-top: 1px solid @editor-border-color;
 		textarea {
@@ -134,9 +141,10 @@
 			border: 1px solid @editor-border-color;
 			height: 100%;
 			width: 100%;
-			color: @gray-dark;
+			color: @chat-new-message-textarea-color;
 			font-size: 14px;
 			padding: @line-height-computed / 4;
+			background-color: @chat-new-message-textarea-bg;
 		}
 	}
 }
@@ -145,7 +153,7 @@
 	word-break: break-all;
 }
 
-.editor-dark {
+.editor-dark when (@is-overleaf = false) {
 	.chat {
 		.new-message {
 			background-color: lighten(@editor-dark-background-color, 10%);

+ 12 - 2
services/web/public/stylesheets/app/editor/history.less

@@ -169,9 +169,19 @@
 					font-size: 0.8rem;
 					line-height: @line-height-computed;
 				}
-				.docs {
-					font-weight: bold;
+				.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 {

+ 32 - 19
services/web/public/stylesheets/app/editor/pdf.less

@@ -10,9 +10,13 @@
 	padding: 0 (@line-height-computed / 2);
 }
 
+.pdf {
+	background-color: @pdf-bg;
+}
+
 .pdf-viewer, .pdf-logs, .pdf-errors, .pdf-uncompiled {
 	.full-size;
-	top: 58px;
+	top: @pdf-top-offset;
 }
 
 .pdf-logs, .pdf-errors, .pdf-uncompiled, .pdf-validation-problems{
@@ -69,11 +73,11 @@
 	}
 	.pdfjs-viewer {
 		.full-size;
-		background-color: @gray-lighter;
+		background-color: @pdfjs-bg;
 		overflow: scroll;
 		canvas, div.pdf-canvas {
 			background: white;
-			box-shadow: black 0px 0px 10px;
+			box-shadow: @pdf-page-shadow-color 0px 0px 10px;
 		}
 		div.pdf-canvas.pdfng-empty {
 			background-color: white;
@@ -179,7 +183,7 @@
 		cursor: pointer;
 		.line-no {
 			float: right;
-			color: @gray;
+			color: @log-line-no-color;
 			font-weight: 700;
 
 			.fa {
@@ -203,16 +207,25 @@
 			}
 		}
 
-		&.alert-danger:hover {
-			background-color: darken(@alert-danger-bg, 5%);
+		&.alert-danger {
+			background-color: tint(@alert-danger-bg, 15%);
+			&:hover {
+				background-color: @alert-danger-bg;
+			}
 		}
 
-		&.alert-warning:hover {
-			background-color: darken(@alert-warning-bg, 5%);
+		&.alert-warning {
+			background-color: tint(@alert-warning-bg, 15%);
+			&:hover {
+				background-color: @alert-warning-bg;
+			}
 		}
 
-		&.alert-info:hover {
-			background-color: darken(@alert-info-bg, 5%);
+		&.alert-info {
+			background-color: tint(@alert-info-bg, 15%);
+			&:hover {
+				background-color: @alert-info-bg;
+			}
 		}
 
 	}
@@ -354,22 +367,22 @@
 		}
 
 		.alert-danger & {
-			color: @alert-danger-border;
+			color: @state-danger-border;
 		}
 
 		.alert-warning & {
-			color: @alert-warning-border;
+			color: @state-warning-border;
 		}
 
 		.alert-info & {
-			color: @alert-info-border;
+			color: @state-info-border;
 		}
 
 	}
 
 	&-text, 
 	&-feedback-label {
-		color: @gray-dark;
+		color: @log-hints-color;
 		font-size: 0.9rem;
 		margin-bottom: 20px;
 	}
@@ -394,25 +407,25 @@
 	&-actions a,
 	&-text a {
 		.alert-danger & {
-			color: @alert-danger-text;
+			color: @state-danger-text;
 		}
 
 		.alert-warning & {
-			color: @alert-warning-text;
+			color: @state-warning-text;
 		}
 
 		.alert-info & {
-			color: @alert-info-text;
+			color: @state-info-text;
 		}
 	}
 
 	&-feedback {
-		color: @gray-dark;
+		color: @log-hints-color;
 		float: right;
 	}
 
 	&-extra-feedback {
-		color: @gray-dark;
+		color: @log-hints-color;
 		font-size: 0.8rem;
 		margin-top: 10px;
 		padding-bottom: 5px;

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

@@ -129,11 +129,11 @@
 }
 
 .toolbar-small-mixin() {
-	height: 32px;
+	height: @toolbar-small-height;
 }
 
 .toolbar-tall-mixin() {
-	height: 58px;
+	height: @toolbar-tall-height;
 	padding-top: 10px;
 }
 .toolbar-alt-mixin() {
@@ -143,7 +143,7 @@
 .toolbar-label {
 	display: none;
 	margin: 0 4px;
-	font-size: 12px;
+	font-size: @toolbar-font-size;
 	font-weight: 600;
 	margin-bottom: 2px;
 	vertical-align: middle;

+ 26 - 0
services/web/public/stylesheets/app/list/v1-import-modal.less

@@ -2,8 +2,34 @@
 	text-align: center;
 }
 
+.v1-import-row {
+	display: flex;
+	align-items: center;
+}
+
+.v1-import-col {
+	flex-basis: 50%;
+	flex-grow: 0;
+	flex-shrink: 0;
+	padding-left: 15px;
+	padding-right: 15px;
+}
+
+	.v1-import-col ul {
+		margin-bottom: 0;
+	}
+
 .v1-import-img {
 	width: 100%;
+	margin-top: 30px;
+}
+
+.v1-import-cta {
+	margin-top: 20px;
+	margin-left: auto;
+	margin-right: auto;
+	width: 90%;
+	text-align: center;
 }
 
 .v1-import-warning {

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

@@ -57,7 +57,6 @@
 		} 
 
 		.project-list-sidebar when (@is-overleaf) {
-			height: 100%;
 			overflow-x: hidden;
 			overflow-y: auto;
 			-ms-overflow-style: -ms-autohiding-scrollbar;

+ 27 - 0
services/web/public/stylesheets/app/sidebar-v2-dash-pane.less

@@ -0,0 +1,27 @@
+@v2-dash-pane-link-height: 130px;
+
+.project-list-sidebar {
+	height: calc(~"100% -" @v2-dash-pane-link-height);
+}
+
+.project-list-sidebar-v2-pane {
+	position: absolute;
+	bottom: 0;
+	height: @v2-dash-pane-link-height;
+	background-color: @v2-dash-pane-bg;
+	text-align: center;
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	color: white;
+	font-size: 13px;
+}
+
+	.project-list-sidebar-v2-pane a {
+		color: @v2-dash-pane-link-color;
+		text-decoration: underline;
+	}
+
+	.project-list-sidebar-v2-pane a:hover {
+		text-decoration: none;
+	}

+ 1 - 1
services/web/public/stylesheets/components/alerts.less

@@ -10,7 +10,7 @@
   padding: @alert-padding;
   margin-bottom: @line-height-computed;
   border-left: 3px solid transparent;
-  // border-radius: @alert-border-radius;
+  border-radius: @alert-border-radius;
 
   // Headings for larger alerts
   h4 {

+ 0 - 1
services/web/public/stylesheets/components/card.less

@@ -1,7 +1,6 @@
 .card {
   background-color: white;
   border-radius: @border-radius-base;
-  -webkit-box-shadow: @card-box-shadow;
   box-shadow: @card-box-shadow;
   padding: @line-height-computed;
   .page-header {

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

@@ -20,14 +20,9 @@
 //== Typography
 //
 //## Font, line-height, and color for body text, headings, and more.
-
-@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);
-//@import url(https://fonts.googleapis.com/css?family=PT+Serif:400,600,700);
-//@import url(https://fonts.googleapis.com/css?family=PT+Serif:400,400i,700,700i);
-@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i);
-
 @font-family-sans-serif:  "Open Sans", sans-serif;
 @font-family-serif:       "Merriweather", serif;
+
 //** Default monospace fonts for `<code>`, `<kbd>`, and `<pre>`.
 @font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
 @font-family-base:        @font-family-sans-serif;
@@ -550,7 +545,7 @@
 //## Define alert colors, border radius, and padding.
 
 @alert-padding:               15px;
-@alert-border-radius:         @border-radius-base;
+@alert-border-radius:         0;
 @alert-link-font-weight:      bold;
 
 @alert-success-bg:            @state-success-bg;
@@ -898,25 +893,28 @@
 @toolbar-btn-active-color       : white;
 @toolbar-btn-active-bg-color    : @link-color;
 @toolbar-btn-active-shadow      : inset 0 3px 5px rgba(0, 0, 0, 0.225);
+@toolbar-font-size              : 12px;
 @toolbar-alt-bg-color           : #fafafa;
 @toolbar-icon-btn-color         : @gray-light;
 @toolbar-icon-btn-hover-color   : @gray-dark;
 @toolbar-icon-btn-hover-shadow  : 0 1px 0 rgba(0, 0, 0, 0.25);
 @toolbar-icon-btn-hover-boxshadow : inset 0 3px 5px rgba(0, 0, 0, 0.225);
-@toolbar-border-bottom          : 1px solid @toolbar-border-color;
+@toolbar-border-bottom            : 1px solid @toolbar-border-color;
+@toolbar-small-height             : 32px;
+@toolbar-tall-height              : 58px;
 
 // Editor file-tree
-@file-tree-bg                   : transparent;
-@file-tree-line-height          : 2.6;
-@file-tree-item-color           : @gray-darker;
-@file-tree-item-toggle-color    : @gray;
-@file-tree-item-icon-color      : @gray-light;
-@file-tree-item-input-color     : inherit;
-@file-tree-item-folder-color    : lighten(desaturate(@link-color, 10%), 5%);
-@file-tree-item-hover-bg        : @gray-lightest;
-@file-tree-item-selected-bg     : transparent;
-@file-tree-multiselect-bg       : lighten(@brand-info, 40%);
-@file-tree-multiselect-hover-bg : lighten(@brand-info, 30%);
+@file-tree-bg                    : transparent;
+@file-tree-line-height           : 2.6;
+@file-tree-item-color            : @gray-darker;
+@file-tree-item-toggle-color     : @gray;
+@file-tree-item-icon-color       : @gray-light;
+@file-tree-item-input-color      : inherit;
+@file-tree-item-folder-color     : lighten(desaturate(@link-color, 10%), 5%);
+@file-tree-item-hover-bg         : @gray-lightest;
+@file-tree-item-selected-bg      : transparent;
+@file-tree-multiselect-bg        : lighten(@brand-info, 40%);
+@file-tree-multiselect-hover-bg  : lighten(@brand-info, 30%);
 
 // Editor resizers
 @editor-resizer-bg-color          : #F4F4F4;
@@ -925,6 +923,28 @@
 @editor-toggler-hover-bg-color    : #DDD;
 @synctex-controls-z-index         : 3;
 @synctex-controls-padding         : 0 2px;
+
+// Chat
+@chat-bg                          : transparent;
+@chat-message-color               : @text-color;
+@chat-message-date-color          : @gray-light;
+@chat-message-name-color          : @gray-light;
+@chat-message-box-shadow          : -1px 2px 3px #ddd;
+@chat-message-border-radius       : 0;
+@chat-message-padding             : @line-height-computed / 2;
+@chat-message-weight              : normal;
+@chat-new-message-bg              : @gray-lightest;
+@chat-new-message-textarea-bg     : #FFF;
+@chat-new-message-textarea-color  : @gray-dark;
+
+// PDF
+@pdf-top-offset                  : @toolbar-tall-height;
+@pdf-bg                          : transparent;
+@pdfjs-bg                        : @gray-lighter;
+@pdf-page-shadow-color           : #000;
+@log-line-no-color               : @gray; 
+@log-hints-color                 : @gray-dark;
+
 // Tags
 @tag-border-radius  : 0.25em;
 @tag-bg-color       : @label-default-bg;

+ 71 - 14
services/web/public/stylesheets/core/ol-variables.less

@@ -1,6 +1,9 @@
 @import "./_common-variables.less";
 
 @is-overleaf: true;
+
+@font-family-sans-serif: "Lato", sans-serif;
+
 @header-height: 68px;
 @footer-height: 50px;
 
@@ -64,6 +67,28 @@
 @btn-info-bg             : @ol-blue;
 @btn-info-border         : transparent;
 
+// Alerts
+@alert-padding : 15px;
+@alert-border-radius : @border-radius-base;
+@alert-link-font-weight : bold;
+
+@alert-success-bg    : @brand-success;
+@alert-success-text  : #FFF;
+@alert-success-border: transparent;
+
+@alert-info-bg       : @brand-info;
+@alert-info-text     : #FFF;
+@alert-info-border   : transparent;
+
+@alert-warning-bg    : @brand-warning;
+@alert-warning-text  : #FFF;
+@alert-warning-border: transparent;
+
+@alert-danger-bg     : @brand-danger;
+@alert-danger-text   : #FFF;
+@alert-danger-border : transparent;
+
+
 // Tags
 @tag-border-radius       : 9999px;
 @tag-bg-color            : @ol-green;
@@ -110,6 +135,10 @@
 @sidebar-active-bg              : @ol-blue-gray-6;
 @sidebar-hover-bg               : @ol-blue-gray-4;
 @sidebar-hover-text-decoration  : none;
+@v2-dash-pane-bg                : @ol-blue-gray-4;
+@v2-dash-pane-link-color        : #FFF;
+@v2-dash-pane-btn-bg            : @ol-blue-gray-5;
+@v2-dash-pane-btn-hover-bg      : @ol-blue-gray-6;
 
 @folders-menu-margin            : 0 -(@grid-gutter-width / 2);
 @folders-menu-line-height       : @structured-list-line-height;
@@ -173,19 +202,22 @@
 @toolbar-icon-btn-hover-shadow    : none;
 @toolbar-border-bottom            : 1px solid @toolbar-border-color;
 @toolbar-icon-btn-hover-boxshadow : none;
+@toolbar-font-size                : 13px;
+
 // Editor file-tree
-@file-tree-bg                     : @ol-blue-gray-4;
-@file-tree-line-height            : 2.05;
-@file-tree-item-color             : #FFF;
-@file-tree-item-input-color       : @ol-blue-gray-5;
-@file-tree-item-toggle-color      : @ol-blue-gray-2;
-@file-tree-item-icon-color        : @ol-blue-gray-2;
-@file-tree-item-folder-color      : @ol-blue-gray-2;
-@file-tree-item-hover-bg          : @ol-blue-gray-5;
-@file-tree-item-selected-bg       : @ol-green;
-@file-tree-multiselect-bg         : @ol-blue;
-@file-tree-multiselect-hover-bg   : @ol-dark-blue;
-@file-tree-droppable-bg-color     : tint(@ol-green, 5%);
+@file-tree-bg                   : @ol-blue-gray-4;
+@file-tree-line-height          : 2.05;
+@file-tree-item-color           : #FFF;
+@file-tree-item-input-color     : @ol-blue-gray-5;
+@file-tree-item-toggle-color    : @ol-blue-gray-2;
+@file-tree-item-icon-color      : @ol-blue-gray-2;
+@file-tree-item-folder-color    : @ol-blue-gray-2;
+@file-tree-item-hover-bg        : @ol-blue-gray-5;
+@file-tree-item-selected-bg     : @ol-green;
+@file-tree-multiselect-bg       : @ol-blue;
+@file-tree-multiselect-hover-bg : @ol-dark-blue;
+@file-tree-droppable-bg-color   : tint(@ol-green, 5%);
+
 // Editor resizers
 @editor-resizer-bg-color          : @ol-blue-gray-6;
 @editor-resizer-bg-color-dragging : transparent;
@@ -193,6 +225,31 @@
 @editor-toggler-hover-bg-color    : @ol-green;
 @synctex-controls-z-index         : 6;
 @synctex-controls-padding         : 0;
+@editor-border-color              : @ol-blue-gray-5;
+
+// Chat
+@chat-bg                          : @ol-blue-gray-5;
+@chat-message-color               : #FFF;
+@chat-message-name-color          : #FFF;
+@chat-message-date-color          : @ol-blue-gray-2;
+@chat-message-box-shadow          : none;
+@chat-message-padding             : 5px 10px;
+@chat-message-border-radius       : @border-radius-large;
+@chat-message-weight              : bold;
+@chat-new-message-bg              : @ol-blue-gray-4;
+@chat-new-message-textarea-bg     : @ol-blue-gray-1;
+@chat-new-message-textarea-color  : @ol-blue-gray-6;
+
+// PDF
+@pdf-top-offset                 : @toolbar-small-height;
+@pdf-bg                         : @ol-blue-gray-1;
+@pdfjs-bg                       : transparent;
+@pdf-page-shadow-color          : rgba(0, 0, 0, 0.5);
+@log-line-no-color              : #FFF;
+@log-hints-color                : @ol-blue-gray-4;
+
+//== Colors
+//
 //## Gray and brand colors for use across Bootstrap.
 @gray-darker:           #252525;
 @gray-dark:             #505050;
@@ -212,9 +269,9 @@
 
 @brand-primary:         @ol-green;
 @brand-success:         @green;
-@brand-info:            @ol-dark-green;
+@brand-info:            @ol-blue;
 @brand-warning:         @orange;
-@brand-danger:          #E03A06;
+@brand-danger:          @ol-red;
 
 @editor-loading-logo-padding-top: 115.44%;
 @editor-loading-logo-background-url: url(/img/ol-brand/overleaf-o-grey.svg);

+ 5 - 1
services/web/public/stylesheets/ol-style.less

@@ -1,4 +1,8 @@
+@import url(https://fonts.googleapis.com/css?family=Lato:300,400,700&subset=latin-ext);
+@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i);
+
 // Core variables and mixins
 @import "core/ol-variables.less";
 @import "app/ol-style-guide.less";
-@import "_style_includes.less";
+@import "_style_includes.less";
+@import "_ol_style_includes.less";

+ 3 - 0
services/web/public/stylesheets/style.less

@@ -1,3 +1,6 @@
+@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);
+@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i);
+
 // Core variables and mixins
 @import "core/variables.less";
 @import "_style_includes.less";

+ 140 - 1
services/web/test/acceptance/coffee/ProjectStructureTests.coffee

@@ -59,7 +59,7 @@ describe "ProjectStructureChanges", ->
 				@dup_project_id = body.project_id
 				done()
 
-		it "should version the dosc created", ->
+		it "should version the docs created", ->
 			updates = MockDocUpdaterApi.getProjectStructureUpdates(@dup_project_id).docUpdates
 			expect(updates.length).to.equal(2)
 			_.each updates, (update) =>
@@ -91,6 +91,7 @@ describe "ProjectStructureChanges", ->
 					throw error if error?
 					if res.statusCode < 200 || res.statusCode >= 300
 						throw new Error("failed to add doc #{res.statusCode}")
+					@example_doc_id = body._id
 					done()
 
 		it "should version the doc added", ->
@@ -162,6 +163,8 @@ describe "ProjectStructureChanges", ->
 				if res.statusCode < 200 || res.statusCode >= 300
 					throw new Error("failed to upload file #{res.statusCode}")
 
+				@example_file_id = JSON.parse(body).entity_id
+
 				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
 				expect(updates.length).to.equal(1)
 				update = updates[0]
@@ -199,6 +202,120 @@ describe "ProjectStructureChanges", ->
 
 				done()
 
+	describe "moving entities", ->
+		before (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'foo'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_1 = JSON.parse(body)._id
+				done()
+
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version moving a doc", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/Doc/#{@example_doc_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/new.tex")
+				expect(update.newPathname).to.equal("/foo/new.tex")
+
+				done()
+
+		it "should version moving a file", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/File/#{@example_file_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move file #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/1pixel.png")
+				expect(update.newPathname).to.equal("/foo/1pixel.png")
+
+				done()
+
+		it "should version moving a folder", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'bar'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_2 = JSON.parse(body)._id
+
+				@owner.request.post {
+					uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_1}/move",
+					json:
+						folder_id: @example_folder_id_2
+				}, (error, res, body) =>
+					throw error if error?
+					if res.statusCode < 200 || res.statusCode >= 300
+						throw new Error("failed to move folder #{res.statusCode}")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/new.tex")
+					expect(update.newPathname).to.equal("/bar/foo/new.tex")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/1pixel.png")
+					expect(update.newPathname).to.equal("/bar/foo/1pixel.png")
+
+					done()
+
+	describe "deleting entities", ->
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version deleting a folder", (done) ->
+			@owner.request.delete {
+				uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_2}",
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete folder #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/new.tex")
+				expect(update.newPathname).to.equal("")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/1pixel.png")
+				expect(update.newPathname).to.equal("")
+
+				done()
+
 	describe "tpds", ->
 		before (done) ->
 			@tpds_project_name = "tpds-project-#{new ObjectId().toString()}"
@@ -305,3 +422,25 @@ describe "ProjectStructureChanges", ->
 				done()
 
 			image_file.pipe(req)
+
+		it "should version deleting a doc", (done) ->
+			req = @owner.request.delete {
+				uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/test.tex",
+				auth:
+					user: _.keys(Settings.httpAuthUsers)[0]
+					pass: _.values(Settings.httpAuthUsers)[0]
+					sendImmediately: true
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/test.tex")
+				expect(update.newPathname).to.equal("")
+
+				done()
+

+ 3 - 0
services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee

@@ -33,6 +33,9 @@ module.exports = MockDocUpdaterApi =
 			@addProjectStructureUpdates(project_id, userId, docUpdates, fileUpdates)
 			res.sendStatus 200
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			res.send 204
+
 		app.listen 3003, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 10 - 0
services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee

@@ -23,6 +23,16 @@ module.exports = MockDocStoreApi =
 			docs = (doc for doc_id, doc of @docs[req.params.project_id])
 			res.send JSON.stringify docs
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			{project_id, doc_id} = req.params
+			if !@docs[project_id]?
+				res.send 404
+			else if !@docs[project_id][doc_id]?
+				res.send 404
+			else
+				@docs[project_id][doc_id] = undefined
+				res.send 204
+
 		app.listen 3016, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 13 - 5
services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee

@@ -393,7 +393,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history disabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
+				@settings.apis.project_history.sendProjectStructureOps = false
 				@request.post = sinon.stub()
 
 				@handler.updateProjectStructure @project_id, @user_id, {}, @callback
@@ -406,7 +406,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history enabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
+				@settings.apis.project_history.sendProjectStructureOps = true
 				@url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}"
 				@request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "")
 
@@ -478,14 +478,22 @@ describe 'DocumentUpdaterHandler', ->
 							.should.equal true
 						done()
 
-			describe "when a doc has been deleted", ->
-				it 'should do nothing', (done) ->
+			describe "when an entity has been deleted", ->
+				it 'should end the structure update to the document updater', (done) ->
 					@docId = new ObjectId()
 					@changes = oldDocs: [
 						{ path: '/foo', docLines: 'a\nb', doc: _id: @docId }
 					]
 
+					docUpdates = [
+						id: @docId.toString(),
+						pathname: '/foo',
+						newPathname: ''
+					]
+
 					@handler.updateProjectStructure @project_id, @user_id, @changes, () =>
-						@request.post.called.should.equal false
+						@request.post
+							.calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id})
+							.should.equal true
 						done()
 

+ 24 - 29
services/web/test/unit/coffee/Editor/EditorControllerTests.coffee

@@ -376,58 +376,53 @@ describe "EditorController", ->
 				err.should.equal "timed out"
 				done()			
 
-
 	describe "deleteEntity", ->
-
 		beforeEach ->
 			@LockManager.getLock.callsArgWith(1)
 			@LockManager.releaseLock.callsArgWith(1)
-			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(4)
+			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(5)
 
 		it "should call deleteEntityWithoutLock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
-				@EditorController.deleteEntityWithoutLock.calledWith(@project_id, @entity_id, @type, @source).should.equal true
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
+				@EditorController.deleteEntityWithoutLock
+					.calledWith(@project_id, @entity_id, @type, @source, @user_id)
+					.should.equal true
 				done()
 
 		it "should take the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
 				@LockManager.getLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should release the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (error)=>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
 				@LockManager.releaseLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should error if it can't cat the lock", (done)->
 			@LockManager.getLock = sinon.stub().callsArgWith(1, "timed out")
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (err)=>
-				expect(err).to.exist
-				err.should.equal "timed out"
-				done()			
-
-
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
+				expect(error).to.exist
+				error.should.equal "timed out"
+				done()
 
 	describe 'deleteEntityWithoutLock', ->
-		beforeEach ->
-			@ProjectEntityHandler.deleteEntity = (project_id, entity_id, type, callback)-> callback()
+		beforeEach (done) ->
 			@entity_id = "entity_id_here"
 			@type = "doc"
 			@EditorRealTimeController.emitToRoom = sinon.stub()
-
-		it 'should delete the folder using the project entity handler', (done)->
-			mock = sinon.mock(@ProjectEntityHandler).expects("deleteEntity").withArgs(@project_id, @entity_id, @type).callsArg(3)
-
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, ->
-				mock.verify()
-				done()
-
-		it 'notify users an entity has been deleted', (done)->
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, =>
-				@EditorRealTimeController.emitToRoom
-					.calledWith(@project_id, "removeEntity", @entity_id, @source)
-					.should.equal true
-				done()
+			@ProjectEntityHandler.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, @user_id, done
+
+		it 'should delete the folder using the project entity handler', ->
+			@ProjectEntityHandler.deleteEntity
+				.calledWith(@project_id, @entity_id, @type, @user_id)
+				.should.equal.true
+
+		it 'notify users an entity has been deleted', ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "removeEntity", @entity_id, @source)
+				.should.equal true
 
 	describe "getting a list of project paths", ->
 

+ 2 - 2
services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee

@@ -331,12 +331,12 @@ describe "EditorHttpController", ->
 				Project_id: @project_id
 				entity_id: @entity_id = "entity-id-123"
 				entity_type: @entity_type = "entity-type"
-			@EditorController.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntity = sinon.stub().callsArg(5)
 			@EditorHttpController.deleteEntity @req, @res
 
 		it "should call EditorController.deleteEntity", ->
 			@EditorController.deleteEntity
-				.calledWith(@project_id, @entity_id, @entity_type, "editor")
+				.calledWith(@project_id, @entity_id, @entity_type, "editor", @userId)
 				.should.equal true
 
 		it "should send back a success response", ->

+ 48 - 151
services/web/test/unit/coffee/History/HistoryControllerTests.coffee

@@ -31,7 +31,7 @@ describe "HistoryController", ->
 
 		describe "for a project with project history", ->
 			beforeEach ->
-				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{display:true}}})
+				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{id: 42, display:true}}})
 				@HistoryController.selectHistoryApi @req, @res, @next
 
 			it "should set the flag for project history to true", ->
@@ -57,93 +57,55 @@ describe "HistoryController", ->
 				on: (event, handler) -> @events[event] = handler
 			@request.returns @proxy
 
-		describe "with project history enabled", ->
+		describe "for a project with the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
-
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
-
-				it "should call the project history api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.project_history.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-		describe "with project history disabled", ->
+				@req.useProjectHistory = true
+				@HistoryController.proxyToHistoryApi @req, @res, @next
+
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
+
+			it "should call the project history api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.project_history.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
+
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
+
+		describe "for a project without the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
-
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
+				@req.useProjectHistory = false
+				@HistoryController.proxyToHistoryApi @req, @res, @next
+
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
+
+			it "should call the track changes api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.trackchanges.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
+
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
 
 		describe "with an error", ->
 			beforeEach ->
@@ -152,68 +114,3 @@ describe "HistoryController", ->
 
 			it "should pass the error up the call chain", ->
 				@next.calledWith(@error).should.equal true
-
-	describe "initializeProject", ->
-		describe "with project history enabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = true
-
-			describe "project history returns a successful response", ->
-				beforeEach ->
-					@overleaf_id = 1234
-					@res = statusCode: 200
-					@body = JSON.stringify(project: id: @overleaf_id)
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should call the project history api", ->
-					@request.post.calledWith(
-						url: "#{@settings.apis.project_history.url}/project"
-					).should.equal true
-
-				it "should return the callback with the overleaf id", ->
-					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
-
-			describe "project history returns a response without the project id", ->
-				beforeEach ->
-					@res = statusCode: 200
-					@body = JSON.stringify(project: {})
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
-						.should.equal true
-
-			describe "project history returns a unsuccessful response", ->
-				beforeEach ->
-					@res = statusCode: 404
-					@request.post = sinon.stub().callsArgWith(1, null, @res)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
-						.should.equal true
-
-			describe "project history errors", ->
-				beforeEach ->
-					@error = sinon.stub()
-					@request.post = sinon.stub().callsArgWith(1, @error)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with the error", ->
-					@callback.calledWithExactly(@error).should.equal true
-
-		describe "with project history disabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = false
-				@HistoryController.initializeProject @callback
-
-			it "should return the callback", ->
-				@callback.calledWithExactly().should.equal true

+ 86 - 0
services/web/test/unit/coffee/History/HistoryManagerTests.coffee

@@ -0,0 +1,86 @@
+chai = require('chai')
+chai.should()
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/History/HistoryManager"
+SandboxedModule = require('sandboxed-module')
+
+describe "HistoryManager", ->
+	beforeEach ->
+		@callback = sinon.stub()
+		@user_id = "user-id-123"
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@user_id)
+		@HistoryManager = SandboxedModule.require modulePath, requires:
+			"request" : @request = sinon.stub()
+			"settings-sharelatex": @settings = {}
+		@settings.apis =
+			trackchanges:
+				enabled: false
+				url: "http://trackchanges.example.com"
+			project_history:
+				url: "http://project_history.example.com"
+
+	describe "initializeProject", ->
+		describe "with project history enabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = true
+
+			describe "project history returns a successful response", ->
+				beforeEach ->
+					@overleaf_id = 1234
+					@res = statusCode: 200
+					@body = JSON.stringify(project: id: @overleaf_id)
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should call the project history api", ->
+					@request.post.calledWith(
+						url: "#{@settings.apis.project_history.url}/project"
+					).should.equal true
+
+				it "should return the callback with the overleaf id", ->
+					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
+
+			describe "project history returns a response without the project id", ->
+				beforeEach ->
+					@res = statusCode: 200
+					@body = JSON.stringify(project: {})
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
+						.should.equal true
+
+			describe "project history returns a unsuccessful response", ->
+				beforeEach ->
+					@res = statusCode: 404
+					@request.post = sinon.stub().callsArgWith(1, null, @res)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
+						.should.equal true
+
+			describe "project history errors", ->
+				beforeEach ->
+					@error = sinon.stub()
+					@request.post = sinon.stub().callsArgWith(1, @error)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with the error", ->
+					@callback.calledWithExactly(@error).should.equal true
+
+		describe "with project history disabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = false
+				@HistoryManager.initializeProject @callback
+
+			it "should return the callback", ->
+				@callback.calledWithExactly().should.equal true

+ 4 - 4
services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee

@@ -38,7 +38,7 @@ describe 'ProjectCreationHandler', ->
 			setRootDoc: sinon.stub().callsArg(2)
 		@ProjectDetailsHandler =
 			validateProjectName: sinon.stub().yields()
-		@HistoryController =
+		@HistoryManager =
 			initializeProject: sinon.stub().callsArg(0)
 
 		@user =
@@ -53,7 +53,7 @@ describe 'ProjectCreationHandler', ->
 			'../../models/User': User:@User
 			'../../models/Project':{Project:@ProjectModel}
 			'../../models/Folder':{Folder:@FolderModel}
-			'../History/HistoryController': @HistoryController
+			'../History/HistoryManager': @HistoryManager
 			'./ProjectEntityHandler':@ProjectEntityHandler
 			"./ProjectDetailsHandler":@ProjectDetailsHandler
 			"settings-sharelatex": @Settings = {}
@@ -66,7 +66,7 @@ describe 'ProjectCreationHandler', ->
 	describe 'Creating a Blank project', ->
 		beforeEach ->
 			@overleaf_id = 1234
-			@HistoryController.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
+			@HistoryManager.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
 			@ProjectModel::save = sinon.stub().callsArg(0)
 
 		describe "successfully", ->
@@ -83,7 +83,7 @@ describe 'ProjectCreationHandler', ->
 
 			it "should initialize the project overleaf if history id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, done
-				@HistoryController.initializeProject.calledWith().should.equal true
+				@HistoryManager.initializeProject.calledWith().should.equal true
 
 			it "should set the overleaf id if overleaf id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, (err, project)=>

+ 6 - 6
services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee

@@ -64,7 +64,7 @@ describe 'ProjectDuplicator', ->
 		@projectOptionsHandler =
 			setCompiler : sinon.stub()								
 		@entityHandler =
-			addDocWithProject: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
+			addDoc: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
 			copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(5)
 			setRootDoc: sinon.stub()
 			addFolderWithProject: sinon.stub().callsArgWith(3, null, @newFolder)
@@ -112,13 +112,13 @@ describe 'ProjectDuplicator', ->
 			done()
 
 	it 'should use the same compiler', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true
 			done()
 	
 	it 'should use the same root doc', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true
 			done()
@@ -139,13 +139,13 @@ describe 'ProjectDuplicator', ->
 	it 'should copy all the docs', (done)->
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@DocstoreManager.getAllDocs.calledWith(@old_project_id).should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @doc0.name, @doc0_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc1.name, @doc1_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc2.name, @doc2_lines, @owner._id)
 				.should.equal true
 			done()

+ 98 - 20
services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee

@@ -157,13 +157,13 @@ describe 'ProjectEntityHandler', ->
 			@ProjectGetter.getProject.callsArgWith(2, null, @project)
 			@tpdsUpdateSender.deleteEntity = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._removeElementFromMongoArray = sinon.stub().callsArg(3)
-			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(3)
+			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(5)
 			@path = mongo: "mongo.path", fileSystem: "/file/system/path"
 			@projectLocator.findElement = sinon.stub().callsArgWith(1, null, @entity = { _id: entity_id }, @path)
 
 		describe "deleting from Mongo", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', done
+				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', userId, done
 
 			it "should retreive the path", ->
 				@projectLocator.findElement.called.should.equal true
@@ -182,7 +182,7 @@ describe 'ProjectEntityHandler', ->
 
 			it "should clean up the entity from the rest of the system", ->
 				@ProjectEntityHandler._cleanUpEntity
-					.calledWith(@project, @entity, @type)
+					.calledWith(@project, @entity, @type, @path.fileSystem, userId)
 					.should.equal true
 
 	describe "_cleanUpEntity", ->
@@ -193,7 +193,9 @@ describe 'ProjectEntityHandler', ->
 
 		describe "a file", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpEntity @project, _id: @entity_id, 'file', done
+				@path = "/file/system/path.png"
+				@entity = _id: @entity_id
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'file', @path, userId, done
 
 			it "should delete the file from FileStoreHandler", ->
 				@FileStoreHandler.deleteFile.calledWith(project_id, @entity_id).should.equal true
@@ -201,38 +203,56 @@ describe 'ProjectEntityHandler', ->
 			it "should not attempt to delete from the document updater", ->
 				@documentUpdaterHandler.deleteDoc.called.should.equal false
 
+			it "should should send the update to the doc updater", ->
+				oldFiles = [ file: @entity, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldFiles})
+					.should.equal true
+
 		describe "a doc", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @entity = {_id: @entity_id}, 'doc', done
+				@path = "/file/system/path.tex"
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@entity = {_id: @entity_id}
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'doc', @path, userId, done
 
 			it "should clean up the doc", ->
 				@ProjectEntityHandler._cleanUpDoc
-					.calledWith(@project, @entity)
+					.calledWith(@project, @entity, @path, userId)
 					.should.equal true
 
 		describe "a folder", ->
 			beforeEach (done) ->
 				@folder =
 					folders: [
-						fileRefs: [ @file1 = {_id: "file-id-1" } ]
-						docs:     [ @doc1  = { _id: "doc-id-1" } ]
+						name: "subfolder"
+						fileRefs: [ @file1 = { _id: "file-id-1", name: "file-name-1"} ]
+						docs:     [ @doc1  = { _id: "doc-id-1", name: "doc-name-1" } ]
 						folders:  []
 					]
-					fileRefs: [ @file2 = { _id: "file-id-2" } ]
-					docs:     [ @doc2  = { _id: "doc-id-2" } ]
+					fileRefs: [ @file2 = { _id: "file-id-2", name: "file-name-2" } ]
+					docs:     [ @doc2  = { _id: "doc-id-2", name: "doc-name-2" } ]
 
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", done
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(4)
+				path = "/folder"
+				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", path, userId, done
 
 			it "should clean up all sub files", ->
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file1).should.equal true
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file2).should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file1, "/folder/subfolder/file-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file2, "/folder/file-name-2", userId)
+					.should.equal true
 
 			it "should clean up all sub docs", ->
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc1).should.equal true
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc2).should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc1, "/folder/subfolder/doc-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
+					.should.equal true
 
 	describe 'moveEntity', ->
 		beforeEach ->
@@ -496,6 +516,51 @@ describe 'ProjectEntityHandler', ->
 				.calledWith(project_id, userId, {newDocs})
 				.should.equal true
 
+	describe 'addDocWithoutUpdatingHistory', ->
+		beforeEach ->
+			@name = "some new doc"
+			@lines = ['1234','abc']
+			@path = "/path/to/doc"
+
+			@ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:@path}})
+			@callback = sinon.stub()
+			@tpdsUpdateSender.addDoc = sinon.stub().callsArg(1)
+			@DocstoreManager.updateDoc = sinon.stub().yields(null, true, 0)
+
+			@ProjectEntityHandler.addDocWithoutUpdatingHistory project_id, folder_id, @name, @lines, userId, @callback
+
+			# Created doc
+			@doc = @ProjectEntityHandler._putElement.args[0][2]
+			@doc.name.should.equal @name
+			expect(@doc.lines).to.be.undefined
+
+		it 'should call put element', ->
+			@ProjectEntityHandler._putElement.calledWith(@project, folder_id, @doc).should.equal true
+
+		it 'should return doc and parent folder', ->
+			@callback.calledWith(null, @doc, folder_id).should.equal true
+
+		it 'should call third party data store', ->
+			@tpdsUpdateSender.addDoc
+				.calledWith({
+					project_id: project_id
+					doc_id: doc_id
+					path: @path
+					project_name: @project.name
+					rev: 0
+				})
+				.should.equal true
+
+		it "should send the doc lines to the doc store", ->
+			@DocstoreManager.updateDoc
+				.calledWith(project_id, @doc._id.toString(), @lines)
+				.should.equal true
+
+		it "should not should send the change in project structure to the doc updater", () ->
+			@documentUpdaterHandler.updateProjectStructure
+				.called
+				.should.equal false
+
 	describe "restoreDoc", ->
 		beforeEach ->
 			@name = "doc-name"
@@ -584,6 +649,12 @@ describe 'ProjectEntityHandler', ->
 
 			@ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () ->
 
+		it "should not send the change in project structure to the doc updater when called as addFileWithoutUpdatingHistory", (done) ->
+			@documentUpdaterHandler.updateProjectStructure = sinon.stub().yields()
+			@ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, {}, userId, () =>
+				@documentUpdaterHandler.updateProjectStructure.called.should.equal false
+				done()
+
 	describe 'replaceFile', ->
 		beforeEach ->
 			@projectLocator
@@ -1116,6 +1187,7 @@ describe 'ProjectEntityHandler', ->
 			@doc =
 				_id: ObjectId()
 				name: "test.tex"
+			@path = "/path/to/doc"
 			@ProjectEntityHandler.unsetRootDoc = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._insertDeletedDocReference = sinon.stub().callsArg(2)
 			@documentUpdaterHandler.deleteDoc = sinon.stub().callsArg(2)
@@ -1125,7 +1197,7 @@ describe 'ProjectEntityHandler', ->
 		describe "when the doc is the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = @doc._id
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc
@@ -1146,13 +1218,19 @@ describe 'ProjectEntityHandler', ->
 					.calledWith(project_id, @doc._id.toString())
 					.should.equal true
 
+			it "should should send the update to the doc updater", ->
+				oldDocs = [ doc: @doc, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldDocs})
+					.should.equal true
+
 			it "should call the callback", ->
 				@callback.called.should.equal true
 
 		describe "when the doc is not the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = ObjectId()
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should not unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc.called.should.equal false

+ 25 - 0
services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee

@@ -57,6 +57,7 @@ describe "SubscriptionUpdater", ->
 
 		@ReferalAllocator = assignBonus:sinon.stub().callsArgWith(1)
 		@ReferalAllocator.cock = true
+		@Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}}
 		@SubscriptionUpdater = SandboxedModule.require modulePath, requires:
 			'../../models/Subscription': Subscription:@SubscriptionModel
 			'./UserFeaturesUpdater': @UserFeaturesUpdater
@@ -65,6 +66,7 @@ describe "SubscriptionUpdater", ->
 			"logger-sharelatex": log:->
 			'settings-sharelatex': @Settings
 			"../Referal/ReferalAllocator" : @ReferalAllocator
+			'../../infrastructure/Modules': @Modules
 
 
 	describe "syncSubscription", ->
@@ -204,10 +206,22 @@ describe "SubscriptionUpdater", ->
 				assert.equal args[1], @groupSubscription.planCode
 				done()
 
+		it "should call updateFeatures with the overleaf subscription if set", (done)->
+			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
+			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, null)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, ['ol_pro'])
+
+			@SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=>
+				args = @UserFeaturesUpdater.updateFeatures.args[0]
+				assert.equal args[0], @adminUser._id
+				assert.equal args[1], 'ol_pro'
+				done()
+
 		it "should call not call updateFeatures  with users subscription if the subscription plan code is the default one (downgraded)", (done)->
 			@subscription.planCode = @Settings.defaultPlanCode
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
 			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
 			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
 				args = @UserFeaturesUpdater.updateFeatures.args[0]
 				assert.equal args[0], @adminUser._id
@@ -218,6 +232,7 @@ describe "SubscriptionUpdater", ->
 		it "should call updateFeatures with default if there are no subscriptions for user", (done)->
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
 			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
 			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
 				args = @UserFeaturesUpdater.updateFeatures.args[0]
 				assert.equal args[0], @adminUser._id
@@ -263,3 +278,13 @@ describe "SubscriptionUpdater", ->
 				@SubscriptionUpdater._setUsersMinimumFeatures
 					.calledWith(user_id)
 					.should.equal true
+
+	describe 'refreshSubscription', ->
+		beforeEach ->
+			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub()
+				.callsArgWith(1, null)
+
+		it 'should call to _setUsersMinimumFeatures', ->
+			@SubscriptionUpdater.refreshSubscription(@adminUser._id, ()->)
+			@SubscriptionUpdater._setUsersMinimumFeatures.callCount.should.equal 1
+			@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true

+ 5 - 3
services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee

@@ -8,7 +8,7 @@ describe 'TpdsUpdateHandler', ->
 	beforeEach ->
 		@requestQueuer = {}
 		@updateMerger = 
-			deleteUpdate: (user_id, path, source, cb)->cb()
+			deleteUpdate: (user_id, project_id, path, source, cb)->cb()
 			mergeUpdate:(user_id, project_id, path, update, source, cb)->cb()
 		@editorController = {}
 		@project_id = "dsjajilknaksdn"
@@ -107,11 +107,13 @@ describe 'TpdsUpdateHandler', ->
 		it 'should call deleteEntity in the collaberation manager', (done)->
 			path = "/delete/this"
 			update = {}
-			@updateMerger.deleteUpdate = sinon.stub().callsArg(3)
+			@updateMerger.deleteUpdate = sinon.stub().callsArg(4)
 
 			@handler.deleteUpdate @user_id, @project.name, path, @source, =>
 				@projectDeleter.markAsDeletedByExternalSource.calledWith(@project._id).should.equal false
-				@updateMerger.deleteUpdate.calledWith(@project_id, path, @source).should.equal true
+				@updateMerger.deleteUpdate
+					.calledWith(@user_id, @project_id, path, @source)
+					.should.equal true
 				done()
 
 		it 'should mark the project as deleted by external source if path is a single slash', (done)->

+ 3 - 3
services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee

@@ -145,13 +145,13 @@ describe 'UpdateMerger :', ->
 
 		it 'should get the element id', ->
 			@projectLocator.findElementByPath = sinon.spy()
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 			@projectLocator.findElementByPath.calledWith(@project_id, @path).should.equal true
 
 		it 'should delete the entity in the editor controller with the correct type', (done)->
 			@entity.lines = []
-			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source).callsArg(4)
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source, @user_id).callsArg(5)
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 				mock.verify()
 				done()
 

+ 134 - 0
services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee

@@ -0,0 +1,134 @@
+Path = require 'path'
+SandboxedModule = require "sandboxed-module"
+modulePath = Path.join __dirname, '../../../public/js/ide/history/HistoryV2Manager'
+sinon = require("sinon")
+expect = require("chai").expect
+
+describe "HistoryV2Manager", ->
+	beforeEach ->
+		@moment = {}
+		@ColorManager = {}
+		SandboxedModule.require modulePath, globals:
+			"define": (dependencies, builder) =>
+				@HistoryV2Manager = builder(@moment, @ColorManager)
+
+		@scope =
+			$watch: sinon.stub()
+			$on: sinon.stub()
+		@ide = {}
+
+		@historyManager = new @HistoryV2Manager(@ide, @scope)
+
+	it "should setup the history scope on intialization", ->
+		expect(@scope.history).to.deep.equal({
+			isV2: true
+			updates: []
+			nextBeforeTimestamp: null
+			atEnd: false
+			selection: {
+				updates: []
+				pathname: null
+				range: {
+					fromV: null
+					toV: null
+				}
+			}
+			diff: null
+		})
+
+	describe "_perDocSummaryOfUpdates", ->
+		it "should return the range of updates for the docs", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main.tex"]
+				fromV: 7, toV: 9
+			},{
+				pathnames: ["main.tex", "foo.tex"]
+				fromV: 4, toV: 6
+			},{
+				pathnames: ["main.tex"]
+				fromV: 3, toV: 3
+			},{
+				pathnames: ["foo.tex"]
+				fromV: 0, toV: 2
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 3, toV: 9 },
+				"foo.tex": { fromV: 0, toV: 6 }
+			})
+
+		it "should track renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main2.tex"]
+				fromV: 5, toV: 9
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 4
+			},{
+				pathnames: ["main1.tex"]
+				fromV: 3, toV: 3
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main0.tex",
+						newPathname: "main1.tex"
+					}
+				}],
+				fromV: 2, toV: 2
+			},{
+				pathnames: ["main0.tex"]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main0.tex": { fromV: 0, toV: 9 }
+			})
+
+		it "should track single renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 5
+			}])
+
+			expect(result).to.deep.equal({
+				"main1.tex": { fromV: 4, toV: 5 }
+			})
+
+		it "should track additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}, {
+				pathnames: ["main.tex"]
+				fromV: 1, toV: 4
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 4 }
+			})
+
+		it "should track single additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 1 }
+			})