Sfoglia il codice sorgente

Merge branch 'master' into sk-fix-chat-link-color-in-v2

Shane Kilkelly 8 anni fa
parent
commit
1c31012135
100 ha cambiato i file con 1800 aggiunte e 5486 eliminazioni
  1. 3 1
      services/web/.gitignore
  2. 0 4
      services/web/.travis.yml
  3. 2 2
      services/web/Makefile
  4. 10 3
      services/web/Makefile.module
  5. 6 6
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  6. 10 9
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  7. 27 14
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  8. 27 15
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  9. 11 7
      services/web/app/coffee/Features/Editor/EditorController.coffee
  10. 0 17
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  11. 0 2
      services/web/app/coffee/Features/Editor/EditorRouter.coffee
  12. 27 0
      services/web/app/coffee/Features/History/HistoryController.coffee
  13. 60 0
      services/web/app/coffee/Features/History/RestoreManager.coffee
  14. 11 1
      services/web/app/coffee/Features/Project/ProjectController.coffee
  15. 7 0
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  16. 1 9
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  17. 52 26
      services/web/app/coffee/Features/Project/ProjectEntityMongoUpdateHandler.coffee
  18. 164 105
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  19. 7 5
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  20. 4 2
      services/web/app/coffee/infrastructure/Features.coffee
  21. 31 12
      services/web/app/coffee/infrastructure/FileWriter.coffee
  22. 4 0
      services/web/app/coffee/infrastructure/LoggerSerializers.coffee
  23. 7 0
      services/web/app/coffee/infrastructure/Modules.coffee
  24. 5 0
      services/web/app/coffee/models/Project.coffee
  25. 31 29
      services/web/app/coffee/models/User.coffee
  26. 7 3
      services/web/app/coffee/router.coffee
  27. 2 17
      services/web/app/views/general/500.pug
  28. 2 18
      services/web/app/views/layout.pug
  29. 8 7
      services/web/app/views/project/editor.pug
  30. 1 1
      services/web/app/views/project/editor/binary-file.pug
  31. 6 11
      services/web/app/views/project/editor/editor.pug
  32. 3 0
      services/web/app/views/project/editor/header.pug
  33. 1 1
      services/web/app/views/project/editor/history-file-tree.pug
  34. 36 143
      services/web/app/views/project/editor/history.pug
  35. 58 0
      services/web/app/views/project/editor/history/diffPanelV1.pug
  36. 50 0
      services/web/app/views/project/editor/history/diffPanelV2.pug
  37. 0 1
      services/web/app/views/project/editor/pdf.pug
  38. 0 1
      services/web/app/views/project/editor/share.pug
  39. 22 2
      services/web/app/views/project/list.pug
  40. 3 3
      services/web/app/views/project/list/front-chat.pug
  41. 4 1
      services/web/app/views/project/list/item.pug
  42. 11 8
      services/web/app/views/project/list/modals.pug
  43. 5 5
      services/web/app/views/project/list/notifications.pug
  44. 1 0
      services/web/app/views/project/list/project-list.pug
  45. 3 6
      services/web/app/views/project/list/side-bar.pug
  46. 1 1
      services/web/app/views/project/list/v1-item.pug
  47. 22 19
      services/web/app/views/subscriptions/group_admin.pug
  48. 42 301
      services/web/app/views/subscriptions/new.pug
  49. 6 12
      services/web/app/views/subscriptions/plans.pug
  50. 13 11
      services/web/app/views/user/settings.pug
  51. 2 0
      services/web/config/settings.defaults.coffee
  52. 9 3
      services/web/karma.conf.js
  53. 0 6
      services/web/modules/.gitignore
  54. 0 0
      services/web/modules/.gitkeep
  55. 3 2
      services/web/nodemon.frontend.json
  56. 2 1
      services/web/nodemon.json
  57. 126 8
      services/web/npm-shrinkwrap.json
  58. 4 0
      services/web/package.json
  59. 41 0
      services/web/public/coffee/directives/eventTracking.coffee
  60. 4 0
      services/web/public/coffee/filters/formatDate.coffee
  61. 1 0
      services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee
  62. 66 1
      services/web/public/coffee/ide/directives/layout.coffee
  63. 1 5
      services/web/public/coffee/ide/editor/EditorManager.coffee
  64. 0 7
      services/web/public/coffee/ide/editor/controllers/EditorToolbarController.coffee
  65. 0 33
      services/web/public/coffee/ide/editor/directives/cmEditor.coffee
  66. 37 0
      services/web/public/coffee/ide/editor/directives/toggleSwitch.coffee
  67. 1 0
      services/web/public/coffee/ide/history/HistoryManager.coffee
  68. 11 4
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  69. 0 9
      services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
  70. 40 0
      services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
  71. 1 0
      services/web/public/coffee/main.coffee
  72. 2 18
      services/web/public/coffee/main/account-upgrade.coffee
  73. 91 36
      services/web/public/coffee/main/new-subscription.coffee
  74. 1 167
      services/web/public/coffee/main/plans.coffee
  75. 0 2
      services/web/public/coffee/main/subscription-dashboard.coffee
  76. 7 0
      services/web/public/coffee/utils/EventEmitter.coffee
  77. 0 16
      services/web/public/es/rich-text.js
  78. BIN
      services/web/public/img/teasers/history/teaser-history.png
  79. 0 4218
      services/web/public/js/libs/recurly-3.0.5.js
  80. 0 0
      services/web/public/js/libs/recurly-4.8.5.js
  81. 0 0
      services/web/public/js/libs/recurly.min.js
  82. BIN
      services/web/public/ol-apple-touch-icon-precomposed.png
  83. 4 0
      services/web/public/ol-mask-favicon.svg
  84. BIN
      services/web/public/ol-touch-icon-192x192.png
  85. 1 5
      services/web/public/stylesheets/_style_includes.less
  86. 10 4
      services/web/public/stylesheets/app/base.less
  87. 81 75
      services/web/public/stylesheets/app/editor.less
  88. 1 1
      services/web/public/stylesheets/app/editor/pdf.less
  89. 89 0
      services/web/public/stylesheets/app/editor/publish-modal.less
  90. 222 0
      services/web/public/stylesheets/app/editor/rich-text.less
  91. 58 0
      services/web/public/stylesheets/app/editor/toolbar.less
  92. 25 1
      services/web/public/stylesheets/app/error-pages.less
  93. 3 6
      services/web/public/stylesheets/app/list/v1-import-modal.less
  94. 5 0
      services/web/public/stylesheets/app/recurly.less
  95. 1 1
      services/web/public/stylesheets/app/sidebar-v2-dash-pane.less
  96. 4 0
      services/web/public/stylesheets/app/subscription.less
  97. 16 7
      services/web/public/stylesheets/app/translations.less
  98. 2 1
      services/web/public/stylesheets/components/forms.less
  99. 0 7
      services/web/public/stylesheets/components/navbar.less
  100. 14 1
      services/web/public/stylesheets/core/_common-variables.less

+ 3 - 1
services/web/.gitignore

@@ -49,6 +49,8 @@ TpdsWorker.js
 BackgroundJobsWorker.js
 UserAndProjectPopulator.coffee
 
+public/es/modules
+
 public/js/*.js
 public/js/*.map
 public/js/libs/sharejs.js
@@ -80,7 +82,7 @@ public/js/libs/require*.js
 
 app/views/external
 
-/modules/
+modules
 docker-shared.yml
 
 config/*.coffee

+ 0 - 4
services/web/.travis.yml

@@ -1,8 +1,5 @@
 language: node_js
 
-node_js:
-    - "0.10"
-
 before_install:
     - npm install -g grunt-cli
 
@@ -16,4 +13,3 @@ script:
 services:
     - redis-server
     - mongodb
-    

+ 2 - 2
services/web/Makefile

@@ -180,7 +180,7 @@ clean_css:
 	rm -f public/stylesheets/*.css*
 
 clean_ci:
-	docker-compose down
+	docker-compose down -v
 
 test: test_unit test_frontend test_acceptance
 
@@ -221,7 +221,7 @@ test_acceptance_module: $(MODULE_MAKEFILES)
 	fi
 
 test_clean:
-	docker-compose ${DOCKER_COMPOSE_FLAGS} down
+	docker-compose ${DOCKER_COMPOSE_FLAGS} down -v
 
 ci:
 	MOCHA_ARGS="--reporter tap" \

+ 10 - 3
services/web/Makefile.module

@@ -1,13 +1,16 @@
 MODULE_NAME := $(notdir $(shell pwd))
 MODULE_DIR := modules/$(MODULE_NAME)
 COFFEE := ../../node_modules/.bin/coffee
-APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee') \
+APP_COFFEE_FILES := $(shell [ -e app/coffee ] && find app/coffee -name '*.coffee') \
 	$(shell [ -e test/unit/coffee ] && find test/unit/coffee -name '*.coffee') \
 	$(shell [ -e test/acceptance/coffee ] && find test/acceptance/coffee -name '*.coffee')
 APP_JS_FILES := $(subst coffee,js,$(APP_COFFEE_FILES))
 IDE_COFFEE_FILES := $(shell [ -e public/coffee/ide ] && find public/coffee/ide -name '*.coffee')
 IDE_JS_FILES := $(subst public/coffee/ide,../../public/js/ide/$(MODULE_NAME),$(IDE_COFFEE_FILES))
 IDE_JS_FILES := $(subst coffee,js,$(IDE_JS_FILES))
+IDE_TEST_COFFEE_FILES := $(shell [ -e test/unit_frontend/coffee ] && find test/unit_frontend/coffee -name '*.coffee')
+IDE_TEST_JS_FILES := $(subst test/unit_frontend/coffee/ide,../../test/unit_frontend/js/ide/$(MODULE_NAME),$(IDE_TEST_COFFEE_FILES))
+IDE_TEST_JS_FILES := $(subst coffee,js,$(IDE_TEST_JS_FILES))
 MAIN_COFFEE_FILES := $(shell [ -e public/coffee/main ] && find public/coffee/main -name '*.coffee')
 MAIN_JS_FILES := $(subst public/coffee/main,../../public/js/main/$(MODULE_NAME),$(MAIN_COFFEE_FILES))
 MAIN_JS_FILES := $(subst coffee,js,$(MAIN_JS_FILES))
@@ -26,6 +29,10 @@ test/acceptance/js/%.js: test/acceptance/coffee/%.coffee
 	@mkdir -p $(dir $@)
 	$(COFFEE) --compile --print $< > $@
 
+../../test/unit_frontend/js/ide/$(MODULE_NAME)/%.js: test/unit_frontend/coffee/ide/%.coffee
+	@mkdir -p $(dir $@)
+	$(COFFEE) --compile --print $< > $@
+
 ../../public/js/ide/$(MODULE_NAME)/%.js: public/coffee/ide/%.coffee
 	@mkdir -p $(dir $@)
 	$(COFFEE) --compile --print $< > $@
@@ -37,11 +44,11 @@ test/acceptance/js/%.js: test/acceptance/coffee/%.coffee
 index.js: index.coffee
 	$(COFFEE) --compile --print $< > $@
 
-compile: $(APP_JS_FILES) $(IDE_JS_FILES) $(MAIN_JS_FILES) index.js
+compile: $(APP_JS_FILES) $(IDE_JS_FILES) $(MAIN_JS_FILES) $(IDE_TEST_JS_FILES) index.js
 	@echo > /dev/null
 
 compile_full:
-	$(COFFEE) -o app/js -c app/coffee
+	if [ -e app/coffee ]; then $(COFFEE) -o app/js -c app/coffee; fi
 	if [ -e test/unit/coffee ]; then $(COFFEE) -o test/unit/js -c test/unit/coffee; fi
 	if [ -e test/acceptance/coffee ]; then $(COFFEE) -o test/acceptance/js -c test/acceptance/coffee; fi
 	if [ -e public/coffee/ide/ ]; then $(COFFEE) -o ../../public/js/ide/$(MODULE_NAME) -c public/coffee/ide/; fi

+ 6 - 6
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -17,17 +17,17 @@ makeFaultTolerantRequest = (userId, options, callback) ->
 	if settings.overleaf?
 		options.qs = Object.assign({}, options.qs, { fromV2: 1 })
 
-	makeRequest(options, callback)
+	makeRequest options, (err) ->
+		if err?
+			logger.err { err: err }, 'Request to analytics failed'
+
+	callback() # Do not wait for all the attempts
 
 makeRequest = (opts, callback)->
 	if settings.apis?.analytics?.url?
 		urlPath = opts.url
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
-		request opts, (err) ->
-			if err?
-				logger.err { err: err }, 'Request to analytics failed'
-
-		callback() # Do not wait for all the attempts
+		request opts, callback
 	else
 		callback(new Errors.ServiceNotConfiguredError('Analytics service not configured'))
 

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

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

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

@@ -121,15 +121,15 @@ module.exports = DocumentUpdaterHandler =
 			method: "DELETE"
 		}, project_id, "delete-thread", callback
 
-	resyncProjectHistory: (project_id, docs, files, callback) ->
+	resyncProjectHistory: (project_id, projectHistoryId, docs, files, callback) ->
 		logger.info {project_id, docs, files}, "resyncing project history in doc updater"
 		DocumentUpdaterHandler._makeRequest {
 			path: "/project/#{project_id}/history/resync"
-			json: { docs, files }
+			json: { docs, files, projectHistoryId }
 			method: "POST"
 		}, project_id, "resync-project-history", callback
 
-	updateProjectStructure : (project_id, userId, changes, callback = (error) ->)->
+	updateProjectStructure: (project_id, projectHistoryId, userId, changes, callback = (error) ->)->
 		return callback() if !settings.apis.project_history?.sendProjectStructureOps
 
 		Project.findOne {_id: project_id}, {version:true}, (err, currentProject) ->
@@ -144,7 +144,13 @@ module.exports = DocumentUpdaterHandler =
 			logger.log {project_id}, "updating project structure in doc updater"
 			DocumentUpdaterHandler._makeRequest {
 				path: "/project/#{project_id}"
-				json: { docUpdates, fileUpdates, userId, version: currentProject.version }
+				json: {
+					docUpdates,
+					fileUpdates,
+					userId,
+					version: currentProject.version
+					projectHistoryId
+				}
 				method: "POST"
 			}, project_id, "update-project-structure", callback
 
@@ -174,6 +180,23 @@ module.exports = DocumentUpdaterHandler =
 		oldEntitiesHash = _.indexBy oldEntities, (entity) -> entity[entityType]._id.toString()
 		newEntitiesHash = _.indexBy newEntities, (entity) -> entity[entityType]._id.toString()
 
+		# Send deletes before adds (and renames) to keep a 1:1 mapping between
+		# paths and ids
+		#
+		# When a file is replaced, we first delete the old file and then add the
+		# new file. If the 'add' operation is sent to project history before the
+		# 'delete' then we would have two files with the same path at that point
+		# in time.
+		for id, oldEntity of oldEntitiesHash
+			newEntity = newEntitiesHash[id]
+
+			if !newEntity?
+				# entity deleted
+				updates.push
+					id: id
+					pathname: oldEntity.path
+					newPathname: ''
+
 		for id, newEntity of newEntitiesHash
 			oldEntity = oldEntitiesHash[id]
 
@@ -191,16 +214,6 @@ 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"

+ 27 - 15
services/web/app/coffee/Features/Documents/DocumentController.coffee

@@ -1,3 +1,5 @@
+ProjectGetter = require "../Project/ProjectGetter"
+ProjectLocator = require "../Project/ProjectLocator"
 ProjectEntityHandler = require "../Project/ProjectEntityHandler"
 ProjectEntityUpdateHandler = require "../Project/ProjectEntityUpdateHandler"
 logger = require("logger-sharelatex")
@@ -8,21 +10,31 @@ module.exports =
 		doc_id = req.params.doc_id
 		plain = req?.query?.plain == 'true'
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
-		ProjectEntityHandler.getDoc project_id, doc_id, {pathname: true}, (error, lines, rev, version, ranges, pathname) ->
-			if error?
-				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
-				return next(error)
-			if plain
-				res.type "text/plain"
-				res.send lines.join('\n')
-			else
-				res.type "json"
-				res.send JSON.stringify {
-					lines: lines
-					version: version
-					ranges: ranges
-					pathname: pathname
-				}
+		ProjectGetter.getProject project_id, rootFolder: true, overleaf: true, (error, project) ->
+			return next(error) if error?
+			return res.sendStatus(404) if !project?
+			ProjectLocator.findElement {project: project, element_id: doc_id, type: 'doc'}, (error, doc, path) ->
+				if error?
+					logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
+					return next(error)
+				ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version, ranges) ->
+					if error?
+						logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding doc contents for getDocument"
+						return next(error)
+					if plain
+						res.type "text/plain"
+						res.send lines.join('\n')
+
+					else
+						projectHistoryId = project?.overleaf?.history?.id
+						res.type "json"
+						res.send JSON.stringify {
+							lines: lines
+							version: version
+							ranges: ranges
+							pathname: path.fileSystem
+							projectHistoryId: projectHistoryId
+						}
 
 	setDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id

+ 11 - 7
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -41,11 +41,13 @@ module.exports = EditorController =
 			callback err, doc
 
 	upsertFile: (project_id, folder_id, fileName, fsPath, linkedFileData, source, user_id, callback = (err, file) ->) ->
-		ProjectEntityUpdateHandler.upsertFile project_id, folder_id, fileName, fsPath, linkedFileData, user_id, (err, file, didAddFile) ->
+		ProjectEntityUpdateHandler.upsertFile project_id, folder_id, fileName, fsPath, linkedFileData, user_id, (err, newFile, didAddFile, existingFile) ->
 			return callback(err) if err?
-			if didAddFile
-				EditorRealTimeController.emitToRoom project_id, 'reciveNewFile', folder_id, file, source, linkedFileData
-			callback null, file
+			if not didAddFile # replacement, so remove the existing file from the client
+				EditorRealTimeController.emitToRoom project_id, 'removeEntity', existingFile._id, source
+			# now add the new file on the client
+			EditorRealTimeController.emitToRoom project_id, 'reciveNewFile', folder_id, newFile, source, linkedFileData
+			callback null, newFile
 
 	upsertDocWithPath: (project_id, elementPath, docLines, source, user_id, callback) ->
 		ProjectEntityUpdateHandler.upsertDocWithPath project_id, elementPath, docLines, source, user_id, (err, doc, didAddNewDoc, newFolders, lastFolder) ->
@@ -57,12 +59,14 @@ module.exports = EditorController =
 				callback()
 
 	upsertFileWithPath: (project_id, elementPath, fsPath, linkedFileData, source, user_id, callback) ->
-		ProjectEntityUpdateHandler.upsertFileWithPath project_id, elementPath, fsPath, linkedFileData, user_id, (err, file, didAddFile, newFolders, lastFolder) ->
+		ProjectEntityUpdateHandler.upsertFileWithPath project_id, elementPath, fsPath, linkedFileData, user_id, (err, newFile, didAddFile, existingFile, newFolders, lastFolder) ->
 			return callback(err) if err?
 			EditorController._notifyProjectUsersOfNewFolders project_id, newFolders, (err) ->
 				return callback(err) if err?
-				if didAddFile
-					EditorRealTimeController.emitToRoom project_id, 'reciveNewFile', lastFolder._id, file, source, linkedFileData
+				if not didAddFile # replacement, so remove the existing file from the client
+					EditorRealTimeController.emitToRoom project_id, 'removeEntity', existingFile._id, source
+				# now add the new file on the client
+				EditorRealTimeController.emitToRoom project_id, 'reciveNewFile', lastFolder._id, newFile, source, linkedFileData
 				callback()
 
 	addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)->

+ 0 - 17
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -57,26 +57,9 @@ module.exports = EditorHttpController =
 								privilegeLevel
 							)
 
-	restoreDoc: (req, res, next) ->
-		project_id = req.params.Project_id
-		doc_id = req.params.doc_id
-		name = req.body.name
-
-		if !name?
-			return res.sendStatus 400 # Malformed request
-
-		logger.log project_id: project_id, doc_id: doc_id, "restoring doc"
-		ProjectEntityUpdateHandler.restoreDoc project_id, doc_id, name, (err, doc, folder_id) =>
-			return next(error) if error?
-			EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc)
-			res.json {
-				doc_id: doc._id
-			}
-
 	_nameIsAcceptableLength: (name)->
 		return name? and name.length < 150 and name.length != 0
 
-
 	addDoc: (req, res, next) ->
 		project_id = req.params.Project_id
 		name = req.body.name

+ 0 - 2
services/web/app/coffee/Features/Editor/EditorRouter.coffee

@@ -14,8 +14,6 @@ module.exports =
 		webRouter.delete '/project/:Project_id/doc/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteDoc
 		webRouter.delete '/project/:Project_id/folder/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteFolder
 
-		webRouter.post   '/project/:Project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.restoreDoc
-
 		# Called by the real-time API to load up the current project state.
 		# This is a post request because it's more than just a getting of data. We take actions
 		# whenever a user joins a project, like updating the deleted status.

+ 27 - 0
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -6,6 +6,7 @@ Errors = require "../Errors/Errors"
 HistoryManager = require "./HistoryManager"
 ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
 ProjectEntityUpdateHandler = require "../Project/ProjectEntityUpdateHandler"
+RestoreManager = require "./RestoreManager"
 
 module.exports = HistoryController =
 	selectHistoryApi: (req, res, next = (error) ->) ->
@@ -71,3 +72,29 @@ module.exports = HistoryController =
 			return res.sendStatus(404) if error instanceof Errors.ProjectHistoryDisabledError
 			return next(error) if error?
 			res.sendStatus 204
+
+	restoreFileFromV2: (req, res, next) ->
+		{project_id} = req.params
+		{version, pathname} = req.body
+		user_id = AuthenticationController.getLoggedInUserId req
+		logger.log {project_id, version, pathname}, "restoring file from v2"
+		RestoreManager.restoreFileFromV2 user_id, project_id, version, pathname, (error, entity) ->
+			return next(error) if error?
+			res.json {
+				type: entity.type,
+				id: entity._id
+			}
+
+	restoreDocFromDeletedDoc: (req, res, next) ->
+		{project_id, doc_id} = req.params
+		{name} = req.body
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		if !name?
+			return res.sendStatus 400 # Malformed request
+		logger.log {project_id, doc_id, user_id}, "restoring doc from v1 deleted doc"
+		RestoreManager.restoreDocFromDeletedDoc user_id, project_id, doc_id, name, (err, doc) =>
+			return next(error) if error?
+			res.json {
+				doc_id: doc._id
+			}
+

+ 60 - 0
services/web/app/coffee/Features/History/RestoreManager.coffee

@@ -0,0 +1,60 @@
+Settings = require 'settings-sharelatex'
+Path = require 'path'
+FileWriter = require '../../infrastructure/FileWriter'
+FileSystemImportManager = require '../Uploads/FileSystemImportManager'
+ProjectEntityHandler = require '../Project/ProjectEntityHandler'
+ProjectLocator = require '../Project/ProjectLocator'
+EditorController = require '../Editor/EditorController'
+Errors = require '../Errors/Errors'
+moment = require 'moment'
+
+module.exports = RestoreManager =
+	restoreDocFromDeletedDoc: (user_id, project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
+		# This is the legacy method for restoring a doc from the SL track-changes/deletedDocs system.
+		# It looks up the deleted doc's contents, and then creates a new doc with the same content.
+		# We don't actually remove the deleted doc entry, just create a new one from its lines.
+		ProjectEntityHandler.getDoc project_id, doc_id, include_deleted: true, (error, lines) ->
+			return callback(error) if error?
+			addDocWithName = (name, callback) ->
+				EditorController.addDoc project_id, null, name, lines, 'restore', user_id, callback
+			RestoreManager._addEntityWithUniqueName addDocWithName, name, callback
+
+	restoreFileFromV2: (user_id, project_id, version, pathname, callback = (error, entity) ->) ->
+		RestoreManager._writeFileVersionToDisk project_id, version, pathname, (error, fsPath) ->
+			return callback(error) if error?
+			basename = Path.basename(pathname)
+			dirname = Path.dirname(pathname)
+			if dirname == '.' # no directory
+				dirname = ''
+			RestoreManager._findOrCreateFolder project_id, dirname, (error, parent_folder_id) ->
+				return callback(error) if error?
+				addEntityWithName = (name, callback) ->
+					FileSystemImportManager.addEntity user_id, project_id, parent_folder_id, name, fsPath, false, callback
+				RestoreManager._addEntityWithUniqueName addEntityWithName, basename, callback
+
+	_findOrCreateFolder: (project_id, dirname, callback = (error, folder_id) ->) ->
+		EditorController.mkdirp project_id, dirname, (error, newFolders, lastFolder) ->
+			return callback(error) if error?
+			return callback(null, lastFolder?._id)
+
+	_addEntityWithUniqueName: (addEntityWithName, basename, callback = (error) ->) ->
+		addEntityWithName basename, (error, entity) ->
+			if error?
+				if error instanceof Errors.InvalidNameError
+					# likely a duplicate name, so try with a prefix
+					date = moment(new Date()).format('Do MMM YY H:mm:ss')
+					# Move extension to the end so the file type is preserved
+					extension = Path.extname(basename)
+					basename = Path.basename(basename, extension)
+					basename = "#{basename} (Restored on #{date})"
+					if extension != ''
+						basename = "#{basename}#{extension}"
+					addEntityWithName basename, callback
+				else
+					callback(error)
+			else
+				callback(null, entity)
+
+	_writeFileVersionToDisk: (project_id, version, pathname, callback = (error, fsPath) ->) ->
+		url = "#{Settings.apis.project_history.url}/project/#{project_id}/version/#{version}/#{encodeURIComponent(pathname)}"
+		FileWriter.writeUrlToDisk project_id, url, callback

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

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

+ 7 - 0
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -35,10 +35,17 @@ module.exports = ProjectEditorHandler =
 			compileGroup:"standard"
 			templates: false
 			references: false
+			referencesSearch: false
+			mendeley: false
 			trackChanges: false
 			trackChangesVisible: ProjectEditorHandler.trackChangesAvailable
 		})
 
+		# Originally these two feature flags were both signalled by the now-deprecated `references` flag.
+		# For older users, the presence of the `references` feature flag should still turn on these features.
+		result.features.referencesSearch = result.features.referencesSearch or result.features.references
+		result.features.mendeley = result.features.mendeley or result.features.references
+
 		return result
 
 	buildOwnerAndMembersViews: (members) ->

+ 1 - 9
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -6,7 +6,6 @@ DocstoreManager = require "../Docstore/DocstoreManager"
 DocumentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler')
 Errors = require '../Errors/Errors'
 Project = require('../../models/Project').Project
-ProjectLocator = require('./ProjectLocator')
 ProjectGetter = require "./ProjectGetter"
 TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
 
@@ -105,14 +104,7 @@ module.exports = ProjectEntityHandler = self =
 			callback = options
 			options = {}
 
-		if options["pathname"]
-			delete options["pathname"]
-			ProjectLocator.findElement {project_id: project_id, element_id: doc_id, type: 'doc'}, (error, doc, path) =>
-				return callback(error) if error?
-				DocstoreManager.getDoc project_id, doc_id, options, (error, lines, rev, version, ranges) =>
-					callback(error, lines, rev, version, ranges, path.fileSystem)
-		else
-			DocstoreManager.getDoc project_id, doc_id, options, callback
+		DocstoreManager.getDoc project_id, doc_id, options, callback
 
 	_getAllFolders: (project_id,  callback) ->
 		logger.log project_id:project_id, "getting all folders for project"

+ 52 - 26
services/web/app/coffee/Features/Project/ProjectEntityMongoUpdateHandler.coffee

@@ -31,7 +31,7 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 	LOCK_NAMESPACE: LOCK_NAMESPACE
 
 	addDoc: wrapWithLock (project_id, folder_id, doc, callback = (err, result) ->) ->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true}, (err, project) ->
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (err, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add doc"
 				return callback(err)
@@ -40,7 +40,7 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 				self._putElement project, folder_id, doc, "doc", callback
 
 	addFile: wrapWithLock (project_id, folder_id, fileRef, callback = (error, result, project) ->)->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true}, (err, project) ->
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (err, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add file"
 				return callback(err)
@@ -48,28 +48,28 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 			self._confirmFolder project, folder_id, (folder_id)->
 				self._putElement project, folder_id, fileRef, "file", callback
 
-	replaceFile: wrapWithLock (project_id, file_id, linkedFileData, callback) ->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder: true, name:true}, (err, project) ->
+	replaceFileWithNew: wrapWithLock (project_id, file_id, newFileRef, callback) ->
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (err, project) ->
 			return callback(err) if err?
 			ProjectLocator.findElement {project:project, element_id: file_id, type: 'file'}, (err, fileRef, path)=>
 				return callback(err) if err?
-				conditions = _id:project._id
-				inc = {}
-				inc["#{path.mongo}.rev"] = 1
-				# currently we do not need to increment the project version number for changes that are replacements
-				# but when we make switch to having immutable files the replace operation will add a new file, and
-				# this will require a version increase.  We will start incrementing the project version now as it does
-				# no harm and will help to test it.
-				inc['version'] = 1
-				set = {}
-				set["#{path.mongo}.created"] = new Date()
-				set["#{path.mongo}.linkedFileData"] = linkedFileData
-				update =
-					"$inc": inc
-					"$set": set
-				Project.update conditions, update, {}, (err) ->
+				ProjectEntityMongoUpdateHandler._insertDeletedFileReference project_id, fileRef, (err) ->
 					return callback(err) if err?
-					callback null, fileRef, project, path
+					conditions = _id:project._id
+					inc = {}
+					# increment the project structure version as we are adding a new file here
+					inc['version'] = 1
+					set = {}
+					set["#{path.mongo}._id"] = newFileRef._id
+					set["#{path.mongo}.created"] = new Date()
+					set["#{path.mongo}.linkedFileData"] = newFileRef.linkedFileData
+					set["#{path.mongo}.rev"] = 1
+					update =
+						"$inc": inc
+						"$set": set
+					Project.update conditions, update, {}, (err) ->
+						return callback(err) if err?
+						callback null, fileRef, project, path
 
 	mkdirp: wrapWithLock (project_id, path, callback) ->
 		folders = path.split('/')
@@ -110,7 +110,7 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 				callback null, folders, lastFolder
 
 	moveEntity: wrapWithLock (project_id, entity_id, destFolderId, entityType, callback = (error) ->) ->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true}, (err, project) ->
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (err, project) ->
 			return callback(err) if err?
 			ProjectLocator.findElement {project, element_id: entity_id, type: entityType}, (err, entity, entityPath)->
 				return callback(err) if err?
@@ -127,10 +127,10 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 									startPath = entityPath.fileSystem
 									endPath = result.path.fileSystem
 									changes = {oldDocs, newDocs, oldFiles, newFiles}
-									callback null, project.name, startPath, endPath, entity.rev, changes, callback
+									callback null, project, startPath, endPath, entity.rev, changes, callback
 
 	deleteEntity: wrapWithLock (project_id, entity_id, entityType, callback) ->
-		ProjectGetter.getProjectWithoutLock project_id, {name:true, rootFolder:true}, (error, project) ->
+		ProjectGetter.getProjectWithoutLock project_id, {name:true, rootFolder:true, overleaf:true}, (error, project) ->
 			return callback(error) if error?
 			ProjectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path) ->
 				return callback(error) if error?
@@ -139,7 +139,7 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 					callback null, entity, path, project
 
 	renameEntity: wrapWithLock (project_id, entity_id, entityType, newName, callback) ->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true}, (error, project)=>
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (error, project)=>
 			return callback(error) if error?
 			ProjectEntityHandler.getAllEntitiesFromProject project, (error, oldDocs, oldFiles) =>
 				return callback(error) if error?
@@ -161,10 +161,10 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 								return callback(error) if error?
 								startPath = entPath.fileSystem
 								changes = {oldDocs, newDocs, oldFiles, newFiles}
-								callback null, project.name, startPath, endPath, entity.rev, changes, callback
+								callback null, project, startPath, endPath, entity.rev, changes, callback
 
 	addFolder: wrapWithLock (project_id, parentFolder_id, folderName, callback) ->
-		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true}, (err, project) ->
+		ProjectGetter.getProjectWithoutLock project_id, {rootFolder:true, name:true, overleaf:true}, (err, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add folder"
 				return callback(err)
@@ -300,3 +300,29 @@ module.exports = ProjectEntityMongoUpdateHandler = self =
 					if isNestedFolder
 						return callback(new Errors.InvalidNameError("destination folder is a child folder of me"))
 				callback()
+
+	_insertDeletedDocReference: (project_id, doc, callback = (error) ->) ->
+		Project.update {
+			_id: project_id
+		}, {
+			$push: {
+				deletedDocs: {
+					_id:  doc._id
+					name: doc.name
+				}
+			}
+		}, {}, callback
+
+	_insertDeletedFileReference: (project_id, fileRef, callback = (error) ->) ->
+		Project.update {
+			_id: project_id
+		}, {
+			$push: {
+				deletedFiles: {
+					_id:  fileRef._id
+					name: fileRef.name
+					linkedFileData: fileRef.linkedFileData
+					deletedAt: new Date()
+				}
+			}
+		}, {}, callback

+ 164 - 105
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -24,17 +24,31 @@ wrapWithLock = (methodWithoutLock) ->
 	# This lock is used to make sure that the project structure updates are made
 	# sequentially. In particular the updates must be made in mongo and sent to
 	# the doc-updater in the same order.
-	methodWithLock = (project_id, args..., callback) ->
-		LockManager.runWithLock LOCK_NAMESPACE, project_id,
-			(cb) -> methodWithoutLock project_id, args..., cb
-			callback
-	methodWithLock.withoutLock = methodWithoutLock
-	methodWithLock
+	if typeof methodWithoutLock is 'function'
+		methodWithLock = (project_id, args..., callback) ->
+			LockManager.runWithLock LOCK_NAMESPACE, project_id,
+				(cb) -> methodWithoutLock project_id, args..., cb
+				callback
+		methodWithLock.withoutLock = methodWithoutLock
+		methodWithLock
+	else
+		# handle case with separate setup and locked stages
+		wrapWithSetup = methodWithoutLock.beforeLock  # a function to set things up before the lock
+		mainTask = methodWithoutLock.withLock # function to execute inside the lock
+		methodWithLock = wrapWithSetup (project_id, args..., callback) ->
+			LockManager.runWithLock(LOCK_NAMESPACE, project_id, (cb) ->
+				mainTask(project_id, args..., cb)
+			callback)
+		methodWithLock.withoutLock = wrapWithSetup mainTask
+		methodWithLock.beforeLock = methodWithoutLock.beforeLock
+		methodWithLock.mainTask = methodWithoutLock.withLock
+		methodWithLock
 
 module.exports = ProjectEntityUpdateHandler = self =
 	# this doesn't need any locking because it's only called by ProjectDuplicator
 	copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
 		project_id = project._id
+		projectHistoryId = project.overleaf?.history?.id
 		logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
 		return callback(err) if err?
 		ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=>
@@ -59,7 +73,7 @@ module.exports = ProjectEntityUpdateHandler = self =
 							path: result?.path?.fileSystem
 							url: fileStoreUrl
 						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
+						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
 							return callback(error) if error?
 							callback null, fileRef, folder_id
 
@@ -108,52 +122,87 @@ module.exports = ProjectEntityUpdateHandler = self =
 		logger.log project_id: project_id, "removing root doc"
 		Project.update {_id:project_id}, {$unset: {rootDoc_id: true}}, {}, callback
 
-	restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
-		if not SafePath.isCleanFilename name
-			return callback new Errors.InvalidNameError("invalid element name")
-		# getDoc will return the deleted doc's lines, but we don't actually remove
-		# the deleted doc, just create a new one from its lines.
-		ProjectEntityHandler.getDoc project_id, doc_id, include_deleted: true, (error, lines) ->
-			return callback(error) if error?
-			self.addDoc project_id, null, name, lines, callback
-
 	addDoc: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
-		self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path) ->
+		self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path, project) ->
 			return callback(error) if error?
+			projectHistoryId = project.overleaf?.history?.id
 			newDocs = [
 				doc: doc
 				path: path
 				docLines: docLines.join('\n')
 			]
-			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) ->
+			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newDocs}, (error) ->
 				return callback(error) if error?
 				callback null, doc, folder_id
 
-	addFile: wrapWithLock (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback = (error, fileRef, folder_id) ->)->
-		self.addFileWithoutUpdatingHistory.withoutLock project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, folder_id, path, fileStoreUrl) ->
-			return callback(error) if error?
-			newFiles = [
-				file: fileRef
-				path: path
-				url: fileStoreUrl
-			]
-			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
-				return callback(error) if error?
-				callback null, fileRef, folder_id
+	_uploadFile: (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback = (error, fileRef, fileStoreUrl) ->)->
+		if not SafePath.isCleanFilename fileName
+			return callback new Errors.InvalidNameError("invalid element name")
+		fileRef = new File(
+			name: fileName
+			linkedFileData: linkedFileData
+		)
+		FileStoreHandler.uploadFileFromDisk project_id, fileRef._id, fsPath, (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)
+			callback(null, fileRef, fileStoreUrl)
 
-	replaceFile: wrapWithLock (project_id, file_id, fsPath, linkedFileData, userId, callback)->
-		FileStoreHandler.uploadFileFromDisk project_id, file_id, fsPath, (err, fileStoreUrl)->
-			return callback(err) if err?
-			ProjectEntityMongoUpdateHandler.replaceFile project_id, file_id, linkedFileData, (err, fileRef, project, path) ->
+	_addFileAndSendToTpds: (project_id, folder_id, fileName, fileRef, callback = (error) ->)->
+		ProjectEntityMongoUpdateHandler.addFile project_id, folder_id, fileRef, (err, result, project) ->
+			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, result, project)
+
+	addFile: wrapWithLock
+		beforeLock: (next) ->
+			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+				ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
+					return callback(error) if error?
+					next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
+		withLock: (project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)->
+			ProjectEntityUpdateHandler._addFileAndSendToTpds project_id, folder_id, fileName, fileRef, (err, result, project) ->
 				return callback(err) if err?
+				projectHistoryId = project.overleaf?.history?.id
 				newFiles = [
 					file: fileRef
+					path: result?.path?.fileSystem
+					url: fileStoreUrl
+				]
+				DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
+					return callback(error) if error?
+					callback(null, fileRef, folder_id)
+
+	replaceFile: wrapWithLock
+		beforeLock: (next) ->
+			(project_id, file_id, fsPath, linkedFileData, userId, callback)->
+				# create a new file
+				fileRef = new File(
+					name: "dummy-upload-filename"
+					linkedFileData: linkedFileData
+				)
+				FileStoreHandler.uploadFileFromDisk project_id, fileRef._id, fsPath, (err, fileStoreUrl)->
+					return callback(err) if err?
+					next project_id, file_id, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback
+		withLock: (project_id, file_id, fsPath, linkedFileData, userId, newFileRef, fileStoreUrl, callback)->
+			ProjectEntityMongoUpdateHandler.replaceFileWithNew project_id, file_id, newFileRef, (err, oldFileRef, project, path) ->
+				return callback(err) if err?
+				oldFiles = [
+					file: oldFileRef
+					path: path.fileSystem
+				]
+				newFiles = [
+					file: newFileRef
 					path: path.fileSystem
 					url: fileStoreUrl
 				]
-				TpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:path.fileSystem, rev:fileRef.rev+1, project_name:project.name}, (err) ->
+				projectHistoryId = project.overleaf?.history?.id
+				TpdsUpdateSender.addFile {project_id:project._id, file_id:newFileRef._id, path:path.fileSystem, rev:newFileRef.rev+1, project_name:project.name}, (err) ->
 					return callback(err) if err?
-					DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, callback
+					DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {oldFiles, newFiles}, callback
 
 	addDocWithoutUpdatingHistory: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
 		# This method should never be called directly, except when importing a project
@@ -178,31 +227,21 @@ module.exports = ProjectEntityUpdateHandler = self =
 					rev:          0
 				}, (err) ->
 					return callback(err) if err?
-					callback(null, doc, folder_id, result?.path?.fileSystem)
+					callback(null, doc, folder_id, result?.path?.fileSystem, project)
 
-	addFileWithoutUpdatingHistory: wrapWithLock (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback = (error, fileRef, folder_id, path, fileStoreUrl) ->)->
+	addFileWithoutUpdatingHistory: wrapWithLock
 		# 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.
-
-		if not SafePath.isCleanFilename fileName
-			return callback new Errors.InvalidNameError("invalid element name")
-
-		fileRef = new File(
-			name: fileName
-			linkedFileData: linkedFileData
-		)
-		FileStoreHandler.uploadFileFromDisk project_id, fileRef._id, fsPath, (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)
-			ProjectEntityMongoUpdateHandler.addFile project_id, folder_id, fileRef, (err, result, project) ->
-				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)
+		beforeLock: (next) ->
+			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+				ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
+					return callback(error) if error?
+					next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
+		withLock: (project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id, path, fileStoreUrl) ->)->
+			ProjectEntityUpdateHandler._addFileAndSendToTpds project_id, folder_id, fileName, fileRef, (err, result, project) ->
+				return callback(err) if err?
+				callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl)
 
 	upsertDoc: wrapWithLock (project_id, folder_id, docName, docLines, source, userId, callback = (err, doc, folder_id, isNewDoc)->)->
 		ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
@@ -224,23 +263,36 @@ module.exports = ProjectEntityUpdateHandler = self =
 					return callback(err) if err?
 					callback null, doc, !existingDoc?
 
-	upsertFile: wrapWithLock (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback = (err, file, isNewFile)->)->
-		ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
-			return callback(error) if error?
-			return callback(new Error("Couldn't find folder")) if !folder?
-			existingFile = null
-			for fileRef in folder.fileRefs
-				if fileRef.name == fileName
-					existingFile = fileRef
-					break
-			if existingFile?
-				self.replaceFile.withoutLock project_id, existingFile._id, fsPath, linkedFileData, userId, (err) ->
-					return callback(err) if err?
-					callback null, existingFile, !existingFile?
-			else
-				self.addFile.withoutLock project_id, folder_id, fileName, fsPath, linkedFileData, userId, (err, file) ->
+	upsertFile: wrapWithLock
+		beforeLock: (next) ->
+			(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback)->
+				# create a new file
+				fileRef = new File(
+					name: fileName
+					linkedFileData: linkedFileData
+				)
+				FileStoreHandler.uploadFileFromDisk project_id, fileRef._id, fsPath, (err, fileStoreUrl)->
 					return callback(err) if err?
-					callback null, file, !existingFile?
+					next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
+		withLock: (project_id, folder_id, fileName, fsPath, linkedFileData, userId, newFileRef, fileStoreUrl, callback = (err, file, isNewFile, existingFile)->)->
+			ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
+				return callback(error) if error?
+				return callback(new Error("Couldn't find folder")) if !folder?
+				existingFile = null
+				for fileRef in folder.fileRefs
+					if fileRef.name == fileName
+						existingFile = fileRef
+						break
+				if existingFile?
+					# this calls directly into the replaceFile main task (without the beforeLock part)
+					self.replaceFile.mainTask project_id, existingFile._id, fsPath, linkedFileData, userId, newFileRef, fileStoreUrl, (err) ->
+						return callback(err) if err?
+						callback null, newFileRef, !existingFile?, existingFile
+				else
+					# this calls directly into the addFile main task (without the beforeLock part)
+					self.addFile.mainTask project_id, folder_id, fileName, fsPath, linkedFileData, userId, newFileRef, fileStoreUrl, (err) ->
+						return callback(err) if err?
+						callback null, newFileRef, !existingFile?, existingFile
 
 	upsertDocWithPath: wrapWithLock (project_id, elementPath, docLines, source, userId, callback) ->
 		docName = path.basename(elementPath)
@@ -251,14 +303,26 @@ module.exports = ProjectEntityUpdateHandler = self =
 				return callback(err) if err?
 				callback null, doc, isNewDoc, newFolders, folder
 
-	upsertFileWithPath: wrapWithLock (project_id, elementPath, fsPath, linkedFileData, userId, callback) ->
-		fileName = path.basename(elementPath)
-		folderPath = path.dirname(elementPath)
-		self.mkdirp.withoutLock project_id, folderPath, (err, newFolders, folder) ->
-			return callback(err) if err?
-			self.upsertFile.withoutLock project_id, folder._id, fileName, fsPath, linkedFileData, userId, (err, file, isNewFile) ->
+	upsertFileWithPath: wrapWithLock
+		beforeLock: (next) ->
+			(project_id, elementPath, fsPath, linkedFileData, userId, callback)->
+				fileName = path.basename(elementPath)
+				folderPath = path.dirname(elementPath)
+				# create a new file
+				fileRef = new File(
+					name: fileName
+					linkedFileData: linkedFileData
+				)
+				FileStoreHandler.uploadFileFromDisk project_id, fileRef._id, fsPath, (err, fileStoreUrl)->
+					return callback(err) if err?
+					next project_id, folderPath, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback
+		withLock: (project_id, folderPath, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback) ->
+			self.mkdirp.withoutLock project_id, folderPath, (err, newFolders, folder) ->
 				return callback(err) if err?
-				callback null, file, isNewFile, newFolders, folder
+				# this calls directly into the upsertFile main task (without the beforeLock part)
+				self.upsertFile.mainTask project_id, folder._id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, (err, newFile, isNewFile, existingFile) ->
+					return callback(err) if err?
+					callback null, newFile, isNewFile, existingFile, newFolders, folder
 
 	deleteEntity: wrapWithLock (project_id, entity_id, entityType, userId, callback = (error) ->)->
 		logger.log entity_id:entity_id, entityType:entityType, project_id:project_id, "deleting project entity"
@@ -294,10 +358,11 @@ module.exports = ProjectEntityUpdateHandler = self =
 			logger.err {err: "No entityType set", project_id, entity_id}
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
-		ProjectEntityMongoUpdateHandler.moveEntity project_id, entity_id, destFolderId, entityType, (err, project_name, startPath, endPath, rev, changes) ->
+		ProjectEntityMongoUpdateHandler.moveEntity project_id, entity_id, destFolderId, entityType, (err, project, startPath, endPath, rev, changes) ->
 			return callback(err) if err?
-			TpdsUpdateSender.moveEntity { project_id, project_name, startPath, endPath, rev }
-			DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
+			projectHistoryId = project.overleaf?.history?.id
+			TpdsUpdateSender.moveEntity { project_id, project_name: project.name, startPath, endPath, rev }
+			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	renameEntity: wrapWithLock (project_id, entity_id, entityType, newName, userId, callback)->
 		if not SafePath.isCleanFilename newName
@@ -308,10 +373,11 @@ module.exports = ProjectEntityUpdateHandler = self =
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
 
-		ProjectEntityMongoUpdateHandler.renameEntity project_id, entity_id, entityType, newName, (err, project_name, startPath, endPath, rev, changes) ->
+		ProjectEntityMongoUpdateHandler.renameEntity project_id, entity_id, entityType, newName, (err, project, startPath, endPath, rev, changes) ->
 			return callback(err) if err?
-			TpdsUpdateSender.moveEntity({project_id, startPath, endPath, project_name, rev})
-			DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
+			projectHistoryId = project.overleaf?.history?.id
+			TpdsUpdateSender.moveEntity { project_id, project_name: project.name, startPath, endPath, rev }
+			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	# This doesn't directly update project structure but we need to take the lock
 	# to prevent anything else being queued before the resync update
@@ -319,7 +385,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 		ProjectGetter.getProject project_id, rootFolder: true, overleaf: true, (error, project) ->
 			return callback(error) if error?
 
-			if !project?.overleaf?.history?.id?
+			projectHistoryId = project?.overleaf?.history?.id
+			if !projectHistoryId?
 				error = new Errors.ProjectHistoryDisabledError("project history not enabled for #{project_id}")
 				return callback(error)
 
@@ -335,7 +402,8 @@ module.exports = ProjectEntityUpdateHandler = self =
 					path: file.path
 					url: FileStoreHandler._buildUrl(project_id, file.file._id)
 
-				DocumentUpdaterHandler.resyncProjectHistory project_id, docs, files, callback 
+				DocumentUpdaterHandler.resyncProjectHistory project_id, projectHistoryId, docs, files, callback
+
 	_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
 		if(entityType.indexOf("file") != -1)
 			self._cleanUpFile project, entity, path, userId, callback
@@ -357,22 +425,25 @@ module.exports = ProjectEntityUpdateHandler = self =
 
 		unsetRootDocIfRequired (error) ->
 			return callback(error) if error?
-			self._insertDeletedDocReference project._id, doc, (error) ->
+			ProjectEntityMongoUpdateHandler._insertDeletedDocReference project._id, doc, (error) ->
 				return callback(error) if error?
 				DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) ->
 					return callback(error) if error?
 					DocstoreManager.deleteDoc project_id, doc_id, (error) ->
 						return callback(error) if error?
 						changes = oldDocs: [ {doc, path} ]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
+						projectHistoryId = project.overleaf?.history?.id
+						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
-		project_id = project._id.toString()
-		file_id = file._id.toString()
-		FileStoreHandler.deleteFile project_id, file_id, (error) ->
+		ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, (error) ->
 			return callback(error) if error?
+			project_id = project._id.toString()
+			projectHistoryId = project.overleaf?.history?.id
 			changes = oldFiles: [ {file, path} ]
-			DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
+			# we are now keeping a copy of every file versio so we no longer delete
+			# the file from the filestore
+			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
 		jobs = []
@@ -392,15 +463,3 @@ module.exports = ProjectEntityUpdateHandler = self =
 				jobs.push (callback) -> self._cleanUpFolder project, childFolder, folderPath, userId, callback
 
 		async.series jobs, callback
-
-	_insertDeletedDocReference: (project_id, doc, callback = (error) ->) ->
-		Project.update {
-			_id: project_id
-		}, {
-			$push: {
-				deletedDocs: {
-					_id:  doc._id
-					name: doc.name
-				}
-			}
-		}, {}, callback

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

@@ -15,6 +15,7 @@ htmlEncoder = new require("node-html-encoder").Encoder("numerical")
 hashedFiles = {}
 Path = require 'path'
 Features = require "./Features"
+Modules = require "./Modules"
 
 jsPath =
 	if Settings.useMinifiedJs
@@ -41,10 +42,9 @@ pathList = [
 	"#{jsPath}ide.js"
 	"#{jsPath}main.js"
 	"#{jsPath}libraries.js"
-	"#{jsPath}es/richText.js"
 	"/stylesheets/style.css"
 	"/stylesheets/ol-style.css"
-]
+].concat(Modules.moduleAssetFiles(jsPath))
 
 if !Settings.useMinifiedJs 
 	logger.log "not using minified JS, not hashing static files"
@@ -150,6 +150,8 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		res.locals.buildWebpackPath = (jsFile, opts = {}) ->
 			if Settings.webpack? and !Settings.useMinifiedJs
 				path = Path.join(jsPath, jsFile)
+				if opts.removeExtension == true
+					path = path.slice(0,-3)
 				return "#{Settings.webpack.url}/public#{path}"
 			else
 				return res.locals.buildJsPath(jsFile, opts)
@@ -309,11 +311,11 @@ 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
+			defaultResizerSizeOpen     : if isOl then 7 else 24
+			defaultResizerSizeClosed   : if isOl then 7 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
+			chatResizerSizeOpen        : if isOl then 7 else 12
 			chatResizerSizeClosed      : 0
 			chatMessageBorderSaturation: if isOl then "85%" else "70%"
 			chatMessageBorderLightness : if isOl then "40%" else "70%"

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

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

+ 31 - 12
services/web/app/coffee/infrastructure/FileWriter.coffee

@@ -3,21 +3,40 @@ logger = require 'logger-sharelatex'
 uuid = require 'uuid'
 _ = require 'underscore'
 Settings = require 'settings-sharelatex'
+request = require 'request'
 
-module.exports = 
+module.exports = FileWriter =
 	writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
 
-		writeStream = fs.createWriteStream(fsPath)
-		stream.pipe(writeStream)
+		stream.pause()
+		fs.mkdir Settings.path.dumpFolder, (error) ->
+			stream.resume()
+			if error? and error.code != 'EEXIST'
+				# Ignore error about already existing
+				return callback(error)
 
-		stream.on 'error', (err)->
-			logger.err {err, identifier, fsPath},	"[writeStreamToDisk] something went wrong with incoming stream"
-			callback(err)
-		writeStream.on 'error', (err)->
-			logger.err {err, identifier, fsPath},	"[writeStreamToDisk] something went wrong with writing to disk"
-			callback(err)
-		writeStream.on "finish", ->
-			logger.log {identifier, fsPath}, "[writeStreamToDisk] write stream finished"
-			callback null, fsPath
+			writeStream = fs.createWriteStream(fsPath)
+			stream.pipe(writeStream)
+
+			stream.on 'error', (err)->
+				logger.err {err, identifier, fsPath},	"[writeStreamToDisk] something went wrong with incoming stream"
+				callback(err)
+			writeStream.on 'error', (err)->
+				logger.err {err, identifier, fsPath},	"[writeStreamToDisk] something went wrong with writing to disk"
+				callback(err)
+			writeStream.on "finish", ->
+				logger.log {identifier, fsPath}, "[writeStreamToDisk] write stream finished"
+				callback null, fsPath
+
+	writeUrlToDisk: (identifier, url, callback = (error, fsPath) ->) ->
+		callback = _.once(callback)
+		stream = request.get(url)
+		stream.on 'response', (response) ->
+			if 200 <= response.statusCode < 300
+				FileWriter.writeStreamToDisk identifier, stream, callback
+			else
+				err = new Error("bad response from url: #{response.statusCode}")
+				logger.err {err, identifier, url}, err.message
+				callback(err)

+ 4 - 0
services/web/app/coffee/infrastructure/LoggerSerializers.coffee

@@ -22,6 +22,8 @@ module.exports =
 		}
 
 	docs: (docs) ->
+		if !docs?.map?
+			return
 		docs.map (doc) ->
 			{
 				path: doc.path
@@ -29,6 +31,8 @@ module.exports =
 			}
 
 	files: (files) ->
+		if !files?.map?
+			return
 		files.map (file) ->
 			{
 				path: file.path

+ 7 - 0
services/web/app/coffee/infrastructure/Modules.coffee

@@ -43,6 +43,13 @@ module.exports = Modules =
 	moduleIncludesAvailable: (view) ->
 		return (Modules.viewIncludes[view] or []).length > 0
 	
+	moduleAssetFiles: (pathPrefix) ->
+		assetFiles = []
+		for module in @modules
+			for assetFile in module.assetFiles or []
+				assetFiles.push "#{pathPrefix}#{assetFile}"
+		return assetFiles
+
 	attachHooks: () ->
 		for module in @modules
 			if module.hooks?

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

@@ -12,6 +12,10 @@ ObjectId = Schema.ObjectId
 DeletedDocSchema = new Schema
 	name: String
 
+DeletedFileSchema = new Schema
+	name: String
+	deletedAt: {type: Date}
+
 ProjectSchema = new Schema
 	name              :   {type:String, default:'new project'}
 	lastUpdated       :   {type:Date, default: () -> new Date()}
@@ -30,6 +34,7 @@ ProjectSchema = new Schema
 	description : {type:String, default:''}
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
+	deletedFiles      : [DeletedFileSchema]
 	imageName         : { type: String }
 	track_changes     : { type: Object }
 	tokens            :

+ 31 - 29
services/web/app/coffee/models/User.coffee

@@ -20,40 +20,42 @@ UserSchema = new Schema
 	loginCount        : {type : Number, default: 0}
 	holdingAccount    : {type : Boolean, default: false}
 	ace               : {
-							mode        :   {type : String, default: 'none'}
-							theme       :   {type : String, default: 'textmate'}
-							fontSize    :   {type : Number, default:'12'}
-							autoComplete:   {type : Boolean, default: true}
-							autoPairDelimiters: {type : Boolean, default: true}
-							spellCheckLanguage :   {type : String, default: "en"}
-							pdfViewer   :   {type : String, default: "pdfjs"}
-							syntaxValidation   :   {type : Boolean}
-						}
+		mode        :   {type : String, default: 'none'}
+		theme       :   {type : String, default: 'textmate'}
+		fontSize    :   {type : Number, default:'12'}
+		autoComplete:   {type : Boolean, default: true}
+		autoPairDelimiters: {type : Boolean, default: true}
+		spellCheckLanguage :   {type : String, default: "en"}
+		pdfViewer   :   {type : String, default: "pdfjs"}
+		syntaxValidation   :   {type : Boolean}
+	}
 	features		  : {
-							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }
-							versioning:    { type:Boolean, default: Settings.defaultFeatures.versioning }
-							dropbox:       { type:Boolean, default: Settings.defaultFeatures.dropbox }
-							github:        { type:Boolean, default: Settings.defaultFeatures.github }
-							compileTimeout: { type:Number, default: Settings.defaultFeatures.compileTimeout }
-							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
-							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
-							references:    { type:Boolean, default: Settings.defaultFeatures.references }
-							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
-						}
+		collaborators:    { type:Number,  default: Settings.defaultFeatures.collaborators }
+		versioning:       { type:Boolean, default: Settings.defaultFeatures.versioning }
+		dropbox:          { type:Boolean, default: Settings.defaultFeatures.dropbox }
+		github:           { type:Boolean, default: Settings.defaultFeatures.github }
+		compileTimeout:   { type:Number, default: Settings.defaultFeatures.compileTimeout }
+		compileGroup:     { type:String,  default: Settings.defaultFeatures.compileGroup }
+		templates:        { type:Boolean, default: Settings.defaultFeatures.templates }
+		references:       { type:Boolean, default: Settings.defaultFeatures.references }
+		trackChanges:     { type:Boolean, default: Settings.defaultFeatures.trackChanges }
+		mendeley:         { type:Boolean, default: Settings.defaultFeatures.mendeley }
+		referencesSearch: { type:Boolean, default: Settings.defaultFeatures.referencesSearch }
+	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	refered_user_count: { type:Number, default: 0 }
 	subscription:
-					recurlyToken : String
-					freeTrialExpiresAt: Date
-					freeTrialDowngraded: Boolean
-					freeTrialPlanCode: String
-					# This is poorly named. It does not directly correspond
-					# to whether the user has has a free trial, but rather
-					# whether they should be allowed one in the future.
-					# For example, a user signing up directly for a paid plan
-					# has this set to true, despite never having had a free trial
-					hadFreeTrial: {type: Boolean, default: false}
+		recurlyToken : String
+		freeTrialExpiresAt: Date
+		freeTrialDowngraded: Boolean
+		freeTrialPlanCode: String
+		# This is poorly named. It does not directly correspond
+		# to whether the user has has a free trial, but rather
+		# whether they should be allowed one in the future.
+		# For example, a user signing up directly for a paid plan
+		# has this set to true, despite never having had a free trial
+		hadFreeTrial: {type: Boolean, default: false}
 	refProviders: {
 		mendeley: Boolean  # coerce the refProviders values to Booleans
 		zotero: Boolean

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

@@ -201,8 +201,11 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.get  "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
 		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
+		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
 
+
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 
@@ -261,9 +264,10 @@ module.exports = class Router
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 
-		webRouter.get "/beta/participate",  AuthenticationController.requireLogin(), BetaProgramController.optInPage
-		webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
-		webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
+		# disable beta program while v2 is in beta
+		# webRouter.get "/beta/participate",  AuthenticationController.requireLogin(), BetaProgramController.optInPage
+		# webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
+		# webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
 		webRouter.get "/confirm-password", AuthenticationController.requireLogin(), SudoModeController.sudoModePrompt
 		webRouter.post "/confirm-password", AuthenticationController.requireLogin(), SudoModeController.submitPassword
 

+ 2 - 17
services/web/app/views/general/500.pug

@@ -4,7 +4,7 @@ html.full-height(itemscope, itemtype='http://schema.org/Product')
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
 		if buildCssPath
-			link(rel='stylesheet', href=buildCssPath('/style.css'))
+			link(rel="stylesheet", href=buildCssPath("/" + settings.brandPrefix + "style.css"))
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 	body.full-height
 		.content.full-height
@@ -18,19 +18,4 @@ html.full-height(itemscope, itemtype='http://schema.org/Product')
 					.error-details
 						p.error-status Something went wrong, sorry.
 						p.error-description Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
-						a.error-btn(href="/") Home
-		//- .content
-		//- 	.container
-		//- 		.row
-		//- 			.col-md-8.col-md-offset-2.text-center
-		//- 				.page-header
-		//- 					h2 Oh dear, something went wrong.
-		//- 				if buildImgPath
-		//- 					p
-		//- 						img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
-		//- 				p
-		//- 					| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
-		//- 				p
-		//- 					a(href="/")
-		//- 						i.fa.fa-arrow-circle-o-left
-		//- 						|  Take me home
+						a.error-btn(href="/") Home

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

@@ -21,7 +21,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		link(rel="icon", href="/" + settings.brandPrefix + "favicon.ico")
 		link(rel="icon", sizes="192x192", href="/" + settings.brandPrefix + "touch-icon-192x192.png")
 		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="mask-icon", href="/" + settings.brandPrefix + "mask-favicon.svg", color=settings.brandPrefix === 'ol-' ? "#4f9c45" : "#a93529")
 			
 		link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css", {hashedPath:true}))
 
@@ -48,7 +48,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 				(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
 				m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
 				})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
-				ga('create', '#{gaToken}', 'sharelatex.com');
+				ga('create', '#{gaToken}', '#{settings.cookieDomain.replace(/^\./, "")}');
 				ga('send', 'pageview');
 		- else
 			script(type='text/javascript').
@@ -111,22 +111,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 				data-badge="inline"
 			)
 
-		- if(typeof(suppressSystemMessages) == "undefined")
-			.system-messages(
-				ng-cloak
-				ng-controller="SystemMessagesController"
-			)
-				.system-message(
-					ng-repeat="message in messages"
-					ng-controller="SystemMessageController"
-					ng-hide="hidden"
-				)
-					a(href, ng-click="hide()").pull-right &times;
-					.system-message-content(ng-bind-html="htmlContent")
-	
-			include translations/translation_message
-
-
 		- if(typeof(suppressNavbar) == "undefined")
 			include layout/navbar
 

+ 8 - 7
services/web/app/views/project/editor.pug

@@ -55,13 +55,18 @@ block content
 				include ./editor/header
 
 				include ./editor/share
+				!= moduleIncludes("publish:body", locals)
 
-				#ide-body(
+				main#ide-body(
 					ng-cloak,
+					role="main",
 					layout="main",
 					ng-hide="state.loading",
 					resize-on="layout:chat:resize",
 					minimum-restore-size-west="130"
+					custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false"
+					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false"
+					custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? "'" + translate("tooltip_show_filetree") + "'" : "false"
 				)
 					.ui-layout-west
 						include ./editor/file-tree
@@ -133,6 +138,8 @@ block requirejs
 				"fineuploader": "libs/#{lib('fineuploader')}",
 				"ide": "#{buildJsPath('ide.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
 				"libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
+				!{moduleIncludes("editor:script", locals)}
+				!{moduleIncludes("publish:script", locals)}
 			},
 			"waitSeconds": 0,
 			"shim": {
@@ -159,12 +166,6 @@ block requirejs
 		window.pdfCMapsPath = "#{pdfCMapsPath}"
 		window.uiConfig = JSON.parse('!{JSON.stringify(uiConfig).replace(/\//g, "\\/")}');
 
-	if hasFeature('rich-text')
-		script(
-			src=buildWebpackPath('es/richText.js', {hashedPath:settings.useMinifiedJs})
-			type="text/javascript"
-		)
-
 	script(
 		data-main=buildJsPath("ide.js", {hashedPath:false}),
 		baseurl=fullJsPath,

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

@@ -35,7 +35,7 @@ div.binary-file.full-size(
 	
 	p.no-preview(
 		ng-if="failedLoad || textPreview.error || isUnpreviewableFile()"
-	) #{translate("no_preview_available")} {{ failedLoad }} {{ textPreview.error }} {{ isUnpreviewableFile() }}
+	) #{translate("no_preview_available")}
 
 	div.binary-file-footer
 		div(ng-if="openFile.linkedFileData.provider == 'url'")

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

@@ -8,6 +8,9 @@ div.full-size(
 	initial-size-east="'50%'"
 	minimum-restore-size-east="300"
 	allow-overflow-on="'center'"
+	custom-toggler-pane=hasFeature('custom-togglers') ? "'east'" : "false"
+	custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_pdf") + "'" : "false"
+	custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? "'" + translate("tooltip_show_pdf") + "'" : "false"
 )
 	.ui-layout-center(
 		ng-controller="ReviewPanelController",
@@ -30,14 +33,13 @@ div.full-size(
 				i.fa.fa-arrow-left
 				| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
 
-		if hasFeature('rich-text')
-			.toolbar.toolbar-editor(ng-controller="EditorToolbarController")
-				button(ng-click="toggleRichText()") Rich Text
+		!= moduleIncludes('editor:toolbar', locals)
 
 		#editor(
 			ace-editor="editor",
 			ng-if="!editor.richText",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
+			style=showRichText ? "top: 32px" : "",
 			theme="settings.theme",
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
@@ -68,13 +70,7 @@ div.full-size(
 			renderer-data="reviewPanel.rendererData"
 		)
 
-		if hasFeature('rich-text')
-			#editor-rich-text(
-				cm-editor,
-				ng-if="editor.richText"
-				ng-show="!!editor.sharejs_doc && !editor.opening"
-				sharejs-doc="editor.sharejs_doc"
-			)
+		!= moduleIncludes('editor:body', locals)
 
 		include ./review-panel
 
@@ -100,7 +96,6 @@ div.full-size(
 			ng-click="syncToCode()"
 		)
 			i.synctex-control-icon
-			
 div.full-size(
 	ng-if="ui.pdfLayout == 'flat'"
 	ng-show="ui.view == 'pdf'"

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

@@ -107,6 +107,9 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 		)
 			i.fa.fa-fw.fa-group
 			p.toolbar-label #{translate("share")}
+
+		!= moduleIncludes('publish:button', locals)
+
 		a.btn.btn-full-height(
 			href,
 			ng-click="toggleHistory();",

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

@@ -11,7 +11,7 @@ aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="
 				.entity
 					.entity-name.entity-name-history(
 						ng-click="history.selection.pathname = pathname",
-						ng-class="{ 'deleted': doc.deleted }"
+						ng-class="{ 'deleted': !!doc.deletedAtV }"
 					)
 						i.fa.fa-fw.fa-pencil
 						span  {{ pathname }}

+ 36 - 143
services/web/app/views/project/editor/history.pug

@@ -1,80 +1,39 @@
 div#history(ng-show="ui.view == 'history'")
-	span(ng-controller="HistoryPremiumPopup")
+	span
 		.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
-			
-			div(ng-if="project.owner._id == user.id")
-				div(sixpack-switch="teaser-history")
-					.message(sixpack-default)
-						p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
-						p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-						ul.list-unstyled
-							li 
-								i.fa.fa-check &nbsp;
-								| #{translate("unlimited_projects")}
-							
-							li
-								i.fa.fa-check &nbsp;
-								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-							
-							li 
-								i.fa.fa-check &nbsp;
-								| #{translate("full_doc_history")}
-							
-							li 
-								i.fa.fa-check &nbsp;
-								| #{translate("sync_to_dropbox")}
-
-							li 
-								i.fa.fa-check &nbsp;
-								| #{translate("sync_to_github")}
-
-							li  
-								i.fa.fa-check &nbsp;
-								|#{translate("compile_larger_projects")}
-						p.text-center(ng-controller="FreeTrialModalController")
-							a.btn.btn-success(
-								href
-								ng-class="buttonClass"
-								ng-click="startFreeTrial('history')"
-								sixpack-convert="teaser-history"
-							) #{translate("start_free_trial")}
-
-					.message.message-wider(sixpack-when="focused")
-						header.message-header
-							h3 History
-
-						.message-body
-							h4.teaser-title See who changed what. Go back to previous versions.
-							img.teaser-img(
-								src="/img/teasers/history/teaser-history.png"
-								alt="History"
-							)
-							p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-							.row
-								.col-md-8.col-md-offset-2
-									ul.list-unstyled
-										li 
-											i.fa.fa-check &nbsp;
-											| Catch up with your collaborators changes
-										
-										li
-											i.fa.fa-check &nbsp;
-											| See changes over any time period
-										
-										li 
-											i.fa.fa-check &nbsp;
-											| Revert your documents to previous versions
-										
-										li 
-											i.fa.fa-check &nbsp;
-											| Restore deleted files
-							p.text-center(ng-controller="FreeTrialModalController")
-								a.btn.btn-success(
-									href
-									ng-class="buttonClass"
-									ng-click="startFreeTrial('history')"
-									sixpack-convert="teaser-history"
-								) Try it for free
+			.message(ng-if="project.owner._id == user.id")
+				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
+				p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+				ul.list-unstyled
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("unlimited_projects")}
+					
+					li
+						i.fa.fa-check &nbsp;
+						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+					
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("full_doc_history")}
+					
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("sync_to_dropbox")}
+
+					li 
+						i.fa.fa-check &nbsp;
+						| #{translate("sync_to_github")}
+
+					li  
+						i.fa.fa-check &nbsp;
+						|#{translate("compile_larger_projects")}
+				p.text-center(ng-controller="FreeTrialModalController")
+					a.btn.btn-success(
+						href
+						ng-class="buttonClass"
+						ng-click="startFreeTrial('history')"
+					) #{translate("start_free_trial")}
 
 			.message(ng-show="project.owner._id != user.id")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}
@@ -163,74 +122,8 @@ div#history(ng-show="ui.view == 'history'")
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
 
-	.diff-panel.full-size(ng-controller="HistoryDiffController")
-		.diff(
-			ng-if="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error && !history.diff.binary"
-		)
-			.toolbar.toolbar-alt
-				span.name
-					| <strong>{{history.diff.highlights.length}} </strong>
-					ng-pluralize(
-						count="history.diff.highlights.length",
-						when="{\
-							'one': 'change',\
-							'other': 'changes'\
-						}"
-					)
-					|  in <strong>{{history.diff.pathname}}</strong>
-				.toolbar-right
-					a.btn.btn-danger.btn-sm(
-						href,
-						ng-if="!history.isV2"
-						ng-click="openRestoreDiffModal()"
-					) #{translate("restore_to_before_these_changes")}
-					.deleted-warning(
-						ng-show="history.selection.docs[history.selection.pathname].deleted"
-					) This file was deleted
-			.diff-editor.hide-ace-cursor(
-				ace-editor="history",
-				theme="settings.theme",
-				font-size="settings.fontSize",
-				text="history.diff.text",
-				highlights="history.diff.highlights",
-				read-only="true",
-				resize-on="layout:main:resize",
-				navigate-highlights="true"
-			)
-
-		.diff.diff-binary(ng-show="history.diff.binary")
-			.toolbar.toolbar-alt
-				span.name
-					strong {{history.diff.pathname}}
-			.alert.alert-info We're still working on showing image and binary changes, sorry. Stay tuned!
-
-		.diff-deleted.text-centered(
-			ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
-		)
-			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
-			p
-				a.btn.btn-primary.btn-lg(
-					href,
-					ng-click="restoreDeletedDoc()",
-					ng-disabled="history.diff.restoreInProgress"
-				) #{translate("restore")}
-
-		.diff-deleted.text-centered(
-			ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
-		)
-			p.text-serif  #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
-			p.text-serif  #{translate("file_restored_back_to_editor")}
-			p
-				a.btn.btn-default(
-					href,
-					ng-click="backToEditorAfterRestore()",
-				) #{translate("file_restored_back_to_editor_btn")}
-
-		.loading-panel(ng-show="history.diff.loading")
-			i.fa.fa-spin.fa-refresh
-			| &nbsp;&nbsp;#{translate("loading")}...
-		.error-panel(ng-show="history.diff.error")
-			.alert.alert-danger #{translate("generic_something_went_wrong")}
+	include ./history/diffPanelV1
+	include ./history/diffPanelV2
 
 script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header

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

@@ -0,0 +1,58 @@
+.diff-panel.full-size(ng-if="!history.isV2", ng-controller="HistoryDiffController")
+	.diff(
+		ng-if="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error && !history.diff.binary"
+	)
+		.toolbar.toolbar-alt
+			span.name
+				| <strong>{{history.diff.highlights.length}} </strong>
+				ng-pluralize(
+					count="history.diff.highlights.length",
+					when="{\
+						'one': 'change',\
+						'other': 'changes'\
+					}"
+				)
+				|  in <strong>{{history.diff.pathname}}</strong>
+			.toolbar-right
+				a.btn.btn-danger.btn-sm(
+					href,
+					ng-click="openRestoreDiffModal()"
+				) #{translate("restore_to_before_these_changes")}
+		.diff-editor.hide-ace-cursor(
+			ace-editor="history",
+			theme="settings.theme",
+			font-size="settings.fontSize",
+			text="history.diff.text",
+			highlights="history.diff.highlights",
+			read-only="true",
+			resize-on="layout:main:resize",
+			navigate-highlights="true"
+		)
+
+	.diff-deleted.text-centered(
+		ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
+	)
+		p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
+		p
+			a.btn.btn-primary.btn-lg(
+				href,
+				ng-click="restoreDeletedDoc()",
+				ng-disabled="history.diff.restoreInProgress"
+			) #{translate("restore")}
+
+	.diff-deleted.text-centered(
+		ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
+	)
+		p.text-serif  #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
+		p.text-serif  #{translate("file_restored_back_to_editor")}
+		p
+			a.btn.btn-default(
+				href,
+				ng-click="backToEditorAfterRestore()",
+			) #{translate("file_restored_back_to_editor_btn")}
+
+	.loading-panel(ng-show="history.diff.loading")
+		i.fa.fa-spin.fa-refresh
+		| &nbsp;&nbsp;#{translate("loading")}...
+	.error-panel(ng-show="history.diff.error")
+		.alert.alert-danger #{translate("generic_something_went_wrong")}

+ 50 - 0
services/web/app/views/project/editor/history/diffPanelV2.pug

@@ -0,0 +1,50 @@
+.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController")
+	.diff(
+		ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
+		ng-class="{ 'diff-binary': history.diff.binary }"
+	)
+		.toolbar.toolbar-alt
+			span.name(ng-if="history.diff.binary")
+				strong {{history.diff.pathname}}
+			span.name(ng-if="!history.diff.binary")
+				| <strong>{{history.diff.highlights.length}} </strong>
+				ng-pluralize(
+					count="history.diff.highlights.length",
+					when="{\
+						'one': 'change',\
+						'other': 'changes'\
+					}"
+				)
+				|  in <strong>{{history.diff.pathname}}</strong>
+			.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
+				button.btn.btn-danger.btn-sm(
+					ng-click="restoreDeletedFile()"
+					ng-show="!restoreState.error"
+					ng-disabled="restoreState.inflight"
+				)
+					i.fa.fa-fw.fa-step-backward
+					span(ng-show="!restoreState.inflight")
+						| Restore this deleted file
+					span(ng-show="restoreState.inflight")
+						| Restoring...
+				span.text-danger(ng-show="restoreState.error")
+					| Error restoring, sorry
+		.diff-editor.hide-ace-cursor(
+			ng-if="!history.diff.binary"
+			ace-editor="history",
+			theme="settings.theme",
+			font-size="settings.fontSize",
+			text="history.diff.text",
+			highlights="history.diff.highlights",
+			read-only="true",
+			resize-on="layout:main:resize",
+			navigate-highlights="true"
+		)
+		.alert.alert-info(ng-if="history.diff.binary")
+			| We're still working on showing image and binary changes, sorry. Stay tuned!
+
+	.loading-panel(ng-show="history.diff.loading")
+		i.fa.fa-spin.fa-refresh
+		| &nbsp;&nbsp;#{translate("loading")}...
+	.error-panel(ng-show="history.diff.error")
+		.alert.alert-danger #{translate("generic_something_went_wrong")}

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

@@ -402,7 +402,6 @@ div.full-size.pdf(ng-controller="PdfController")
 					a.btn.btn-success.row-spaced-small(
 						href
 						ng-class="buttonClass"
-						sixpack-convert="track_changes_feature_info"
 						ng-click="startFreeTrial('compile-timeout')"
 					) #{translate("start_free_trial")}
 

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

@@ -173,7 +173,6 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 						a.btn.btn-success(
 							href
 							ng-class="buttonClass"
-							sixpack-convert="track_changes_feature_info"
 							ng-click="startFreeTrial('projectMembers')"
 						) #{translate("start_free_trial")}
 

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

@@ -56,9 +56,28 @@ block content
 							href
 							ng-click="showAll();"
 						) Show all
-	.content.content-alt.project-list-page(ng-controller="ProjectPageController")
-		.project-list-content
+	main.content.content-alt.project-list-page(
+		ng-controller="ProjectPageController"
+		role="main"
+	)
+		- if(typeof(suppressSystemMessages) == "undefined")
+			.system-messages(
+				ng-cloak
+				ng-controller="SystemMessagesController"
+			)
+				.system-message(
+					ng-repeat="message in messages"
+					ng-controller="SystemMessageController"
+					ng-hide="hidden"
+				)
+					button(ng-click="hide()").close.pull-right
+						span(aria-hidden="true") &times;
+						span.sr-only #{translate("close")}
+					.system-message-content(ng-bind-html="htmlContent")
+			
+			include ../translations/translation_message
 
+		.project-list-content(event-tracking=settings.overleaf ? "loads_v2_dash" : "", onboard=settings.overleaf ? "true" : "", event-tracking-trigger=settings.overleaf ? "load" : "", event-tracking-mb="true", event-segmentation="{location: 'dash', v2_onboard: true}")
 			.row.project-list-row(ng-cloak)
 				.project-list-container(ng-if="projects.length > 0")
 					.project-list-sidebar-wrapper.col-md-2.col-xs-3
@@ -77,6 +96,7 @@ block content
 								if userIsFromOLv1(user)
 									div(ng-show="visible")
 										| To tag or rename your v1 projects, please go back to Overleaf v1.
+									div(ng-show="visible")
 										a.project-list-sidebar-v1-link(
 											href=settings.overleaf.host + "/dash?prefer-v1-dash=1"
 										) Go back to v1

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

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

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

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

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

@@ -342,17 +342,20 @@ script(type="text/ng-template", id="v1ImportModalTemplate")
 		.v1-import-step-2(ng-show="step === 2")
 			.v1-import-row
 				.v1-import-warning.v1-import-col(aria-label="Warning symbol.")
-					i.fa.fa-exclamation-triangle
+					i.fa.fa-flask
 				.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:
+					p We are still working hard to bring some Overleaf v1 features to the v2 editor. In v2 there is:
 					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
+						li <strong>No Journals and Services</strong> menu to submit directly to our partners yet
+						li <strong>No Rich Text (WYSIWYG)</strong> mode yet
+						li <strong>No linked files</strong> (to URLs or to files in other Overleaf projects) yet
+						li <strong>No Zotero and CiteULike</strong> integrations yet
+						li <strong>No labelled versions</strong> yet
+					p.row-spaced-small
+						| If you currently use the <strong>Overleaf Git bridge</strong> with your v1 project, you can migrate your project to the Overleaf v2 GitHub integration.
+						|
+						a(href='https://www.overleaf.com/help/343-working-offline-in-overleaf-v2', target='_blank') Read More.
 			.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.

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

@@ -6,13 +6,13 @@ if (user.awareOfV2 && !settings.overleaf)
 					.col-xs-12
 						.alert.alert-info
 							.notification_inner
-								.notification_body
-									a.btn.btn-info.btn-sm.pull-right(href="/user/login_to_ol_v2") Try Overleaf v2
-									| ShareLaTeX is joining Overleaf and will become <em>Overleaf v2</em> in late 2018 (<a href="https://www.overleaf.com/help/342-overleaf-v2-faq">read more</a>).
+								.notification_body(event-tracking="sees_v2_banner" event-tracking-mb="true" event-segmentation="{location: 'welcome', v2_onboard: true}" event-tracking-trigger="load" event-tracking-send-once="true")
+									a.btn.btn-info.btn-sm.pull-right(event-tracking="go_to_v2" event-tracking-mb="true" event-segmentation="{location: 'welcome', v2_onboard: true}" event-tracking-trigger="click" href="/user/login_to_ol_v2") Try Overleaf v2
+									| ShareLaTeX is joining Overleaf and will become <em>Overleaf v2</em> in late 2018 (<a event-tracking="click_v2_read_more" event-tracking-mb="true" event-segmentation="{location: 'welcome', v2_onboard: true}" event-tracking-trigger="click" href="https://www.overleaf.com/help/342-overleaf-v2-faq">read more</a>).
 									<br/>
 									| We’re beta testing Overleaf v2 now and you can try it out with your ShareLaTeX account.
 								.notification_close
-									button(ng-click="dismiss()").close.pull-right
+									button(ng-click="dismiss()" event-tracking="closes_v2_banner" event-tracking-mb="true" event-segmentation="{location: 'welcome', v2_onboard: true}"  event-tracking-trigger="click").close.pull-right
 										span(aria-hidden="true") &times;
 										span.sr-only #{translate("close")}
 
@@ -49,4 +49,4 @@ span(ng-controller="NotificationsController").userNotifications
 							span().notification_close
 								button(ng-click="dismiss(notification)").close.pull-right
 									span(aria-hidden="true") &times;
-									span.sr-only #{translate("close")}
+									span.sr-only #{translate("close")}

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

@@ -61,6 +61,7 @@
 						li(
 							ng-repeat="tag in tags | orderBy:'name'",
 							ng-controller="TagDropdownItemController"
+							ng-if="!tag.isV1"
 						)
 							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
 								i.fa(

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

@@ -10,19 +10,16 @@
 		li
 			a(
 				href,
-				sixpack-convert="first_sign_up",
 				ng-click="openCreateProjectModal()"
 			) #{translate("blank_project")}
 		li
 			a(
 				href,
-				sixpack-convert="first_sign_up",
 				ng-click="openCreateProjectModal('example')"
 			) #{translate("example_project")}
 		li
 			a(
 				href,
-				sixpack-convert="first_sign_up",
 				ng-click="openUploadProjectModal()"
 			) #{translate("upload_project")}
 		!= moduleIncludes("newProjectMenu", locals)
@@ -31,7 +28,7 @@
 			li.dropdown-header #{translate("templates")}
 			each item in templates
 				li
-					a.menu-indent(href=item.url, sixpack-convert="first_sign_up") #{translate(item.name)}
+					a.menu-indent(href=item.url) #{translate(item.name)}
 
 .row-spaced(ng-if="projects.length > 0", ng-cloak)
 	ul.list-unstyled.folders-menu(
@@ -76,7 +73,7 @@
 						tooltip-template="'v1TagTooltipTemplate'"
 						tooltip-append-to-body="true"
 					)
-			span.dropdown.tag-menu(dropdown)
+			span.dropdown.tag-menu(dropdown)(ng-if="!tag.isV1")
 				a.dropdown-toggle(
 					href="#",
 					data-toggle="dropdown",
@@ -133,7 +130,7 @@
 			hr
 			p.small #{translate("on_free_sl")}
 			p
-				a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+				a(href="/user/subscription/plans").btn.btn-primary #{translate("upgrade")}
 			p.small.text-centered
 				| #{translate("or_unlock_features_bonus")} 
 				a(href="/user/bonus") #{translate("sharing_sl")} .

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

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

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

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

+ 42 - 301
services/web/app/views/subscriptions/new.pug

@@ -1,8 +1,6 @@
 extends ../layout
 
 block scripts
-	script(src="https://js.recurly.com/v3/recurly.js")
-
 	script(type='text/javascript').
 		window.countryCode = '#{countryCode}'
 		window.plan_code = '#{plan_code}'
@@ -41,19 +39,20 @@ block content
 										span !{translate("first_few_days_free", {trialLen:'{{trialLength}}'})}
 										span(ng-if="discountMonths && discountRate") &nbsp; -  {{discountMonths}} #{translate("month")}s {{discountRate}}% Off
 									div(ng-if="price")
-										strong {{price.currency.symbol}}{{price.next.total}} 
+										strong {{plans[currencyCode]['symbol']}}{{price.next.total}} 
 										span(ng-if="monthlyBilling")  #{translate("every")} #{translate("month")}
 										span(ng-if="!monthlyBilling")  #{translate("every")} #{translate("year")}
 									div(ng-if="normalPrice")
-										span.small Normally {{price.currency.symbol}}{{normalPrice}}
+										span.small Normally {{plans[currencyCode]['symbol']}}{{normalPrice}}
 						.row
 							div()
 								.col-md-12()
 									form(
-										ng-if="planName"
 										name="simpleCCForm"
 										novalidate
 									)
+
+										
 										div.payment-method-toggle
 											a.payment-method-toggle-switch(
 												href
@@ -75,10 +74,10 @@ block content
 										.alert.alert-warning.small(ng-show="genericError")
 											strong {{genericError}}
 
-										div(ng-if="paymentMethod.value === 'credit_card'")
+										div(ng-show="paymentMethod.value === 'credit_card'")
 											.row
 												.col-xs-6
-													.form-group(ng-class="validation.errorFields.first_name || inputHasError(simpleCCForm.firstName) ? 'has-error' : ''")
+													.form-group(ng-class="validation.errorFields.first_name || inputHasError(simpleCCForm.firstName) ? 'has-external-error' : ''")
 														label(for="first-name") #{translate('first_name')}
 														input#first-name.form-control(
 															type="text"
@@ -90,7 +89,7 @@ block content
 														)
 														span.input-feedback-message  {{ simpleCCForm.firstName.$error.required ? 'This field is required' : '' }}
 												.col-xs-6
-													.form-group(for="last-name",ng-class="validation.errorFields.last_name || inputHasError(simpleCCForm.lastName)? 'has-error' : ''")
+													.form-group(for="last-name",ng-class="validation.errorFields.last_name || inputHasError(simpleCCForm.lastName)? 'has-external-error' : ''")
 														label(for="last-name") #{translate('last_name')}
 														input#last-name.form-control(
 															type="text"
@@ -100,47 +99,41 @@ block content
 															ng-model="data.last_name"
 															required
 														)
-														span.input-feedback-message {{ simpleCCForm.lastName.$error.required ? 'This field is required' : '' }}
 
-											.form-group(ng-class="validation.correctCardNumber == false || validation.errorFields.number || inputHasError(simpleCCForm.ccNumber) ? 'has-error' : ''")
+											.form-group(ng-class="validation.errorFields.number ? 'has-external-error' : ''")
 												label(for="card-no") #{translate("credit_card_number")}
-												input#card-no.form-control(
+												div#card-no(
 													type="text"
-													ng-model="data.number"
 													name="ccNumber"
-													ng-focus="validation.correctCardNumber = true; validation.errorFields.number = false;"
-													ng-blur="validateCardNumber();"
-													required
-													cc-format-card-number
+													data-recurly='number'
 												)
-												span.input-feedback-message {{ simpleCCForm.ccNumber.$error.required ? 'This field is required' : 'Please re-check the card number' }}
 
 											.row
-												.col-xs-6
-													.form-group.has-feedback(ng-class="validation.correctExpiry == false || validation.errorFields.expiry || inputHasError(simpleCCForm.expiry) ? 'has-error' : ''")
-														label #{translate("expiry")}
-														input.form-control(
-															type="text"
-															ng-model="data.mmYY"
-															name="expiry"
-															placeholder="MM / YY"
-															ng-focus="validation.correctExpiry = true; validation.errorFields.expiry = false;"
-															ng-blur="updateExpiry(); validateExpiry()"
-															required
-															cc-format-expiry
+												.col-xs-3
+													.form-group.has-feedback(ng-class="validation.errorFields.month ? 'has-external-error' : ''")
+														label(for="month").capitalised #{translate("month")}
+														div(
+															type="number"
+															name="month"
+															data-recurly="month"
 														)
-														span.input-feedback-message {{ simpleCCForm.expiry.$error.required ? 'This field is required' : 'Please re-check the expiry date' }}
-
+												.col-xs-3
+													.form-group.has-feedback(ng-class="validation.errorFields.year ? 'has-external-error' : ''")
+														label(for="year").capitalised #{translate("year")}
+														div(
+															type="number"
+															name="year"
+															data-recurly="year"
+														)
+							
 												.col-xs-6
-													.form-group.has-feedback(ng-class="validation.correctCvv == false || validation.errorFields.cvv || inputHasError(simpleCCForm.cvv) ? 'has-error' : ''")
+													.form-group.has-feedback(ng-class="validation.errorFields.cvv ? 'has-external-error' : ''")
 														label #{translate("security_code")}
-														input.form-control(
-															type="text"
+														div(
+															type="number"
 															ng-model="data.cvv"
-															ng-focus="validation.correctCvv = true; validation.errorFields.cvv = false;"
-															ng-blur="validateCvv()"
+															data-recurly="cvv"
 															name="cvv"
-															required
 															cc-format-sec-code
 														)
 														.form-control-feedback
@@ -151,20 +144,21 @@ block content
 																tooltip-trigger="mouseenter"
 																tooltip-append-to-body="true"
 															) ?
-														span.input-feedback-message {{ simpleCCForm.cvv.$error.required ? 'This field is required' : 'Please re-check the security code' }}
 
-										
 										div
-											.form-group(ng-class="validation.errorFields.country || inputHasError(simpleCCForm.country) ? 'has-error' : ''")
+											.form-group(ng-class="validation.errorFields.country || inputHasError(simpleCCForm.country) ? 'has-external-error' : ''")
 												label(for="country") #{translate('country')}
 												select#country.form-control(
 													data-recurly="country"
 													ng-model="data.country"
 													name="country"
 													ng-change="updateCountry()"
-													required
+													required,
+													ng-options="country.code as country.name for country in countries",
+													ng-selected="{{country.code == data.country}}"
 												)
-													+countries_options()
+													option(value='', disabled, selected) #{translate("country")}
+													option(value='-') --------------
 												span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }}
 
 											if (showVatField)
@@ -189,8 +183,8 @@ block content
 											div.price-breakdown(ng-if="price.next.tax !== '0.00'")
 												hr.thin
 												span Total: 
-												strong {{price.currency.symbol}}{{price.next.total}} 
-												span ({{price.currency.symbol}}{{price.next.subtotal}} + {{price.currency.symbol}}{{price.next.tax}} tax) 
+												strong {{plans[currencyCode]['symbol']}}{{price.next.total}} 
+												span ({{plans[currencyCode]['symbol']}}{{price.next.subtotal}} + {{plans[currencyCode]['symbol']}}{{price.next.tax}} tax) 
 												span(ng-if="monthlyBilling") #{translate("every")} #{translate("month")}
 												span(ng-if="!monthlyBilling") #{translate("every")} #{translate("year")}
 												hr.thin
@@ -198,12 +192,14 @@ block content
 											div.payment-submit
 												button.btn.btn-success.btn-block(
 													ng-click="submit()"
-													ng-disabled="processing || !isFormValid(simpleCCForm);"
+													ng-disabled="processing  || !isFormValid(simpleCCForm);" 
 												) 
 													span(ng-show="processing") 
 														i.fa.fa-spinner.fa-spin
 														| &nbsp;
-													| {{ paymentMethod.value === 'credit_card' ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_paypal_btn")}' }}
+													span(ng-if="paymentMethod.value === 'credit_card'")
+														| {{ monthlyBilling ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_now")}'}}
+													span(ng-if="paymentMethod.value !== 'credit_card'") #{translate("upgrade_paypal_btn")}
 
 
 				.col-md-3.col-md-pull-4
@@ -258,258 +254,3 @@ block content
 	)
 		p For #[strong Visa, MasterCard and Discover], the #[strong 3 digits] on the #[strong back] of your card.
 		p For #[strong American Express], the #[strong 4 digits] on the #[strong front] of your card.
-
-mixin countries_options()
-	option(value='', disabled, selected) #{translate("country")}
-	option(value='-') --------------
-	option(value='AF') Afghanistan
-	option(value='AL') Albania
-	option(value='DZ') Algeria
-	option(value='AS') American Samoa
-	option(value='AD') Andorra
-	option(value='AO') Angola
-	option(value='AI') Anguilla
-	option(value='AQ') Antarctica
-	option(value='AG') Antigua and Barbuda
-	option(value='AR') Argentina
-	option(value='AM') Armenia
-	option(value='AW') Aruba
-	option(value='AC') Ascension Island
-	option(value='AU') Australia
-	option(value='AT') Austria
-	option(value='AZ') Azerbaijan
-	option(value='BS') Bahamas
-	option(value='BH') Bahrain
-	option(value='BD') Bangladesh
-	option(value='BB') Barbados
-	option(value='BE') Belgium
-	option(value='BZ') Belize
-	option(value='BJ') Benin
-	option(value='BM') Bermuda
-	option(value='BT') Bhutan
-	option(value='BO') Bolivia
-	option(value='BA') Bosnia and Herzegovina
-	option(value='BW') Botswana
-	option(value='BV') Bouvet Island
-	option(value='BR') Brazil
-	option(value='BQ') British Antarctic Territory
-	option(value='IO') British Indian Ocean Territory
-	option(value='VG') British Virgin Islands
-	option(value='BN') Brunei
-	option(value='BG') Bulgaria
-	option(value='BF') Burkina Faso
-	option(value='BI') Burundi
-	option(value='KH') Cambodia
-	option(value='CM') Cameroon
-	option(value='CA') Canada
-	option(value='IC') Canary Islands
-	option(value='CT') Canton and Enderbury Islands
-	option(value='CV') Cape Verde
-	option(value='KY') Cayman Islands
-	option(value='CF') Central African Republic
-	option(value='EA') Ceuta and Melilla
-	option(value='TD') Chad
-	option(value='CL') Chile
-	option(value='CN') China
-	option(value='CX') Christmas Island
-	option(value='CP') Clipperton Island
-	option(value='CC') Cocos [Keeling] Islands
-	option(value='CO') Colombia
-	option(value='KM') Comoros
-	option(value='CD') Congo [DRC]
-	option(value='CK') Cook Islands
-	option(value='CR') Costa Rica
-	option(value='HR') Croatia
-	option(value='CU') Cuba
-	option(value='CY') Cyprus
-	option(value='CZ') Czech Republic
-	option(value='DK') Denmark
-	option(value='DG') Diego Garcia
-	option(value='DJ') Djibouti
-	option(value='DM') Dominica
-	option(value='DO') Dominican Republic
-	option(value='NQ') Dronning Maud Land
-	option(value='TL') East Timor
-	option(value='EC') Ecuador
-	option(value='EG') Egypt
-	option(value='SV') El Salvador
-	option(value='EE') Estonia
-	option(value='ET') Ethiopia
-	option(value='FK') Falkland Islands [Islas Malvinas]
-	option(value='FO') Faroe Islands
-	option(value='FJ') Fiji
-	option(value='FI') Finland
-	option(value='FR') France
-	option(value='GF') French Guiana
-	option(value='PF') French Polynesia
-	option(value='TF') French Southern Territories
-	option(value='FQ') French Southern and Antarctic Territories
-	option(value='GA') Gabon
-	option(value='GM') Gambia
-	option(value='GE') Georgia
-	option(value='DE') Germany
-	option(value='GH') Ghana
-	option(value='GI') Gibraltar
-	option(value='GR') Greece
-	option(value='GL') Greenland
-	option(value='GD') Grenada
-	option(value='GP') Guadeloupe
-	option(value='GU') Guam
-	option(value='GT') Guatemala
-	option(value='GG') Guernsey
-	option(value='GW') Guinea-Bissau
-	option(value='GY') Guyana
-	option(value='HT') Haiti
-	option(value='HM') Heard Island and McDonald Islands
-	option(value='HN') Honduras
-	option(value='HK') Hong Kong
-	option(value='HU') Hungary
-	option(value='IS') Iceland
-	option(value='IN') India
-	option(value='ID') Indonesia
-	option(value='IE') Ireland
-	option(value='IM') Isle of Man
-	option(value='IL') Israel
-	option(value='IT') Italy
-	option(value='JM') Jamaica
-	option(value='JP') Japan
-	option(value='JE') Jersey
-	option(value='JT') Johnston Island
-	option(value='JO') Jordan
-	option(value='KZ') Kazakhstan
-	option(value='KE') Kenya
-	option(value='KI') Kiribati
-	option(value='KW') Kuwait
-	option(value='KG') Kyrgyzstan
-	option(value='LA') Laos
-	option(value='LV') Latvia
-	option(value='LS') Lesotho
-	option(value='LY') Libya
-	option(value='LI') Liechtenstein
-	option(value='LT') Lithuania
-	option(value='LU') Luxembourg
-	option(value='MO') Macau
-	option(value='MK') Macedonia [FYROM]
-	option(value='MG') Madagascar
-	option(value='MW') Malawi
-	option(value='MY') Malaysia
-	option(value='MV') Maldives
-	option(value='ML') Mali
-	option(value='MT') Malta
-	option(value='MH') Marshall Islands
-	option(value='MQ') Martinique
-	option(value='MR') Mauritania
-	option(value='MU') Mauritius
-	option(value='YT') Mayotte
-	option(value='FX') Metropolitan France
-	option(value='MX') Mexico
-	option(value='FM') Micronesia
-	option(value='MI') Midway Islands
-	option(value='MD') Moldova
-	option(value='MC') Monaco
-	option(value='MN') Mongolia
-	option(value='ME') Montenegro
-	option(value='MS') Montserrat
-	option(value='MA') Morocco
-	option(value='MZ') Mozambique
-	option(value='NA') Namibia
-	option(value='NR') Nauru
-	option(value='NP') Nepal
-	option(value='NL') Netherlands
-	option(value='AN') Netherlands Antilles
-	option(value='NT') Neutral Zone
-	option(value='NC') New Caledonia
-	option(value='NZ') New Zealand
-	option(value='NI') Nicaragua
-	option(value='NE') Niger
-	option(value='NG') Nigeria
-	option(value='NU') Niue
-	option(value='NF') Norfolk Island
-	option(value='VD') North Vietnam
-	option(value='MP') Northern Mariana Islands
-	option(value='NO') Norway
-	option(value='OM') Oman
-	option(value='QO') Outlying Oceania
-	option(value='PC') Pacific Islands Trust Territory
-	option(value='PK') Pakistan
-	option(value='PW') Palau
-	option(value='PS') Palestinian Territories
-	option(value='PA') Panama
-	option(value='PZ') Panama Canal Zone
-	option(value='PY') Paraguay
-	option(value='YD') People&apos;s Democratic Republic of Yemen
-	option(value='PE') Peru
-	option(value='PH') Philippines
-	option(value='PN') Pitcairn Islands
-	option(value='PL') Poland
-	option(value='PT') Portugal
-	option(value='PR') Puerto Rico
-	option(value='QA') Qatar
-	option(value='RO') Romania
-	option(value='RU') Russia
-	option(value='RW') Rwanda
-	option(value='RE') R&eacute;union
-	option(value='BL') Saint Barth&eacute;lemy
-	option(value='SH') Saint Helena
-	option(value='KN') Saint Kitts and Nevis
-	option(value='LC') Saint Lucia
-	option(value='MF') Saint Martin
-	option(value='PM') Saint Pierre and Miquelon
-	option(value='VC') Saint Vincent and the Grenadines
-	option(value='WS') Samoa
-	option(value='SM') San Marino
-	option(value='SA') Saudi Arabia
-	option(value='SN') Senegal
-	option(value='RS') Serbia
-	option(value='CS') Serbia and Montenegro
-	option(value='SC') Seychelles
-	option(value='SL') Sierra Leone
-	option(value='SG') Singapore
-	option(value='SK') Slovakia
-	option(value='SI') Slovenia
-	option(value='SB') Solomon Islands
-	option(value='ZA') South Africa
-	option(value='GS') South Georgia and the South Sandwich Islands
-	option(value='KR') South Korea
-	option(value='ES') Spain
-	option(value='LK') Sri Lanka
-	option(value='SR') Suriname
-	option(value='SJ') Svalbard and Jan Mayen
-	option(value='SZ') Swaziland
-	option(value='SE') Sweden
-	option(value='CH') Switzerland
-	option(value='ST') S&atilde;o Tom&eacute; and Pr&iacute;ncipe
-	option(value='TW') Taiwan
-	option(value='TJ') Tajikistan
-	option(value='TZ') Tanzania
-	option(value='TH') Thailand
-	option(value='TG') Togo
-	option(value='TK') Tokelau
-	option(value='TO') Tonga
-	option(value='TT') Trinidad and Tobago
-	option(value='TA') Tristan da Cunha
-	option(value='TN') Tunisia
-	option(value='TR') Turkey
-	option(value='TM') Turkmenistan
-	option(value='TC') Turks and Caicos Islands
-	option(value='TV') Tuvalu
-	option(value='UM') U.S. Minor Outlying Islands
-	option(value='PU') U.S. Miscellaneous Pacific Islands
-	option(value='VI') U.S. Virgin Islands
-	option(value='UG') Uganda
-	option(value='UA') Ukraine
-	option(value='AE') United Arab Emirates
-	option(value='GB') United Kingdom
-	option(value='US') United States
-	option(value='UY') Uruguay
-	option(value='UZ') Uzbekistan
-	option(value='VU') Vanuatu
-	option(value='VA') Vatican City
-	option(value='VE') Venezuela
-	option(value='VN') Vietnam
-	option(value='WK') Wake Island
-	option(value='WF') Wallis and Futuna
-	option(value='EH') Western Sahara
-	option(value='YE') Yemen
-	option(value='ZM') Zambia
-	option(value='AX') &angst;land Islands

+ 6 - 12
services/web/app/views/subscriptions/plans.pug

@@ -76,7 +76,7 @@ block content
 													br
 													a.btn.btn-info(
 														href="/register"
-														style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+														style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
 													) #{translate("sign_up_now")}
 									.col-md-4
 										.card.card-highlighted
@@ -90,10 +90,8 @@ block content
 													| {{plans[currencyCode]['collaborator']['annual']}}
 													span.small /yr
 											ul.list-unstyled
-												li 
-													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:10})}
-													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:8})}
-													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:12})}
+												li
+													strong #{translate("collabs_per_proj", {collabcount:10})}
 												li #{translate("full_doc_history")}
 												li #{translate("sync_to_dropbox")}
 												li #{translate("sync_to_github")}
@@ -144,7 +142,7 @@ block content
 													br
 													a.btn.btn-info(
 														href="/register"
-														style=(getLoggedInUserId() === undefined ? "" : "visibility: hidden")
+														style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
 													) #{translate("sign_up_now")}
 
 									.col-md-4
@@ -157,9 +155,7 @@ block content
 													span.small /mo
 											ul.list-unstyled
 												li
-													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
-													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
-													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
+													strong #{translate("collabs_per_proj", {collabcount:6})}
 												li #{translate("full_doc_history")}
 												li #{translate("sync_to_dropbox")}
 												li #{translate("sync_to_github")}
@@ -180,9 +176,7 @@ block content
 													span.small /yr
 											ul.list-unstyled
 												li
-													strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
-													strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
-													strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
+													strong #{translate("collabs_per_proj", {collabcount:6})}
 												li #{translate("full_doc_history")}
 												li #{translate("sync_to_dropbox")}
 												li #{translate("sync_to_github")}

+ 13 - 11
services/web/app/views/user/settings.pug

@@ -118,17 +118,19 @@ block content
 
 							| !{moduleIncludes("userSettings", locals)}
 
-							hr
-
-							h3
-								| #{translate("sharelatex_beta_program")}
-
-							if (user.betaProgram)
-								p.small
-									| #{translate("beta_program_already_participating")}
-
-							div
-								a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
+							//- The beta program doesn't make much sense to include while v2 is going
+							//- but we may want to add it back in later
+							//- hr
+							//-
+							//- h3
+							//- 	| #{translate("sharelatex_beta_program")}
+							//-
+							//- if (user.betaProgram)
+							//- 	p.small
+							//- 		| #{translate("beta_program_already_participating")}
+							//-
+							//- div
+							//- 	a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
 
 							hr
 	

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

@@ -154,6 +154,8 @@ module.exports = settings =
 			url: "http://#{process.env['ANALYTICS_HOST'] or 'localhost'}:3050"
 		linkedUrlProxy:
 			url: process.env['LINKED_URL_PROXY']
+		thirdpartyreferences:
+			url: "http://#{process.env['THIRD_PARTY_REFERENCES_HOST'] or 'localhost'}:3046"
 
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"

+ 9 - 3
services/web/karma.conf.js

@@ -18,6 +18,7 @@ module.exports = function (config) {
       'public/js/libs/angular-1.6.4.min.js',
       'public/js/libs/angular-mocks.js',
       'public/js/libs/jquery-1.11.1.min.js',
+      'public/js/libs/underscore-1.3.3.js',
       // Set up requirejs
       'test/unit_frontend/js/test-main.js',
       // Include source & test files, but don't "include" them as requirejs
@@ -25,13 +26,18 @@ module.exports = function (config) {
       { pattern: 'public/js/**/*.js', included: false },
       { pattern: 'test/unit_frontend/js/**/*.js', included: false },
       // Include ES test files
-      'test/unit_frontend/es/**/*.js'
+      'test/unit_frontend/es/**/*.js',
+      'modules/**/test/unit_frontend/es/**/*.js',
+      // Include CSS (there is some in js/libs dir)
+      'public/stylesheets/**/*.css',
+      'public/js/libs/**/*.css'
     ],
     middleware: ['fake-img'],
     preprocessors: {
       // Run ES test files through webpack (which will then include source
       // files in bundle)
-      'test/unit_frontend/es/**/*.js': ['webpack']
+      'test/unit_frontend/es/**/*.js': ['webpack'],
+      'modules/**/test/unit_frontend/es/**/*.js': ['webpack']
     },
     frameworks: ['requirejs', 'mocha', 'chai-sinon'],
     // Configure webpack in the tests
@@ -72,4 +78,4 @@ function fakeImgMiddlewareFactory () {
     }
     next()
   }
-}
+}

+ 0 - 6
services/web/modules/.gitignore

@@ -1,6 +0,0 @@
-# Ignore all modules except for a whitelist
-*
-!dropbox
-!github-sync
-!public-registration
-!.gitignore

+ 0 - 0
services/web/modules/.gitkeep


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

@@ -5,10 +5,11 @@
   ],
   "verbose": true,
   "legacyWatch": true,
-  "exec": "make compile",
+  "exec": "make compile || exit 1",
   "watch": [
     "public/coffee/",
-    "public/stylesheets/"
+    "public/stylesheets/",
+    "modules/**/public/coffee/"
   ],
   "ext": "coffee less"
 }

+ 2 - 1
services/web/nodemon.json

@@ -11,7 +11,8 @@
   "watch": [
     "app/coffee/",
     "app.coffee",
-    "modules/*/app/coffee/"
+    "modules/*/app/coffee/",
+    "config/"
   ],
   "ext": "coffee"
 }

+ 126 - 8
services/web/npm-shrinkwrap.json

@@ -533,6 +533,12 @@
       "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz",
       "dev": true
     },
+    "babel-helper-builder-react-jsx": {
+      "version": "6.26.0",
+      "from": "babel-helper-builder-react-jsx@>=6.24.1 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz",
+      "dev": true
+    },
     "babel-helper-call-delegate": {
       "version": "6.24.1",
       "from": "babel-helper-call-delegate@>=6.24.1 <7.0.0",
@@ -629,6 +635,18 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
       "dev": true
     },
+    "babel-plugin-syntax-flow": {
+      "version": "6.18.0",
+      "from": "babel-plugin-syntax-flow@>=6.18.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
+      "dev": true
+    },
+    "babel-plugin-syntax-jsx": {
+      "version": "6.18.0",
+      "from": "babel-plugin-syntax-jsx@>=6.3.13 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
+      "dev": true
+    },
     "babel-plugin-syntax-trailing-function-commas": {
       "version": "6.22.0",
       "from": "babel-plugin-syntax-trailing-function-commas@>=6.22.0 <7.0.0",
@@ -779,6 +797,36 @@
       "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz",
       "dev": true
     },
+    "babel-plugin-transform-flow-strip-types": {
+      "version": "6.22.0",
+      "from": "babel-plugin-transform-flow-strip-types@>=6.22.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz",
+      "dev": true
+    },
+    "babel-plugin-transform-react-display-name": {
+      "version": "6.25.0",
+      "from": "babel-plugin-transform-react-display-name@>=6.23.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz",
+      "dev": true
+    },
+    "babel-plugin-transform-react-jsx": {
+      "version": "6.24.1",
+      "from": "babel-plugin-transform-react-jsx@>=6.24.1 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz",
+      "dev": true
+    },
+    "babel-plugin-transform-react-jsx-self": {
+      "version": "6.22.0",
+      "from": "babel-plugin-transform-react-jsx-self@>=6.22.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz",
+      "dev": true
+    },
+    "babel-plugin-transform-react-jsx-source": {
+      "version": "6.22.0",
+      "from": "babel-plugin-transform-react-jsx-source@>=6.22.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz",
+      "dev": true
+    },
     "babel-plugin-transform-regenerator": {
       "version": "6.26.0",
       "from": "babel-plugin-transform-regenerator@>=6.22.0 <7.0.0",
@@ -805,6 +853,18 @@
         }
       }
     },
+    "babel-preset-flow": {
+      "version": "6.23.0",
+      "from": "babel-preset-flow@>=6.23.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz",
+      "dev": true
+    },
+    "babel-preset-react": {
+      "version": "6.24.1",
+      "from": "babel-preset-react@>=6.16.0 <7.0.0",
+      "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz",
+      "dev": true
+    },
     "babel-register": {
       "version": "6.26.0",
       "from": "babel-register@>=6.26.0 <7.0.0",
@@ -2031,6 +2091,11 @@
       "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.6.tgz",
       "dev": true
     },
+    "create-react-class": {
+      "version": "15.6.3",
+      "from": "create-react-class@>=15.6.0 <16.0.0",
+      "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz"
+    },
     "cross-spawn": {
       "version": "5.1.0",
       "from": "cross-spawn@>=5.0.1 <6.0.0",
@@ -3429,6 +3494,23 @@
       "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz",
       "dev": true
     },
+    "fbjs": {
+      "version": "0.8.16",
+      "from": "fbjs@>=0.8.9 <0.9.0",
+      "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz",
+      "dependencies": {
+        "core-js": {
+          "version": "1.2.7",
+          "from": "core-js@>=1.0.0 <2.0.0",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz"
+        },
+        "promise": {
+          "version": "7.3.1",
+          "from": "promise@>=7.1.1 <8.0.0",
+          "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz"
+        }
+      }
+    },
     "fd-slicer": {
       "version": "1.0.1",
       "from": "fd-slicer@>=1.0.1 <1.1.0",
@@ -3744,6 +3826,11 @@
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
       "dev": true
     },
+    "fuse.js": {
+      "version": "3.2.0",
+      "from": "fuse.js@>=3.0.0 <4.0.0",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.2.0.tgz"
+    },
     "gauge": {
       "version": "2.7.4",
       "from": "gauge@>=2.7.3 <2.8.0",
@@ -5307,8 +5394,7 @@
     "is-stream": {
       "version": "1.1.0",
       "from": "is-stream@>=1.1.0 <2.0.0",
-      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz"
     },
     "is-symbol": {
       "version": "1.0.1",
@@ -5355,6 +5441,11 @@
       "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
       "dev": true
     },
+    "isomorphic-fetch": {
+      "version": "2.2.1",
+      "from": "isomorphic-fetch@>=2.1.1 <3.0.0",
+      "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz"
+    },
     "isstream": {
       "version": "0.1.2",
       "from": "isstream@>=0.1.2 <0.2.0",
@@ -5391,8 +5482,7 @@
     "js-tokens": {
       "version": "3.0.2",
       "from": "js-tokens@>=3.0.2 <4.0.0",
-      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz"
     },
     "js-yaml": {
       "version": "2.0.5",
@@ -6858,8 +6948,7 @@
     "loose-envify": {
       "version": "1.3.1",
       "from": "loose-envify@>=1.0.0 <2.0.0",
-      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz"
     },
     "loud-rejection": {
       "version": "1.6.0",
@@ -7620,6 +7709,11 @@
       "from": "nocache@2.0.0",
       "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.0.0.tgz"
     },
+    "node-fetch": {
+      "version": "1.7.3",
+      "from": "node-fetch@>=1.0.1 <2.0.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz"
+    },
     "node-forge": {
       "version": "0.2.24",
       "from": "node-forge@0.2.24",
@@ -8663,6 +8757,11 @@
       "resolved": "https://registry.npmjs.org/prompt/-/prompt-0.2.14.tgz",
       "dev": true
     },
+    "prop-types": {
+      "version": "15.6.1",
+      "from": "prop-types@>=15.5.10 <16.0.0",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.1.tgz"
+    },
     "proxy-addr": {
       "version": "1.0.10",
       "from": "proxy-addr@>=1.0.8 <1.1.0",
@@ -8967,6 +9066,16 @@
         }
       }
     },
+    "react": {
+      "version": "15.6.2",
+      "from": "react@>=15.4.2 <16.0.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz"
+    },
+    "react-dom": {
+      "version": "15.6.2",
+      "from": "react-dom@>=15.4.2 <16.0.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz"
+    },
     "read": {
       "version": "1.0.7",
       "from": "read@>=1.0.0 <1.1.0",
@@ -9676,8 +9785,7 @@
     "setimmediate": {
       "version": "1.0.5",
       "from": "setimmediate@>=1.0.4 <2.0.0",
-      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
     },
     "setprototypeof": {
       "version": "1.0.3",
@@ -10945,6 +11053,11 @@
       "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
       "dev": true
     },
+    "ua-parser-js": {
+      "version": "0.7.17",
+      "from": "ua-parser-js@>=0.7.9 <0.8.0",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz"
+    },
     "uglify-js": {
       "version": "2.4.24",
       "from": "uglify-js@>=2.4.0 <2.5.0",
@@ -12061,6 +12174,11 @@
       "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz",
       "dev": true
     },
+    "whatwg-fetch": {
+      "version": "2.0.4",
+      "from": "whatwg-fetch@>=0.10.0",
+      "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz"
+    },
     "when": {
       "version": "3.7.8",
       "from": "when@>=3.7.7 <4.0.0",

+ 4 - 0
services/web/package.json

@@ -42,6 +42,7 @@
     "express-http-proxy": "^1.1.0",
     "express-session": "^1.14.2",
     "fs-extra": "^4.0.2",
+    "fuse.js": "^3.0.0",
     "heapdump": "^0.3.7",
     "helmet": "^3.8.1",
     "http-proxy": "^1.8.1",
@@ -72,6 +73,8 @@
     "passport-oauth2-refresh": "^1.0.0",
     "passport-saml": "^0.15.0",
     "pug": "^2.0.0-beta6",
+    "react": "^15.4.2",
+    "react-dom": "^15.4.2",
     "redis-sharelatex": "git+https://github.com/sharelatex/redis-sharelatex.git#v1.0.4",
     "request": "^2.69.0",
     "requestretry": "^1.13.0",
@@ -95,6 +98,7 @@
     "babel-core": "^6.26.0",
     "babel-loader": "^7.1.2",
     "babel-preset-env": "^1.6.1",
+    "babel-preset-react": "^6.16.0",
     "bunyan": "0.22.1",
     "chai": "3.5.0",
     "chai-spies": "",

+ 41 - 0
services/web/public/coffee/directives/eventTracking.coffee

@@ -0,0 +1,41 @@
+# For sending event data to metabase and google analytics
+# ---
+# by default,
+#   event not sent to MB.
+#     for MB, add event-tracking-mb='true'
+#     by default, event sent to MB via sendMB
+#       this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute
+#   event not sent to GA.
+#     for GA, add event-tracking-ga attribute, where the value is the GA category
+# event-tracking-trigger attribute is required to send event
+
+define [
+	'base'
+], (App) ->
+	App.directive 'eventTracking', ['event_tracking', (event_tracking) ->
+		return {
+			scope: {
+				eventTracking: '@',
+				eventSegmentation: '=?'
+			}
+			link: (scope, element, attrs) ->
+				sendGA = attrs.eventTrackingGa || false
+				sendMB = attrs.eventTrackingMb || false
+				sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
+				segmentation = scope.eventSegmentation || {}
+
+				segmentation.page = window.location.pathname
+
+				sendEvent = () ->
+					if sendMB
+						event_tracking[sendMBFunction] scope.eventTracking, segmentation
+					if sendGA
+						event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
+
+				if attrs.eventTrackingTrigger == 'load'
+					sendEvent()
+				else if attrs.eventTrackingTrigger == 'click'
+					element.on 'click', (e) ->
+						sendEvent()
+		}
+	]

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

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

+ 1 - 0
services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee

@@ -8,6 +8,7 @@ define [
 					@openFile(entity)
 
 		openFile: (file) ->
+			@ide.fileTreeManager.selectEntity(file)
 			@$scope.ui.view = "file"
 			@$scope.openFile = null
 			@$scope.$apply()

+ 66 - 1
services/web/public/coffee/ide/directives/layout.coffee

@@ -2,12 +2,17 @@ define [
 	"base"
 	"libs/jquery-layout"
 ], (App) ->
-	App.directive "layout", ["$parse", "ide", ($parse, ide) ->
+	App.directive "layout", ["$parse", "$compile", "ide", ($parse, $compile, ide) ->
 		return {
 			compile: () ->
 				pre: (scope, element, attrs) ->
 					name = attrs.layout
 
+					customTogglerPane = scope.$eval(attrs.customTogglerPane or "false")
+					customTogglerMsgWhenOpen = scope.$eval(attrs.customTogglerMsgWhenOpen or "false")
+					customTogglerMsgWhenClosed = scope.$eval(attrs.customTogglerMsgWhenClosed or "false")
+					hasCustomToggler = customTogglerPane != false and customTogglerMsgWhenOpen != false and customTogglerMsgWhenClosed != false
+
 					if attrs.spacingOpen?
 						spacingOpen = parseInt(attrs.spacingOpen, 10)
 					else
@@ -23,6 +28,10 @@ define [
 						spacing_closed: spacingClosed
 						slidable: false
 						enableCursorHotkey: false
+						onopen: (pane) => 
+							onPaneOpen(pane)
+						onclose: (pane) => 
+							onPaneClose(pane)
 						onresize: () =>
 							onInternalResize()
 						maskIframesOnResize: scope.$eval(
@@ -62,6 +71,15 @@ define [
 									right: state.east.size
 								})
 
+					repositionCustomToggler = () ->
+						if !customTogglerEl?
+							return
+						state = element.layout().readState()
+						positionAnchor = if customTogglerPane == "east" then "right" else "left"
+						paneState = state[customTogglerPane]
+						if paneState?
+							customTogglerEl.css(positionAnchor, if paneState.initClosed then 0 else paneState.size)
+	
 					resetOpenStates = () ->
 						state = element.layout().readState()
 						if attrs.openEast? and state.east?
@@ -73,6 +91,8 @@ define [
 						state = element.layout().readState()
 						scope.$broadcast "layout:#{name}:resize", state
 						repositionControls()
+						if hasCustomToggler
+							repositionCustomToggler()
 						resetOpenStates()
 						
 					oldWidth = element.width()
@@ -94,6 +114,46 @@ define [
 					if attrs.resizeOn?
 						scope.$on attrs.resizeOn, () -> onExternalResize()
 
+					if hasCustomToggler
+						state = element.layout().readState()
+						customTogglerScope = scope.$new()
+
+						customTogglerScope.isOpen = true
+						customTogglerScope.isVisible = true
+
+						if state[customTogglerPane]?.initClosed == true
+							customTogglerScope.isOpen = false
+
+						customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
+						customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
+							
+						customTogglerScope.tooltipPlacement = if customTogglerPane == "east" then "left" else "right"
+						customTogglerScope.handleClick = () ->
+							element.layout().toggle(customTogglerPane)
+							repositionCustomToggler()
+						customTogglerEl = $compile("
+							<a href 
+							   ng-show=\"isVisible\"
+							   class=\"custom-toggler #{ 'custom-toggler-' + customTogglerPane }\"
+							   ng-class=\"isOpen ? 'custom-toggler-open' : 'custom-toggler-closed'\"
+							   tooltip=\"{{ isOpen ? tooltipMsgWhenOpen : tooltipMsgWhenClosed }}\"
+							   tooltip-placement=\"{{ tooltipPlacement }}\"
+							   ng-click=\"handleClick()\">
+						")(customTogglerScope)
+						element.append(customTogglerEl)
+
+					onPaneOpen = (pane) ->
+						if !hasCustomToggler and pane != customTogglerPane
+							return
+						customTogglerEl.scope().$applyAsync () -> 
+							customTogglerEl.scope().isOpen = true
+
+					onPaneClose = (pane) ->
+						if !hasCustomToggler and pane != customTogglerPane
+							return
+						customTogglerEl.scope().$applyAsync () -> 
+							customTogglerEl.scope().isOpen = false
+
 					# Save state when exiting
 					$(window).unload () ->
 						ide.localStorage("layout.#{name}", element.layout().readState())
@@ -128,6 +188,11 @@ define [
 								element.layout().hide("east")
 							else
 								element.layout().show("east")
+							if hasCustomToggler
+								customTogglerEl.scope().$applyAsync () ->
+									customTogglerEl.scope().isOpen = !value
+									customTogglerEl.scope().isVisible = !value
+
 
 				post: (scope, element, attrs) ->
 					name = attrs.layout

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

@@ -1,9 +1,8 @@
 define [
 	"ide/editor/Document"
 	"ide/editor/directives/aceEditor"
-	"ide/editor/directives/cmEditor"
+	"ide/editor/directives/toggleSwitch"
 	"ide/editor/controllers/SavingNotificationController"
-	"ide/editor/controllers/EditorToolbarController"
 ], (Document) ->
 	class EditorManager
 		constructor: (@ide, @$scope) ->
@@ -189,6 +188,3 @@ define [
 						@$scope.editor.trackChanges = want
 				else
 					@_syncTimeout = setTimeout tryToggle, 100
-
-		toggleRichText: () ->
-			@$scope.editor.richText = !@$scope.editor.richText

+ 0 - 7
services/web/public/coffee/ide/editor/controllers/EditorToolbarController.coffee

@@ -1,7 +0,0 @@
-define [
-	"base"
-	"ide/editor/Document"
-], (App, Document) ->
-  App.controller "EditorToolbarController", ($scope, ide) ->
-    $scope.toggleRichText = () ->
-      ide.editorManager.toggleRichText()

+ 0 - 33
services/web/public/coffee/ide/editor/directives/cmEditor.coffee

@@ -1,33 +0,0 @@
-define [
-  "base"
-], (App) ->
-  App.directive "cmEditor", () ->
-    return {
-      scope: {
-        sharejsDoc: "="
-      }
-
-      link: (scope, element, attrs) ->
-        cm = Frontend.richText.init(element.find('.cm-editor-wrapper')[0])
-
-        scope.$watch "sharejsDoc", (sharejsDoc, oldSharejsDoc) ->
-          if oldSharejsDoc?
-            detachFromCM(oldSharejsDoc)
-          if sharejsDoc?
-            attachToCM(sharejsDoc)
-
-        attachToCM = (sharejsDoc) ->
-          scope.$applyAsync () ->
-            Frontend.richText.openDoc(cm, sharejsDoc.getSnapshot())
-            sharejsDoc.attachToCM(cm)
-
-        detachFromCM = (sharejsDoc) ->
-          sharejsDoc.detachFromCM()
-
-        scope.$on 'destroy', () ->
-          detachFromCM(scope.sharejsDoc)
-
-      template: """
-        <div class="cm-editor-wrapper"></div>
-      """
-    }

+ 37 - 0
services/web/public/coffee/ide/editor/directives/toggleSwitch.coffee

@@ -0,0 +1,37 @@
+define [
+  "base"
+], (App) ->
+  App.directive "toggleSwitch", () ->
+    restrict: "E"
+    scope:
+      description: "@"
+      labelFalse: "@"
+      labelTrue: "@"
+      ngModel: "="
+    template: """
+  <fieldset class="toggle-switch">
+    <legend class="sr-only">{{description}}</legend>
+
+    <input
+      type="radio"
+      name="editor-mode"
+      class="toggle-switch-input"
+      id="toggle-switch-false-{{$id}}"
+      ng-value="false"
+      ng-model="ngModel"
+    >
+    <label for="toggle-switch-false-{{$id}}" class="toggle-switch-label">{{labelFalse}}</label>
+
+    <input
+      type="radio"
+      class="toggle-switch-input"
+      name="editor-mode"
+      id="toggle-switch-true-{{$id}}"
+      ng-value="true"
+      ng-model="ngModel"
+    >
+    <label for="toggle-switch-true-{{$id}}" class="toggle-switch-label">{{labelTrue}}</label>
+
+    <span class="toggle-switch-selection" aria-hidden="true"></span>
+  </fieldset>
+"""

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

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

+ 11 - 4
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -49,6 +49,13 @@ define [
 				diff: null
 			}
 
+		restoreFile: (version, pathname) ->
+			url = "/project/#{@$scope.project_id}/restore_file"
+			@ide.$http.post(url, {
+				version, pathname,
+				_csrf: window.csrfToken
+			})
+
 		MAX_RECENT_UPDATES_TO_SELECT: 5
 		autoSelectRecentUpdates: () ->
 			return if @$scope.history.updates.length == 0
@@ -204,7 +211,7 @@ define [
 			# Map of original pathname -> doc summary
 			docs_summary = Object.create(null)
 
-			updatePathnameWithUpdateVersions = (pathname, update, deleted) ->
+			updatePathnameWithUpdateVersions = (pathname, update, deletedAtV) ->
 				# 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
@@ -222,8 +229,8 @@ define [
 					doc_summary.toV,
 					update.toV
 				)
-				if deleted?
-					doc_summary.deleted = true
+				if deletedAtV?
+					doc_summary.deletedAtV = deletedAtV
 
 			# Put updates in ascending chronological order
 			updates = updates.slice().reverse()
@@ -241,7 +248,7 @@ define [
 						updatePathnameWithUpdateVersions(add.pathname, update)
 					if project_op.remove?
 						remove = project_op.remove
-						updatePathnameWithUpdateVersions(remove.pathname, update, true)
+						updatePathnameWithUpdateVersions(remove.pathname, update, project_op.atV)
 
 			return docs_summary
 

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

@@ -3,15 +3,6 @@ define [
 	"ide/history/util/displayNameForUser"
 ], (App, displayNameForUser) ->
 
-	App.controller "HistoryPremiumPopup", ($scope, ide, sixpack)->
-		$scope.$watch "ui.view", ->
-			if $scope.ui.view == "history"
-				if $scope.project?.features?.versioning
-					$scope.versioningPopupType = "default"
-				else if $scope.ui.view == "history"
-					sixpack.participate 'history-discount', ['default', 'discount'], (chosenVariation, rawResponse)->
-						$scope.versioningPopupType = chosenVariation
-
 	App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
 		$scope.hoveringOverListSelectors = false
 

+ 40 - 0
services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee

@@ -0,0 +1,40 @@
+define [
+	"base"
+], (App) ->
+	App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
+		$scope.restoreState =
+			inflight: false
+			error: false
+
+		$scope.restoreDeletedFile = () ->
+			pathname = $scope.history.selection.pathname
+			return if !pathname?
+			version = $scope.history.selection.docs[pathname]?.deletedAtV
+			return if !version?
+			event_tracking.sendMB "history-v2-restore-deleted"
+			$scope.restoreState.inflight = true
+			ide.historyManager
+				.restoreFile(version, pathname)
+				.then (response) ->
+					{ data } = response
+					openEntity(data)
+				.catch () ->
+					ide.showGenericMessageModal('Sorry, something went wrong with the restore')
+				.finally () ->
+					$scope.restoreState.inflight = false
+
+		openEntity = (data) ->
+			iterations = 0
+			{id, type} = data
+			do tryOpen = () ->
+				if iterations > 5
+					return
+				iterations += 1
+				entity = ide.fileTreeManager.findEntityById(id)
+				if entity? and type == 'doc'
+					ide.editorManager.openDoc(entity)
+				else if entity? and type == 'file'
+					ide.binaryFilesManager.openFile(entity)
+				else
+					setTimeout(tryOpen, 500)
+			

+ 1 - 0
services/web/public/coffee/main.coffee

@@ -24,6 +24,7 @@ define [
 	"directives/stopPropagation"
 	"directives/focus"
 	"directives/equals"
+	"directives/eventTracking"
 	"directives/fineUpload"
 	"directives/onEnter"
 	"directives/selectAll"

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

@@ -19,24 +19,8 @@ define [
 						url = "#{url}&cc=#{couponCode}"
 				$scope.startedFreeTrial = true
 
-				switch source
-					when "dropbox"
-						sixpack.participate 'teaser-dropbox-text', ['default', 'dropbox-focused'], (variant) ->
-							event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
-
-					when "history"
-						sixpack.participate 'teaser-history', ['default', 'focused'], (variant) ->
-							event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
-
-					else
-						event_tracking.sendMB "subscription-start-trial", { source, plan }
+				event_tracking.sendMB "subscription-start-trial", { source, plan }
 
 				w.location = url
 
-			if $scope.shouldABTestPlans
-				sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
-					if chosenVariation in ['heron', 'ibis']
-						plan = "collaborator_#{chosenVariation}"
-					go()
-			else
-				go()
+			go()

+ 91 - 36
services/web/public/coffee/main/new-subscription.coffee

@@ -1,6 +1,7 @@
 define [
 	"base",
 	"directives/creditCards"
+	"libs/recurly-4.8.5"
 ], (App)->
 
 	App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking, ccUtils)->
@@ -21,10 +22,6 @@ define [
 			value: "credit_card"
 
 		$scope.data =
-			number: ""
-			month: ""
-			year: ""
-			cvv: ""
 			first_name: ""
 			last_name: ""
 			postal_code: ""
@@ -34,16 +31,25 @@ define [
 			city:""
 			country:window.countryCode
 			coupon: window.couponCode
-			mmYY: ""
-
-		$scope.validation =
-			correctCardNumber : true
-			correctExpiry: true
-			correctCvv: true
+			
+		$scope.validation = {}
 
 		$scope.processing = false
 
-		recurly.configure window.recurlyApiKey
+		recurly.configure 
+			publicKey: window.recurlyApiKey
+			style: 
+				all: 
+					fontFamily: '"Open Sans", sans-serif',
+					fontSize: '16px',
+					fontColor: '#7a7a7a'
+				month: 
+					placeholder: 'MM'
+				year: 
+					placeholder: 'YY'
+				cvv:
+					placeholder: 'CVV'
+
 
 		pricing = recurly.Pricing()
 		window.pricing = pricing
@@ -73,6 +79,8 @@ define [
 					$scope.normalPrice += (basePrice * pricing.price.taxes[0].rate)
 			$scope.$apply()
 
+
+
 		$scope.applyCoupon = ->
 			pricing.coupon($scope.data.coupon).done()
 
@@ -83,26 +91,6 @@ define [
 			$scope.currencyCode = newCurrency
 			pricing.currency(newCurrency).done()
 
-		$scope.updateExpiry = () ->
-			parsedDateObj = ccUtils.parseExpiry $scope.data.mmYY
-			if parsedDateObj?
-				$scope.data.month = parsedDateObj.month
-				$scope.data.year = parsedDateObj.year
-
-		$scope.validateCardNumber = validateCardNumber = ->
-			$scope.validation.errorFields = {}
-			if $scope.data.number?.length != 0
-				$scope.validation.correctCardNumber = recurly.validate.cardNumber($scope.data.number)
-
-		$scope.validateExpiry = validateExpiry = ->
-			$scope.validation.errorFields = {}
-			if $scope.data.month?.length != 0 and $scope.data.year?.length != 0
-				$scope.validation.correctExpiry = recurly.validate.expiry($scope.data.month, $scope.data.year)
-
-		$scope.validateCvv = validateCvv = ->
-			$scope.validation.errorFields = {}
-			if $scope.data.cvv?.length != 0
-				$scope.validation.correctCvv = recurly.validate.cvv($scope.data.cvv)
 
 		$scope.inputHasError = inputHasError = (formItem) ->
 			if !formItem?
@@ -114,10 +102,7 @@ define [
 			if $scope.paymentMethod.value == 'paypal' 
 				return $scope.data.country != ""
 			else 
-				return (form.$valid and 
-						$scope.validation.correctCardNumber and
-						$scope.validation.correctExpiry and
-						$scope.validation.correctCvv)
+				return form.$valid
 
 		$scope.updateCountry = ->
 			pricing.address({country:$scope.data.country}).done()
@@ -178,4 +163,74 @@ define [
 				recurly.token $scope.data, completeSubscription
 
 
-
+		$scope.countries = [
+			{code:'AF',name:'Afghanistan'},{code:'AL',name:'Albania'},{code:'DZ',name:'Algeria'},{code:'AS',name:'American Samoa'},
+			{code:'AD',name:'Andorra'},{code:'AO',name:'Angola'},{code:'AI',name:'Anguilla'},{code:'AQ',name:'Antarctica'},
+			{code:'AG',name:'Antigua and Barbuda'},{code:'AR',name:'Argentina'},{code:'AM',name:'Armenia'},{code:'AW',name:'Aruba'},
+			{code:'AC',name:'Ascension Island'},{code:'AU',name:'Australia'},{code:'AT',name:'Austria'},{code:'AZ',name:'Azerbaijan'},
+			{code:'BS',name:'Bahamas'},{code:'BH',name:'Bahrain'},{code:'BD',name:'Bangladesh'},{code:'BB',name:'Barbados'},
+			{code:'BE',name:'Belgium'},{code:'BZ',name:'Belize'},{code:'BJ',name:'Benin'},{code:'BM',name:'Bermuda'},
+			{code:'BT',name:'Bhutan'},{code:'BO',name:'Bolivia'},{code:'BA',name:'Bosnia and Herzegovina'},{code:'BW',name:'Botswana'},
+			{code:'BV',name:'Bouvet Island'},{code:'BR',name:'Brazil'},{code:'BQ',name:'British Antarctic Territory'},
+			{code:'IO',name:'British Indian Ocean Territory'},{code:'VG',name:'British Virgin Islands'},{code:'BN',name:'Brunei'},
+			{code:'BG',name:'Bulgaria'},{code:'BF',name:'Burkina Faso'},{code:'BI',name:'Burundi'},{code:'KH',name:'Cambodia'},
+			{code:'CM',name:'Cameroon'},{code:'CA',name:'Canada'},{code:'IC',name:'Canary Islands'},
+			{code:'CT',name:'Canton and Enderbury Islands'},{code:'CV',name:'Cape Verde'},{code:'KY',name:'Cayman Islands'},
+			{code:'CF',name:'Central African Republic'},{code:'EA',name:'Ceuta and Melilla'},{code:'TD',name:'Chad'},
+			{code:'CL',name:'Chile'},{code:'CN',name:'China'},{code:'CX',name:'Christmas Island'},{code:'CP',name:'Clipperton Island'},
+			{code:'CC',name:'Cocos [Keeling] Islands'},{code:'CO',name:'Colombia'},{code:'KM',name:'Comoros'},{code:'CD',name:'Congo [DRC]'},
+			{code:'CK',name:'Cook Islands'},{code:'CR',name:'Costa Rica'},{code:'HR',name:'Croatia'},{code:'CU',name:'Cuba'},
+			{code:'CY',name:'Cyprus'},{code:'CZ',name:'Czech Republic'},{code:'DK',name:'Denmark'},{code:'DG',name:'Diego Garcia'},
+			{code:'DJ',name:'Djibouti'},{code:'DM',name:'Dominica'},{code:'DO',name:'Dominican Republic'},
+			{code:'NQ',name:'Dronning Maud Land'},{code:'TL',name:'East Timor'},{code:'EC',name:'Ecuador'},{code:'EG',name:'Egypt'},
+			{code:'SV',name:'El Salvador'},{code:'EE',name:'Estonia'},{code:'ET',name:'Ethiopia'},
+			{code:'FK',name:'Falkland Islands [Islas Malvinas]'},{code:'FO',name:'Faroe Islands'},{code:'FJ',name:'Fiji'},
+			{code:'FI',name:'Finland'},{code:'FR',name:'France'},{code:'GF',name:'French Guiana'},{code:'PF',name:'French Polynesia'},
+			{code:'TF',name:'French Southern Territories'},{code:'FQ',name:'French Southern and Antarctic Territories'},
+			{code:'GA',name:'Gabon'},{code:'GM',name:'Gambia'},{code:'GE',name:'Georgia'},{code:'DE',name:'Germany'},
+			{code:'GH',name:'Ghana'},{code:'GI',name:'Gibraltar'},{code:'GR',name:'Greece'},{code:'GL',name:'Greenland'},
+			{code:'GD',name:'Grenada'},{code:'GP',name:'Guadeloupe'},{code:'GU',name:'Guam'},{code:'GT',name:'Guatemala'},
+			{code:'GG',name:'Guernsey'},{code:'GW',name:'Guinea-Bissau'},{code:'GY',name:'Guyana'},{code:'HT',name:'Haiti'},
+			{code:'HM',name:'Heard Island and McDonald Islands'},{code:'HN',name:'Honduras'},{code:'HK',name:'Hong Kong'},
+			{code:'HU',name:'Hungary'},{code:'IS',name:'Iceland'},{code:'IN',name:'India'},{code:'ID',name:'Indonesia'},
+			{code:'IE',name:'Ireland'},{code:'IM',name:'Isle of Man'},{code:'IL',name:'Israel'},{code:'IT',name:'Italy'},
+			{code:'JM',name:'Jamaica'},{code:'JP',name:'Japan'},{code:'JE',name:'Jersey'},{code:'JT',name:'Johnston Island'},
+			{code:'JO',name:'Jordan'},{code:'KZ',name:'Kazakhstan'},{code:'KE',name:'Kenya'},{code:'KI',name:'Kiribati'},
+			{code:'KW',name:'Kuwait'},{code:'KG',name:'Kyrgyzstan'},{code:'LA',name:'Laos'},{code:'LV',name:'Latvia'},
+			{code:'LS',name:'Lesotho'},{code:'LY',name:'Libya'},{code:'LI',name:'Liechtenstein'},{code:'LT',name:'Lithuania'},
+			{code:'LU',name:'Luxembourg'},{code:'MO',name:'Macau'},{code:'MK',name:'Macedonia [FYROM]'},{code:'MG',name:'Madagascar'},
+			{code:'MW',name:'Malawi'},{code:'MY',name:'Malaysia'},{code:'MV',name:'Maldives'},{code:'ML',name:'Mali'},
+			{code:'MT',name:'Malta'},{code:'MH',name:'Marshall Islands'},{code:'MQ',name:'Martinique'},{code:'MR',name:'Mauritania'},
+			{code:'MU',name:'Mauritius'},{code:'YT',name:'Mayotte'},{code:'FX',name:'Metropolitan France'},{code:'MX',name:'Mexico'},
+			{code:'FM',name:'Micronesia'},{code:'MI',name:'Midway Islands'},{code:'MD',name:'Moldova'},{code:'MC',name:'Monaco'},
+			{code:'MN',name:'Mongolia'},{code:'ME',name:'Montenegro'},{code:'MS',name:'Montserrat'},{code:'MA',name:'Morocco'},
+			{code:'MZ',name:'Mozambique'},{code:'NA',name:'Namibia'},{code:'NR',name:'Nauru'},{code:'NP',name:'Nepal'},
+			{code:'NL',name:'Netherlands'},{code:'AN',name:'Netherlands Antilles'},{code:'NT',name:'Neutral Zone'},
+			{code:'NC',name:'New Caledonia'},{code:'NZ',name:'New Zealand'},{code:'NI',name:'Nicaragua'},{code:'NE',name:'Niger'},
+			{code:'NG',name:'Nigeria'},{code:'NU',name:'Niue'},{code:'NF',name:'Norfolk Island'},{code:'VD',name:'North Vietnam'},
+			{code:'MP',name:'Northern Mariana Islands'},{code:'NO',name:'Norway'},{code:'OM',name:'Oman'},
+			{code:'QO',name:'Outlying Oceania'},{code:'PC',name:'Pacific Islands Trust Territory'},{code:'PK',name:'Pakistan'},
+			{code:'PW',name:'Palau'},{code:'PS',name:'Palestinian Territories'},{code:'PA',name:'Panama'},{code:'PZ',name:'Panama Canal Zone'},
+			{code:'PY',name:'Paraguay'},{code:'YD',name:'People&apos;s Democratic Republic of Yemen'},{code:'PE',name:'Peru'},
+			{code:'PH',name:'Philippines'},{code:'PN',name:'Pitcairn Islands'},{code:'PL',name:'Poland'},{code:'PT',name:'Portugal'},
+			{code:'PR',name:'Puerto Rico'},{code:'QA',name:'Qatar'},{code:'RO',name:'Romania'},{code:'RU',name:'Russia'},
+			{code:'RW',name:'Rwanda'},{code:'RE',name:'R&eacute;union'},{code:'BL',name:'Saint Barth&eacute;lemy'},
+			{code:'SH',name:'Saint Helena'},{code:'KN',name:'Saint Kitts and Nevis'},{code:'LC',name:'Saint Lucia'},
+			{code:'MF',name:'Saint Martin'},{code:'PM',name:'Saint Pierre and Miquelon'},{code:'VC',name:'Saint Vincent and the Grenadines'},
+			{code:'WS',name:'Samoa'},{code:'SM',name:'San Marino'},{code:'SA',name:'Saudi Arabia'},{code:'SN',name:'Senegal'},
+			{code:'RS',name:'Serbia'},{code:'CS',name:'Serbia and Montenegro'},{code:'SC',name:'Seychelles'},{code:'SL',name:'Sierra Leone'},
+			{code:'SG',name:'Singapore'},{code:'SK',name:'Slovakia'},{code:'SI',name:'Slovenia'},{code:'SB',name:'Solomon Islands'},
+			{code:'ZA',name:'South Africa'},{code:'GS',name:'South Georgia and the South Sandwich Islands'},{code:'KR',name:'South Korea'},
+			{code:'ES',name:'Spain'},{code:'LK',name:'Sri Lanka'},{code:'SR',name:'Suriname'},{code:'SJ',name:'Svalbard and Jan Mayen'},
+			{code:'SZ',name:'Swaziland'},{code:'SE',name:'Sweden'},{code:'CH',name:'Switzerland'},
+			{code:'ST',name:'S&atilde;o Tom&eacute; and Pr&iacute;ncipe'},{code:'TW',name:'Taiwan'},{code:'TJ',name:'Tajikistan'},
+			{code:'TZ',name:'Tanzania'},{code:'TH',name:'Thailand'},{code:'TG',name:'Togo'},{code:'TK',name:'Tokelau'},{code:'TO',name:'Tonga'},
+			{code:'TT',name:'Trinidad and Tobago'},{code:'TA',name:'Tristan da Cunha'},{code:'TN',name:'Tunisia'},{code:'TR',name:'Turkey'},
+			{code:'TM',name:'Turkmenistan'},{code:'TC',name:'Turks and Caicos Islands'},{code:'TV',name:'Tuvalu'},
+			{code:'UM',name:'U.S. Minor Outlying Islands'},{code:'PU',name:'U.S. Miscellaneous Pacific Islands'},
+			{code:'VI',name:'U.S. Virgin Islands'},{code:'UG',name:'Uganda'},{code:'UA',name:'Ukraine'},{code:'AE',name:'United Arab Emirates'},
+			{code:'GB',name:'United Kingdom'},{code:'US',name:'United States'},{code:'UY',name:'Uruguay'},{code:'UZ',name:'Uzbekistan'},
+			{code:'VU',name:'Vanuatu'},{code:'VA',name:'Vatican City'},{code:'VE',name:'Venezuela'},{code:'VN',name:'Vietnam'},
+			{code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'},
+			{code:'ZM',name:'Zambia'},{code:'AX',name:'&angst;land Islandscode:'}
+		]

+ 1 - 167
services/web/public/coffee/main/plans.coffee

@@ -1,6 +1,6 @@
 define [
 	"base"
-	"libs/recurly-3.0.5"
+	"libs/recurly-4.8.5"
 ], (App, recurly) ->
 
 
@@ -11,164 +11,6 @@ define [
 		return {
 			currencyCode:currencyCode
 
-			heron:
-				USD:
-					student:
-						monthly: "$6"
-						annual: "$60"
-					collaborator:
-						monthly: "$12"
-						annual: "$144"
-				EUR:
-					student:
-						monthly: "€5"
-						annual: "€50"
-					collaborator:
-						monthly: "€11"
-						annual: "€132"
-				GBP:
-					student:
-						monthly: "£5"
-						annual: "£50"
-					collaborator:
-						monthly: "£10"
-						annual: "£120"
-				SEK:
-					student:
-						monthly: "45 kr"
-						annual: "450 kr"
-					collaborator:
-						monthly: "90 kr"
-						annual: "1080 kr"
-				CAD:
-					student:
-						monthly: "$7"
-						annual: "$70"
-					collaborator:
-						monthly: "$14"
-						annual: "$168"
-				NOK:
-					student:
-						monthly: "45 kr"
-						annual: "450 kr"
-					collaborator:
-						monthly: "90 kr"
-						annual: "1080 kr"
-				DKK:
-					student:
-						monthly: "40 kr"
-						annual: "400 kr"
-					collaborator:
-						monthly: "70 kr"
-						annual: "840 kr"
-				AUD:
-					student:
-						monthly: "$8"
-						annual: "$80"
-					collaborator:
-						monthly: "$15"
-						annual: "$180"
-				NZD:
-					student:
-						monthly: "$8"
-						annual: "$80"
-					collaborator:
-						monthly: "$15"
-						annual: "$180"
-				CHF:
-					student:
-						monthly: "Fr 6"
-						annual: "Fr 60"
-					collaborator:
-						monthly: "Fr 12"
-						annual: "Fr 144"
-				SGD:
-					student:
-						monthly: "$8"
-						annual: "$80"
-					collaborator:
-						monthly: "$16"
-						annual: "$192"
-
-			ibis:
-				USD:
-					student:
-						monthly: "$10"
-						annual: "$100"
-					collaborator:
-						monthly: "$18"
-						annual: "$216"
-				EUR:
-					student:
-						monthly: "€9"
-						annual: "€90"
-					collaborator:
-						monthly: "€17"
-						annual: "€204"
-				GBP:
-					student:
-						monthly: "£7"
-						annual: "£70"
-					collaborator:
-						monthly: "£14"
-						annual: "£168"
-				SEK:
-					student:
-						monthly: "75 kr"
-						annual: "750 kr"
-					collaborator:
-						monthly: "140 kr"
-						annual: "1680 kr"
-				CAD:
-					student:
-						monthly: "$12"
-						annual: "$120"
-					collaborator:
-						monthly: "$22"
-						annual: "$264"
-				NOK:
-					student:
-						monthly: "75 kr"
-						annual: "750 kr"
-					collaborator:
-						monthly: "140 kr"
-						annual: "1680 kr"
-				DKK:
-					student:
-						monthly: "68 kr"
-						annual: "680 kr"
-					collaborator:
-						monthly: "110 kr"
-						annual: "1320 kr"
-				AUD:
-					student:
-						monthly: "$13"
-						annual: "$130"
-					collaborator:
-						monthly: "$22"
-						annual: "$264"
-				NZD:
-					student:
-						monthly: "$14"
-						annual: "$140"
-					collaborator:
-						monthly: "$22"
-						annual: "$264"
-				CHF:
-					student:
-						monthly: "Fr 10"
-						annual: "Fr 100"
-					collaborator:
-						monthly: "Fr 18"
-						annual: "Fr 216"
-				SGD:
-					student:
-						monthly: "$14"
-						annual: "$140"
-					collaborator:
-						monthly: "$25"
-						annual: "$300"
-
 			plans:
 				USD:
 					symbol: "$"
@@ -312,14 +154,6 @@ define [
 		$scope.shouldABTestPlans = window.shouldABTestPlans
 
 		if $scope.shouldABTestPlans
-			sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
-				$scope.plansVariant = chosenVariation
-				event_tracking.sendMB 'plans-page', {plans_variant: chosenVariation}
-				if chosenVariation in ['heron', 'ibis']
-					# overwrite student plans with alternative
-					for currency, _v of $scope.plans
-						$scope.plans[currency]['student'] = MultiCurrencyPricing[chosenVariation][currency]['student']
-						$scope.plans[currency]['collaborator'] = MultiCurrencyPricing[chosenVariation][currency]['collaborator']
 				$scope.showPlans = true
 		else
 			$scope.showPlans = true

+ 0 - 2
services/web/public/coffee/main/subscription-dashboard.coffee

@@ -3,8 +3,6 @@ define [
 ], (App)->
 
 	App.controller 'SuccessfulSubscriptionController', ($scope, sixpack) ->
-		sixpack.convert 'plans-1610', () ->
-
 
 	SUBSCRIPTION_URL = "/user/subscription/update"
 

+ 7 - 0
services/web/public/coffee/utils/EventEmitter.coffee

@@ -1,4 +1,11 @@
 define [], () ->
+	# Simple event emitter implementation, but has a slightly unusual API for
+	# removing specific listeners. If a specific listener needs to be removed
+	# (instead of all listeners), then it needs to use a "namespace":
+	# Create a listener on the foo event with bar namespace: .on 'foo.bar'
+	# Trigger all events for the foo event (including namespaces): .trigger 'foo'
+	# Remove all listeners for the foo event (including namespaces): .off 'foo'
+	# Remove a listener for the foo event with the bar namespace: .off 'foo.bar'
 	class EventEmitter
 		on: (event, callback) ->
 			@events ||= {}

+ 0 - 16
services/web/public/es/rich-text.js

@@ -1,16 +0,0 @@
-import CodeMirror, { Doc } from 'codemirror'
-
-export function init (rootEl) {
-  CodeMirror.defineMIME('application/x-tex', 'latex')
-  CodeMirror.defineMIME('application/x-latex', 'latex')
-
-  return CodeMirror(rootEl, {
-    mode: 'latex'
-  })
-}
-
-export function openDoc (cm, content) {
-  const newDoc = Doc(content, 'latex')
-  cm.swapDoc(newDoc)
-  return newDoc
-}

BIN
services/web/public/img/teasers/history/teaser-history.png


+ 0 - 4218
services/web/public/js/libs/recurly-3.0.5.js

@@ -1,4218 +0,0 @@
-;(function(){
-
-/**
- * Require the given path.
- *
- * @param {String} path
- * @return {Object} exports
- * @api public
- */
-
-function require(path, parent, orig) {
-  var resolved = require.resolve(path);
-
-  // lookup failed
-  if (null == resolved) {
-    orig = orig || path;
-    parent = parent || 'root';
-    var err = new Error('Failed to require "' + orig + '" from "' + parent + '"');
-    err.path = orig;
-    err.parent = parent;
-    err.require = true;
-    throw err;
-  }
-
-  var module = require.modules[resolved];
-
-  // perform real require()
-  // by invoking the module's
-  // registered function
-  if (!module._resolving && !module.exports) {
-    var mod = {};
-    mod.exports = {};
-    mod.client = mod.component = true;
-    module._resolving = true;
-    module.call(this, mod.exports, require.relative(resolved), mod);
-    delete module._resolving;
-    module.exports = mod.exports;
-  }
-
-  return module.exports;
-}
-
-/**
- * Registered modules.
- */
-
-require.modules = {};
-
-/**
- * Registered aliases.
- */
-
-require.aliases = {};
-
-/**
- * Resolve `path`.
- *
- * Lookup:
- *
- *   - PATH/index.js
- *   - PATH.js
- *   - PATH
- *
- * @param {String} path
- * @return {String} path or null
- * @api private
- */
-
-require.resolve = function(path) {
-  if (path.charAt(0) === '/') path = path.slice(1);
-
-  var paths = [
-    path,
-    path + '.js',
-    path + '.json',
-    path + '/index.js',
-    path + '/index.json'
-  ];
-
-  for (var i = 0; i < paths.length; i++) {
-    var path = paths[i];
-    if (require.modules.hasOwnProperty(path)) return path;
-    if (require.aliases.hasOwnProperty(path)) return require.aliases[path];
-  }
-};
-
-/**
- * Normalize `path` relative to the current path.
- *
- * @param {String} curr
- * @param {String} path
- * @return {String}
- * @api private
- */
-
-require.normalize = function(curr, path) {
-  var segs = [];
-
-  if ('.' != path.charAt(0)) return path;
-
-  curr = curr.split('/');
-  path = path.split('/');
-
-  for (var i = 0; i < path.length; ++i) {
-    if ('..' == path[i]) {
-      curr.pop();
-    } else if ('.' != path[i] && '' != path[i]) {
-      segs.push(path[i]);
-    }
-  }
-
-  return curr.concat(segs).join('/');
-};
-
-/**
- * Register module at `path` with callback `definition`.
- *
- * @param {String} path
- * @param {Function} definition
- * @api private
- */
-
-require.register = function(path, definition) {
-  require.modules[path] = definition;
-};
-
-/**
- * Alias a module definition.
- *
- * @param {String} from
- * @param {String} to
- * @api private
- */
-
-require.alias = function(from, to) {
-  if (!require.modules.hasOwnProperty(from)) {
-    throw new Error('Failed to alias "' + from + '", it does not exist');
-  }
-  require.aliases[to] = from;
-};
-
-/**
- * Return a require function relative to the `parent` path.
- *
- * @param {String} parent
- * @return {Function}
- * @api private
- */
-
-require.relative = function(parent) {
-  var p = require.normalize(parent, '..');
-
-  /**
-   * lastIndexOf helper.
-   */
-
-  function lastIndexOf(arr, obj) {
-    var i = arr.length;
-    while (i--) {
-      if (arr[i] === obj) return i;
-    }
-    return -1;
-  }
-
-  /**
-   * The relative require() itself.
-   */
-
-  function localRequire(path) {
-    var resolved = localRequire.resolve(path);
-    return require(resolved, parent, path);
-  }
-
-  /**
-   * Resolve relative to the parent.
-   */
-
-  localRequire.resolve = function(path) {
-    var c = path.charAt(0);
-    if ('/' == c) return path.slice(1);
-    if ('.' == c) return require.normalize(p, path);
-
-    // resolve deps by returning
-    // the dep in the nearest "deps"
-    // directory
-    var segs = parent.split('/');
-    var i = lastIndexOf(segs, 'deps') + 1;
-    if (!i) i = 0;
-    path = segs.slice(0, i + 1).join('/') + '/deps/' + path;
-    return path;
-  };
-
-  /**
-   * Check if module is defined at `path`.
-   */
-
-  localRequire.exists = function(path) {
-    return require.modules.hasOwnProperty(localRequire.resolve(path));
-  };
-
-  return localRequire;
-};
-require.register("visionmedia-node-querystring/index.js", function(exports, require, module){
-/**
- * Object#toString() ref for stringify().
- */
-
-var toString = Object.prototype.toString;
-
-/**
- * Object#hasOwnProperty ref
- */
-
-var hasOwnProperty = Object.prototype.hasOwnProperty;
-
-/**
- * Array#indexOf shim.
- */
-
-var indexOf = typeof Array.prototype.indexOf === 'function'
-  ? function(arr, el) { return arr.indexOf(el); }
-  : function(arr, el) {
-      for (var i = 0; i < arr.length; i++) {
-        if (arr[i] === el) return i;
-      }
-      return -1;
-    };
-
-/**
- * Array.isArray shim.
- */
-
-var isArray = Array.isArray || function(arr) {
-  return toString.call(arr) == '[object Array]';
-};
-
-/**
- * Object.keys shim.
- */
-
-var objectKeys = Object.keys || function(obj) {
-  var ret = [];
-  for (var key in obj) {
-    if (obj.hasOwnProperty(key)) {
-      ret.push(key);
-    }
-  }
-  return ret;
-};
-
-/**
- * Array#forEach shim.
- */
-
-var forEach = typeof Array.prototype.forEach === 'function'
-  ? function(arr, fn) { return arr.forEach(fn); }
-  : function(arr, fn) {
-      for (var i = 0; i < arr.length; i++) fn(arr[i]);
-    };
-
-/**
- * Array#reduce shim.
- */
-
-var reduce = function(arr, fn, initial) {
-  if (typeof arr.reduce === 'function') return arr.reduce(fn, initial);
-  var res = initial;
-  for (var i = 0; i < arr.length; i++) res = fn(res, arr[i]);
-  return res;
-};
-
-/**
- * Cache non-integer test regexp.
- */
-
-var isint = /^[0-9]+$/;
-
-function promote(parent, key) {
-  if (parent[key].length == 0) return parent[key] = {}
-  var t = {};
-  for (var i in parent[key]) {
-    if (hasOwnProperty.call(parent[key], i)) {
-      t[i] = parent[key][i];
-    }
-  }
-  parent[key] = t;
-  return t;
-}
-
-function parse(parts, parent, key, val) {
-  var part = parts.shift();
-  
-  // illegal
-  if (Object.getOwnPropertyDescriptor(Object.prototype, key)) return;
-  
-  // end
-  if (!part) {
-    if (isArray(parent[key])) {
-      parent[key].push(val);
-    } else if ('object' == typeof parent[key]) {
-      parent[key] = val;
-    } else if ('undefined' == typeof parent[key]) {
-      parent[key] = val;
-    } else {
-      parent[key] = [parent[key], val];
-    }
-    // array
-  } else {
-    var obj = parent[key] = parent[key] || [];
-    if (']' == part) {
-      if (isArray(obj)) {
-        if ('' != val) obj.push(val);
-      } else if ('object' == typeof obj) {
-        obj[objectKeys(obj).length] = val;
-      } else {
-        obj = parent[key] = [parent[key], val];
-      }
-      // prop
-    } else if (~indexOf(part, ']')) {
-      part = part.substr(0, part.length - 1);
-      if (!isint.test(part) && isArray(obj)) obj = promote(parent, key);
-      parse(parts, obj, part, val);
-      // key
-    } else {
-      if (!isint.test(part) && isArray(obj)) obj = promote(parent, key);
-      parse(parts, obj, part, val);
-    }
-  }
-}
-
-/**
- * Merge parent key/val pair.
- */
-
-function merge(parent, key, val){
-  if (~indexOf(key, ']')) {
-    var parts = key.split('[')
-      , len = parts.length
-      , last = len - 1;
-    parse(parts, parent, 'base', val);
-    // optimize
-  } else {
-    if (!isint.test(key) && isArray(parent.base)) {
-      var t = {};
-      for (var k in parent.base) t[k] = parent.base[k];
-      parent.base = t;
-    }
-    set(parent.base, key, val);
-  }
-
-  return parent;
-}
-
-/**
- * Compact sparse arrays.
- */
-
-function compact(obj) {
-  if ('object' != typeof obj) return obj;
-
-  if (isArray(obj)) {
-    var ret = [];
-
-    for (var i in obj) {
-      if (hasOwnProperty.call(obj, i)) {
-        ret.push(obj[i]);
-      }
-    }
-
-    return ret;
-  }
-
-  for (var key in obj) {
-    obj[key] = compact(obj[key]);
-  }
-
-  return obj;
-}
-
-/**
- * Parse the given obj.
- */
-
-function parseObject(obj){
-  var ret = { base: {} };
-
-  forEach(objectKeys(obj), function(name){
-    merge(ret, name, obj[name]);
-  });
-
-  return compact(ret.base);
-}
-
-/**
- * Parse the given str.
- */
-
-function parseString(str){
-  var ret = reduce(String(str).split('&'), function(ret, pair){
-    var eql = indexOf(pair, '=')
-      , brace = lastBraceInKey(pair)
-      , key = pair.substr(0, brace || eql)
-      , val = pair.substr(brace || eql, pair.length)
-      , val = val.substr(indexOf(val, '=') + 1, val.length);
-
-    // ?foo
-    if ('' == key) key = pair, val = '';
-    if ('' == key) return ret;
-
-    return merge(ret, decode(key), decode(val));
-  }, { base: {} }).base;
-
-  return compact(ret);
-}
-
-/**
- * Parse the given query `str` or `obj`, returning an object.
- *
- * @param {String} str | {Object} obj
- * @return {Object}
- * @api public
- */
-
-exports.parse = function(str){
-  if (null == str || '' == str) return {};
-  return 'object' == typeof str
-    ? parseObject(str)
-    : parseString(str);
-};
-
-/**
- * Turn the given `obj` into a query string
- *
- * @param {Object} obj
- * @return {String}
- * @api public
- */
-
-var stringify = exports.stringify = function(obj, prefix) {
-  if (isArray(obj)) {
-    return stringifyArray(obj, prefix);
-  } else if ('[object Object]' == toString.call(obj)) {
-    return stringifyObject(obj, prefix);
-  } else if ('string' == typeof obj) {
-    return stringifyString(obj, prefix);
-  } else {
-    return prefix + '=' + encodeURIComponent(String(obj));
-  }
-};
-
-/**
- * Stringify the given `str`.
- *
- * @param {String} str
- * @param {String} prefix
- * @return {String}
- * @api private
- */
-
-function stringifyString(str, prefix) {
-  if (!prefix) throw new TypeError('stringify expects an object');
-  return prefix + '=' + encodeURIComponent(str);
-}
-
-/**
- * Stringify the given `arr`.
- *
- * @param {Array} arr
- * @param {String} prefix
- * @return {String}
- * @api private
- */
-
-function stringifyArray(arr, prefix) {
-  var ret = [];
-  if (!prefix) throw new TypeError('stringify expects an object');
-  for (var i = 0; i < arr.length; i++) {
-    ret.push(stringify(arr[i], prefix + '[' + i + ']'));
-  }
-  return ret.join('&');
-}
-
-/**
- * Stringify the given `obj`.
- *
- * @param {Object} obj
- * @param {String} prefix
- * @return {String}
- * @api private
- */
-
-function stringifyObject(obj, prefix) {
-  var ret = []
-    , keys = objectKeys(obj)
-    , key;
-
-  for (var i = 0, len = keys.length; i < len; ++i) {
-    key = keys[i];
-    if ('' == key) continue;
-    if (null == obj[key]) {
-      ret.push(encodeURIComponent(key) + '=');
-    } else {
-      ret.push(stringify(obj[key], prefix
-        ? prefix + '[' + encodeURIComponent(key) + ']'
-        : encodeURIComponent(key)));
-    }
-  }
-
-  return ret.join('&');
-}
-
-/**
- * Set `obj`'s `key` to `val` respecting
- * the weird and wonderful syntax of a qs,
- * where "foo=bar&foo=baz" becomes an array.
- *
- * @param {Object} obj
- * @param {String} key
- * @param {String} val
- * @api private
- */
-
-function set(obj, key, val) {
-  var v = obj[key];
-  if (Object.getOwnPropertyDescriptor(Object.prototype, key)) return;
-  if (undefined === v) {
-    obj[key] = val;
-  } else if (isArray(v)) {
-    v.push(val);
-  } else {
-    obj[key] = [v, val];
-  }
-}
-
-/**
- * Locate last brace in `str` within the key.
- *
- * @param {String} str
- * @return {Number}
- * @api private
- */
-
-function lastBraceInKey(str) {
-  var len = str.length
-    , brace
-    , c;
-  for (var i = 0; i < len; ++i) {
-    c = str[i];
-    if (']' == c) brace = false;
-    if ('[' == c) brace = true;
-    if ('=' == c && !brace) return i;
-  }
-}
-
-/**
- * Decode `str`.
- *
- * @param {String} str
- * @return {String}
- * @api private
- */
-
-function decode(str) {
-  try {
-    return decodeURIComponent(str.replace(/\+/g, ' '));
-  } catch (err) {
-    return str;
-  }
-}
-
-});
-require.register("component-emitter/index.js", function(exports, require, module){
-
-/**
- * Expose `Emitter`.
- */
-
-module.exports = Emitter;
-
-/**
- * Initialize a new `Emitter`.
- *
- * @api public
- */
-
-function Emitter(obj) {
-  if (obj) return mixin(obj);
-};
-
-/**
- * Mixin the emitter properties.
- *
- * @param {Object} obj
- * @return {Object}
- * @api private
- */
-
-function mixin(obj) {
-  for (var key in Emitter.prototype) {
-    obj[key] = Emitter.prototype[key];
-  }
-  return obj;
-}
-
-/**
- * Listen on the given `event` with `fn`.
- *
- * @param {String} event
- * @param {Function} fn
- * @return {Emitter}
- * @api public
- */
-
-Emitter.prototype.on =
-Emitter.prototype.addEventListener = function(event, fn){
-  this._callbacks = this._callbacks || {};
-  (this._callbacks[event] = this._callbacks[event] || [])
-    .push(fn);
-  return this;
-};
-
-/**
- * Adds an `event` listener that will be invoked a single
- * time then automatically removed.
- *
- * @param {String} event
- * @param {Function} fn
- * @return {Emitter}
- * @api public
- */
-
-Emitter.prototype.once = function(event, fn){
-  var self = this;
-  this._callbacks = this._callbacks || {};
-
-  function on() {
-    self.off(event, on);
-    fn.apply(this, arguments);
-  }
-
-  on.fn = fn;
-  this.on(event, on);
-  return this;
-};
-
-/**
- * Remove the given callback for `event` or all
- * registered callbacks.
- *
- * @param {String} event
- * @param {Function} fn
- * @return {Emitter}
- * @api public
- */
-
-Emitter.prototype.off =
-Emitter.prototype.removeListener =
-Emitter.prototype.removeAllListeners =
-Emitter.prototype.removeEventListener = function(event, fn){
-  this._callbacks = this._callbacks || {};
-
-  // all
-  if (0 == arguments.length) {
-    this._callbacks = {};
-    return this;
-  }
-
-  // specific event
-  var callbacks = this._callbacks[event];
-  if (!callbacks) return this;
-
-  // remove all handlers
-  if (1 == arguments.length) {
-    delete this._callbacks[event];
-    return this;
-  }
-
-  // remove specific handler
-  var cb;
-  for (var i = 0; i < callbacks.length; i++) {
-    cb = callbacks[i];
-    if (cb === fn || cb.fn === fn) {
-      callbacks.splice(i, 1);
-      break;
-    }
-  }
-  return this;
-};
-
-/**
- * Emit `event` with the given args.
- *
- * @param {String} event
- * @param {Mixed} ...
- * @return {Emitter}
- */
-
-Emitter.prototype.emit = function(event){
-  this._callbacks = this._callbacks || {};
-  var args = [].slice.call(arguments, 1)
-    , callbacks = this._callbacks[event];
-
-  if (callbacks) {
-    callbacks = callbacks.slice(0);
-    for (var i = 0, len = callbacks.length; i < len; ++i) {
-      callbacks[i].apply(this, args);
-    }
-  }
-
-  return this;
-};
-
-/**
- * Return array of callbacks for `event`.
- *
- * @param {String} event
- * @return {Array}
- * @api public
- */
-
-Emitter.prototype.listeners = function(event){
-  this._callbacks = this._callbacks || {};
-  return this._callbacks[event] || [];
-};
-
-/**
- * Check if this emitter has `event` handlers.
- *
- * @param {String} event
- * @return {Boolean}
- * @api public
- */
-
-Emitter.prototype.hasListeners = function(event){
-  return !! this.listeners(event).length;
-};
-
-});
-require.register("component-indexof/index.js", function(exports, require, module){
-module.exports = function(arr, obj){
-  if (arr.indexOf) return arr.indexOf(obj);
-  for (var i = 0; i < arr.length; ++i) {
-    if (arr[i] === obj) return i;
-  }
-  return -1;
-};
-});
-require.register("component-object/index.js", function(exports, require, module){
-
-/**
- * HOP ref.
- */
-
-var has = Object.prototype.hasOwnProperty;
-
-/**
- * Return own keys in `obj`.
- *
- * @param {Object} obj
- * @return {Array}
- * @api public
- */
-
-exports.keys = Object.keys || function(obj){
-  var keys = [];
-  for (var key in obj) {
-    if (has.call(obj, key)) {
-      keys.push(key);
-    }
-  }
-  return keys;
-};
-
-/**
- * Return own values in `obj`.
- *
- * @param {Object} obj
- * @return {Array}
- * @api public
- */
-
-exports.values = function(obj){
-  var vals = [];
-  for (var key in obj) {
-    if (has.call(obj, key)) {
-      vals.push(obj[key]);
-    }
-  }
-  return vals;
-};
-
-/**
- * Merge `b` into `a`.
- *
- * @param {Object} a
- * @param {Object} b
- * @return {Object} a
- * @api public
- */
-
-exports.merge = function(a, b){
-  for (var key in b) {
-    if (has.call(b, key)) {
-      a[key] = b[key];
-    }
-  }
-  return a;
-};
-
-/**
- * Return length of `obj`.
- *
- * @param {Object} obj
- * @return {Number}
- * @api public
- */
-
-exports.length = function(obj){
-  return exports.keys(obj).length;
-};
-
-/**
- * Check if `obj` is empty.
- *
- * @param {Object} obj
- * @return {Boolean}
- * @api public
- */
-
-exports.isEmpty = function(obj){
-  return 0 == exports.length(obj);
-};
-});
-require.register("component-event/index.js", function(exports, require, module){
-var bind = window.addEventListener ? 'addEventListener' : 'attachEvent',
-    unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent',
-    prefix = bind !== 'addEventListener' ? 'on' : '';
-
-/**
- * Bind `el` event `type` to `fn`.
- *
- * @param {Element} el
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @return {Function}
- * @api public
- */
-
-exports.bind = function(el, type, fn, capture){
-  el[bind](prefix + type, fn, capture || false);
-  return fn;
-};
-
-/**
- * Unbind `el` event `type`'s callback `fn`.
- *
- * @param {Element} el
- * @param {String} type
- * @param {Function} fn
- * @param {Boolean} capture
- * @return {Function}
- * @api public
- */
-
-exports.unbind = function(el, type, fn, capture){
-  el[unbind](prefix + type, fn, capture || false);
-  return fn;
-};
-});
-require.register("component-clone/index.js", function(exports, require, module){
-/**
- * Module dependencies.
- */
-
-var type;
-try {
-  type = require('component-type');
-} catch (_) {
-  type = require('type');
-}
-
-/**
- * Module exports.
- */
-
-module.exports = clone;
-
-/**
- * Clones objects.
- *
- * @param {Mixed} any object
- * @api public
- */
-
-function clone(obj){
-  switch (type(obj)) {
-    case 'object':
-      var copy = {};
-      for (var key in obj) {
-        if (obj.hasOwnProperty(key)) {
-          copy[key] = clone(obj[key]);
-        }
-      }
-      return copy;
-
-    case 'array':
-      var copy = new Array(obj.length);
-      for (var i = 0, l = obj.length; i < l; i++) {
-        copy[i] = clone(obj[i]);
-      }
-      return copy;
-
-    case 'regexp':
-      // from millermedeiros/amd-utils - MIT
-      var flags = '';
-      flags += obj.multiline ? 'm' : '';
-      flags += obj.global ? 'g' : '';
-      flags += obj.ignoreCase ? 'i' : '';
-      return new RegExp(obj.source, flags);
-
-    case 'date':
-      return new Date(obj.getTime());
-
-    default: // string, number, boolean, …
-      return obj;
-  }
-}
-
-});
-require.register("component-bind/index.js", function(exports, require, module){
-
-/**
- * Slice reference.
- */
-
-var slice = [].slice;
-
-/**
- * Bind `obj` to `fn`.
- *
- * @param {Object} obj
- * @param {Function|String} fn or string
- * @return {Function}
- * @api public
- */
-
-module.exports = function(obj, fn){
-  if ('string' == typeof fn) fn = obj[fn];
-  if ('function' != typeof fn) throw new Error('bind() requires a function');
-  var args = [].slice.call(arguments, 2);
-  return function(){
-    return fn.apply(obj, args.concat(slice.call(arguments)));
-  }
-};
-
-});
-require.register("component-props/index.js", function(exports, require, module){
-/**
- * Global Names
- */
-
-var globals = /\b(this|Array|Date|Object|Math|JSON)\b/g;
-
-/**
- * Return immediate identifiers parsed from `str`.
- *
- * @param {String} str
- * @param {String|Function} map function or prefix
- * @return {Array}
- * @api public
- */
-
-module.exports = function(str, fn){
-  var p = unique(props(str));
-  if (fn && 'string' == typeof fn) fn = prefixed(fn);
-  if (fn) return map(str, p, fn);
-  return p;
-};
-
-/**
- * Return immediate identifiers in `str`.
- *
- * @param {String} str
- * @return {Array}
- * @api private
- */
-
-function props(str) {
-  return str
-    .replace(/\.\w+|\w+ *\(|"[^"]*"|'[^']*'|\/([^/]+)\//g, '')
-    .replace(globals, '')
-    .match(/[$a-zA-Z_]\w*/g)
-    || [];
-}
-
-/**
- * Return `str` with `props` mapped with `fn`.
- *
- * @param {String} str
- * @param {Array} props
- * @param {Function} fn
- * @return {String}
- * @api private
- */
-
-function map(str, props, fn) {
-  var re = /\.\w+|\w+ *\(|"[^"]*"|'[^']*'|\/([^/]+)\/|[a-zA-Z_]\w*/g;
-  return str.replace(re, function(_){
-    if ('(' == _[_.length - 1]) return fn(_);
-    if (!~props.indexOf(_)) return _;
-    return fn(_);
-  });
-}
-
-/**
- * Return unique array.
- *
- * @param {Array} arr
- * @return {Array}
- * @api private
- */
-
-function unique(arr) {
-  var ret = [];
-
-  for (var i = 0; i < arr.length; i++) {
-    if (~ret.indexOf(arr[i])) continue;
-    ret.push(arr[i]);
-  }
-
-  return ret;
-}
-
-/**
- * Map with prefix `str`.
- */
-
-function prefixed(str) {
-  return function(_){
-    return str + _;
-  };
-}
-
-});
-require.register("component-to-function/index.js", function(exports, require, module){
-/**
- * Module Dependencies
- */
-
-var expr = require('props');
-
-/**
- * Expose `toFunction()`.
- */
-
-module.exports = toFunction;
-
-/**
- * Convert `obj` to a `Function`.
- *
- * @param {Mixed} obj
- * @return {Function}
- * @api private
- */
-
-function toFunction(obj) {
-  switch ({}.toString.call(obj)) {
-    case '[object Object]':
-      return objectToFunction(obj);
-    case '[object Function]':
-      return obj;
-    case '[object String]':
-      return stringToFunction(obj);
-    case '[object RegExp]':
-      return regexpToFunction(obj);
-    default:
-      return defaultToFunction(obj);
-  }
-}
-
-/**
- * Default to strict equality.
- *
- * @param {Mixed} val
- * @return {Function}
- * @api private
- */
-
-function defaultToFunction(val) {
-  return function(obj){
-    return val === obj;
-  }
-}
-
-/**
- * Convert `re` to a function.
- *
- * @param {RegExp} re
- * @return {Function}
- * @api private
- */
-
-function regexpToFunction(re) {
-  return function(obj){
-    return re.test(obj);
-  }
-}
-
-/**
- * Convert property `str` to a function.
- *
- * @param {String} str
- * @return {Function}
- * @api private
- */
-
-function stringToFunction(str) {
-  // immediate such as "> 20"
-  if (/^ *\W+/.test(str)) return new Function('_', 'return _ ' + str);
-
-  // properties such as "name.first" or "age > 18" or "age > 18 && age < 36"
-  return new Function('_', 'return ' + get(str));
-}
-
-/**
- * Convert `object` to a function.
- *
- * @param {Object} object
- * @return {Function}
- * @api private
- */
-
-function objectToFunction(obj) {
-  var match = {}
-  for (var key in obj) {
-    match[key] = typeof obj[key] === 'string'
-      ? defaultToFunction(obj[key])
-      : toFunction(obj[key])
-  }
-  return function(val){
-    if (typeof val !== 'object') return false;
-    for (var key in match) {
-      if (!(key in val)) return false;
-      if (!match[key](val[key])) return false;
-    }
-    return true;
-  }
-}
-
-/**
- * Built the getter function. Supports getter style functions
- *
- * @param {String} str
- * @return {String}
- * @api private
- */
-
-function get(str) {
-  var props = expr(str);
-  if (!props.length) return '_.' + str;
-
-  var val;
-  for(var i = 0, prop; prop = props[i]; i++) {
-    val = '_.' + prop;
-    val = "('function' == typeof " + val + " ? " + val + "() : " + val + ")";
-    str = str.replace(new RegExp(prop, 'g'), val);
-  }
-
-  return str;
-}
-
-});
-require.register("component-each/index.js", function(exports, require, module){
-
-/**
- * Module dependencies.
- */
-
-var type = require('type');
-var toFunction = require('to-function');
-
-/**
- * HOP reference.
- */
-
-var has = Object.prototype.hasOwnProperty;
-
-/**
- * Iterate the given `obj` and invoke `fn(val, i)`
- * in optional context `ctx`.
- *
- * @param {String|Array|Object} obj
- * @param {Function} fn
- * @param {Object} [ctx]
- * @api public
- */
-
-module.exports = function(obj, fn, ctx){
-  fn = toFunction(fn);
-  ctx = ctx || this;
-  switch (type(obj)) {
-    case 'array':
-      return array(obj, fn, ctx);
-    case 'object':
-      if ('number' == typeof obj.length) return array(obj, fn, ctx);
-      return object(obj, fn, ctx);
-    case 'string':
-      return string(obj, fn, ctx);
-  }
-};
-
-/**
- * Iterate string chars.
- *
- * @param {String} obj
- * @param {Function} fn
- * @param {Object} ctx
- * @api private
- */
-
-function string(obj, fn, ctx) {
-  for (var i = 0; i < obj.length; ++i) {
-    fn.call(ctx, obj.charAt(i), i);
-  }
-}
-
-/**
- * Iterate object keys.
- *
- * @param {Object} obj
- * @param {Function} fn
- * @param {Object} ctx
- * @api private
- */
-
-function object(obj, fn, ctx) {
-  for (var key in obj) {
-    if (has.call(obj, key)) {
-      fn.call(ctx, key, obj[key]);
-    }
-  }
-}
-
-/**
- * Iterate array-ish.
- *
- * @param {Array|Object} obj
- * @param {Function} fn
- * @param {Object} ctx
- * @api private
- */
-
-function array(obj, fn, ctx) {
-  for (var i = 0; i < obj.length; ++i) {
-    fn.call(ctx, obj[i], i);
-  }
-}
-
-});
-require.register("component-find/index.js", function(exports, require, module){
-
-/**
- * Module dependencies.
- */
-
-var toFunction = require('to-function');
-
-/**
- * Find the first value in `arr` with when `fn(val, i)` is truthy.
- *
- * @param {Array} arr
- * @param {Function} fn
- * @return {Array}
- * @api public
- */
-
-module.exports = function(arr, fn){
-  // callback
-  if ('function' != typeof fn) {
-    if (Object(fn) === fn) fn = objectToFunction(fn);
-    else fn = toFunction(fn);
-  }
-
-  // filter
-  for (var i = 0, len = arr.length; i < len; ++i) {
-    if (fn(arr[i], i)) return arr[i];
-  }
-};
-
-/**
- * Convert `obj` into a match function.
- *
- * @param {Object} obj
- * @return {Function}
- * @api private
- */
-
-function objectToFunction(obj) {
-  return function(o){
-    for (var key in obj) {
-      if (o[key] != obj[key]) return false;
-    }
-    return true;
-  }
-}
-});
-require.register("component-json/index.js", function(exports, require, module){
-
-module.exports = 'undefined' == typeof JSON
-  ? require('component-json-fallback')
-  : JSON;
-
-});
-require.register("component-type/index.js", function(exports, require, module){
-
-/**
- * toString ref.
- */
-
-var toString = Object.prototype.toString;
-
-/**
- * Return the type of `val`.
- *
- * @param {Mixed} val
- * @return {String}
- * @api public
- */
-
-module.exports = function(val){
-  switch (toString.call(val)) {
-    case '[object Function]': return 'function';
-    case '[object Date]': return 'date';
-    case '[object RegExp]': return 'regexp';
-    case '[object Arguments]': return 'arguments';
-    case '[object Array]': return 'array';
-    case '[object String]': return 'string';
-  }
-
-  if (val === null) return 'null';
-  if (val === undefined) return 'undefined';
-  if (val && val.nodeType === 1) return 'element';
-  if (val === Object(val)) return 'object';
-
-  return typeof val;
-};
-
-});
-require.register("component-trim/index.js", function(exports, require, module){
-
-exports = module.exports = trim;
-
-function trim(str){
-  if (str.trim) return str.trim();
-  return str.replace(/^\s*|\s*$/g, '');
-}
-
-exports.left = function(str){
-  if (str.trimLeft) return str.trimLeft();
-  return str.replace(/^\s*/, '');
-};
-
-exports.right = function(str){
-  if (str.trimRight) return str.trimRight();
-  return str.replace(/\s*$/, '');
-};
-
-});
-require.register("component-map/index.js", function(exports, require, module){
-
-/**
- * Module dependencies.
- */
-
-var toFunction = require('to-function');
-
-/**
- * Map the given `arr` with callback `fn(val, i)`.
- *
- * @param {Array} arr
- * @param {Function} fn
- * @return {Array}
- * @api public
- */
-
-module.exports = function(arr, fn){
-  var ret = [];
-  fn = toFunction(fn);
-  for (var i = 0; i < arr.length; ++i) {
-    ret.push(fn(arr[i], i));
-  }
-  return ret;
-};
-});
-require.register("yields-merge/index.js", function(exports, require, module){
-
-/**
- * merge `b`'s properties with `a`'s.
- *
- * example:
- *
- *        var user = {};
- *        merge(user, console);
- *        // > { log: fn, dir: fn ..}
- *
- * @param {Object} a
- * @param {Object} b
- * @return {Object}
- */
-
-module.exports = function (a, b) {
-  for (var k in b) a[k] = b[k];
-  return a;
-};
-
-});
-require.register("learnboost-jsonp/index.js", function(exports, require, module){
-/**
- * Module dependencies
- */
-
-var debug = require('debug')('jsonp');
-
-/**
- * Module exports.
- */
-
-module.exports = jsonp;
-
-/**
- * Callback index.
- */
-
-var count = 0;
-
-/**
- * Noop function.
- */
-
-function noop(){}
-
-/**
- * JSONP handler
- *
- * Options:
- *  - param {String} qs parameter (`callback`)
- *  - timeout {Number} how long after a timeout error is emitted (`60000`)
- *
- * @param {String} url
- * @param {Object|Function} optional options / callback
- * @param {Function} optional callback
- */
-
-function jsonp(url, opts, fn){
-  if ('function' == typeof opts) {
-    fn = opts;
-    opts = {};
-  }
-  if (!opts) opts = {};
-
-  var prefix = opts.prefix || '__jp';
-  var param = opts.param || 'callback';
-  var timeout = null != opts.timeout ? opts.timeout : 60000;
-  var enc = encodeURIComponent;
-  var target = document.getElementsByTagName('script')[0] || document.head;
-  var script;
-  var timer;
-
-  // generate a unique id for this request
-  var id = prefix + (count++);
-
-  if (timeout) {
-    timer = setTimeout(function(){
-      cleanup();
-      if (fn) fn(new Error('Timeout'));
-    }, timeout);
-  }
-
-  function cleanup(){
-    script.parentNode.removeChild(script);
-    window[id] = noop;
-  }
-
-  window[id] = function(data){
-    debug('jsonp got', data);
-    if (timer) clearTimeout(timer);
-    cleanup();
-    if (fn) fn(null, data);
-  };
-
-  // add qs component
-  url += (~url.indexOf('?') ? '&' : '?') + param + '=' + enc(id);
-  url = url.replace('?&', '?');
-
-  debug('jsonp req "%s"', url);
-
-  // create script
-  script = document.createElement('script');
-  script.src = url;
-  target.parentNode.insertBefore(script, target);
-}
-
-});
-require.register("visionmedia-debug/debug.js", function(exports, require, module){
-
-/**
- * Expose `debug()` as the module.
- */
-
-module.exports = debug;
-
-/**
- * Create a debugger with the given `name`.
- *
- * @param {String} name
- * @return {Type}
- * @api public
- */
-
-function debug(name) {
-  if (!debug.enabled(name)) return function(){};
-
-  return function(fmt){
-    fmt = coerce(fmt);
-
-    var curr = new Date;
-    var ms = curr - (debug[name] || curr);
-    debug[name] = curr;
-
-    fmt = name
-      + ' '
-      + fmt
-      + ' +' + debug.humanize(ms);
-
-    // This hackery is required for IE8
-    // where `console.log` doesn't have 'apply'
-    window.console
-      && console.log
-      && Function.prototype.apply.call(console.log, console, arguments);
-  }
-}
-
-/**
- * The currently active debug mode names.
- */
-
-debug.names = [];
-debug.skips = [];
-
-/**
- * Enables a debug mode by name. This can include modes
- * separated by a colon and wildcards.
- *
- * @param {String} name
- * @api public
- */
-
-debug.enable = function(name) {
-  try {
-    localStorage.debug = name;
-  } catch(e){}
-
-  var split = (name || '').split(/[\s,]+/)
-    , len = split.length;
-
-  for (var i = 0; i < len; i++) {
-    name = split[i].replace('*', '.*?');
-    if (name[0] === '-') {
-      debug.skips.push(new RegExp('^' + name.substr(1) + '$'));
-    }
-    else {
-      debug.names.push(new RegExp('^' + name + '$'));
-    }
-  }
-};
-
-/**
- * Disable debug output.
- *
- * @api public
- */
-
-debug.disable = function(){
-  debug.enable('');
-};
-
-/**
- * Humanize the given `ms`.
- *
- * @param {Number} m
- * @return {String}
- * @api private
- */
-
-debug.humanize = function(ms) {
-  var sec = 1000
-    , min = 60 * 1000
-    , hour = 60 * min;
-
-  if (ms >= hour) return (ms / hour).toFixed(1) + 'h';
-  if (ms >= min) return (ms / min).toFixed(1) + 'm';
-  if (ms >= sec) return (ms / sec | 0) + 's';
-  return ms + 'ms';
-};
-
-/**
- * Returns true if the given mode name is enabled, false otherwise.
- *
- * @param {String} name
- * @return {Boolean}
- * @api public
- */
-
-debug.enabled = function(name) {
-  for (var i = 0, len = debug.skips.length; i < len; i++) {
-    if (debug.skips[i].test(name)) {
-      return false;
-    }
-  }
-  for (var i = 0, len = debug.names.length; i < len; i++) {
-    if (debug.names[i].test(name)) {
-      return true;
-    }
-  }
-  return false;
-};
-
-/**
- * Coerce `val`.
- */
-
-function coerce(val) {
-  if (val instanceof Error) return val.stack || val.message;
-  return val;
-}
-
-// persist
-
-try {
-  if (window.localStorage) debug.enable(localStorage.debug);
-} catch(e){}
-
-});
-require.register("johntron-asap/asap.js", function(exports, require, module){
-"use strict";
-
-// Use the fastest possible means to execute a task in a future turn
-// of the event loop.
-
-// linked list of tasks (single, with head node)
-var head = {task: void 0, next: null};
-var tail = head;
-var flushing = false;
-var requestFlush = void 0;
-var hasSetImmediate = typeof setImmediate === "function";
-var domain;
-
-if (typeof global != 'undefined') {
-	// Avoid shims from browserify.
-	// The existence of `global` in browsers is guaranteed by browserify.
-	var process = global.process;
-}
-
-// Note that some fake-Node environments,
-// like the Mocha test runner, introduce a `process` global.
-var isNodeJS = !!process && ({}).toString.call(process) === "[object process]";
-
-function flush() {
-    /* jshint loopfunc: true */
-
-    while (head.next) {
-        head = head.next;
-        var task = head.task;
-        head.task = void 0;
-
-        try {
-            task();
-
-        } catch (e) {
-            if (isNodeJS) {
-                // In node, uncaught exceptions are considered fatal errors.
-                // Re-throw them to interrupt flushing!
-
-                // Ensure continuation if an uncaught exception is suppressed
-                // listening process.on("uncaughtException") or domain("error").
-                requestFlush();
-
-                throw e;
-
-            } else {
-                // In browsers, uncaught exceptions are not fatal.
-                // Re-throw them asynchronously to avoid slow-downs.
-                    throw e;
-            }
-        }
-    }
-
-    flushing = false;
-}
-
-if (isNodeJS) {
-    // Node.js
-    requestFlush = function () {
-        // Ensure flushing is not bound to any domain.
-        var currentDomain = process.domain;
-        if (currentDomain) {
-            domain = domain || (1,require)("domain");
-            domain.active = process.domain = null;
-        }
-
-        // Avoid tick recursion - use setImmediate if it exists.
-        if (flushing && hasSetImmediate) {
-            setImmediate(flush);
-        } else {
-            process.nextTick(flush);
-        }
-
-        if (currentDomain) {
-            domain.active = process.domain = currentDomain;
-        }
-    };
-
-} else if (hasSetImmediate) {
-    // In IE10, or https://github.com/NobleJS/setImmediate
-    requestFlush = function () {
-        setImmediate(flush);
-    };
-
-} else if (typeof MessageChannel !== "undefined") {
-    // modern browsers
-    // http://www.nonblocking.io/2011/06/windownexttick.html
-    var channel = new MessageChannel();
-    // At least Safari Version 6.0.5 (8536.30.1) intermittently cannot create
-    // working message ports the first time a page loads.
-    channel.port1.onmessage = function () {
-        requestFlush = requestPortFlush;
-        channel.port1.onmessage = flush;
-        flush();
-    };
-    var requestPortFlush = function () {
-        // Opera requires us to provide a message payload, regardless of
-        // whether we use it.
-        channel.port2.postMessage(0);
-    };
-    requestFlush = function () {
-        setTimeout(flush, 0);
-        requestPortFlush();
-    };
-
-} else {
-    // old browsers
-    requestFlush = function () {
-        setTimeout(flush, 0);
-    };
-}
-
-function asap(task) {
-    if (isNodeJS && process.domain) {
-        task = process.domain.bind(task);
-    }
-
-    tail = tail.next = {task: task, next: null};
-
-    if (!flushing) {
-        requestFlush();
-        flushing = true;
-    }
-};
-
-module.exports = asap;
-
-});
-require.register("chrissrogers-promise/index.js", function(exports, require, module){
-'use strict';
-
-//This file contains then/promise specific extensions to the core promise API
-
-var Promise = require('./core.js')
-var asap = require('asap')
-
-module.exports = Promise
-
-/* Static Functions */
-
-function ValuePromise(value) {
-  this.then = function (onFulfilled) {
-    if (typeof onFulfilled !== 'function') return this
-    return new Promise(function (resolve, reject) {
-      asap(function () {
-        try {
-          resolve(onFulfilled(value))
-        } catch (ex) {
-          reject(ex);
-        }
-      })
-    })
-  }
-}
-ValuePromise.prototype = Promise.prototype
-
-var TRUE = new ValuePromise(true)
-var FALSE = new ValuePromise(false)
-var NULL = new ValuePromise(null)
-var UNDEFINED = new ValuePromise(undefined)
-var ZERO = new ValuePromise(0)
-var EMPTYSTRING = new ValuePromise('')
-
-Promise.resolve = function (value) {
-  if (value instanceof Promise) return value
-
-  if (value === null) return NULL
-  if (value === undefined) return UNDEFINED
-  if (value === true) return TRUE
-  if (value === false) return FALSE
-  if (value === 0) return ZERO
-  if (value === '') return EMPTYSTRING
-
-  if (typeof value === 'object' || typeof value === 'function') {
-    try {
-      var then = value.then
-      if (typeof then === 'function') {
-        return new Promise(then.bind(value))
-      }
-    } catch (ex) {
-      return new Promise(function (resolve, reject) {
-        reject(ex)
-      })
-    }
-  }
-
-  return new ValuePromise(value)
-}
-
-Promise.from = Promise.cast = function (value) {
-  var err = new Error('Promise.from and Promise.cast are deprecated, use Promise.resolve instead')
-  err.name = 'Warning'
-  console.warn(err.stack)
-  return Promise.resolve(value)
-}
-
-Promise.denodeify = function (fn, argumentCount) {
-  argumentCount = argumentCount || Infinity
-  return function () {
-    var self = this
-    var args = Array.prototype.slice.call(arguments)
-    return new Promise(function (resolve, reject) {
-      while (args.length && args.length > argumentCount) {
-        args.pop()
-      }
-      args.push(function (err, res) {
-        if (err) reject(err)
-        else resolve(res)
-      })
-      fn.apply(self, args)
-    })
-  }
-}
-Promise.nodeify = function (fn) {
-  return function () {
-    var args = Array.prototype.slice.call(arguments)
-    var callback = typeof args[args.length - 1] === 'function' ? args.pop() : null
-    try {
-      return fn.apply(this, arguments).nodeify(callback)
-    } catch (ex) {
-      if (callback === null || typeof callback == 'undefined') {
-        return new Promise(function (resolve, reject) { reject(ex) })
-      } else {
-        asap(function () {
-          callback(ex)
-        })
-      }
-    }
-  }
-}
-
-Promise.all = function () {
-  var calledWithArray = arguments.length === 1 && Array.isArray(arguments[0])
-  var args = Array.prototype.slice.call(calledWithArray ? arguments[0] : arguments)
-
-  if (!calledWithArray) {
-    var err = new Error('Promise.all should be called with a single array, calling it with multiple arguments is deprecated')
-    err.name = 'Warning'
-    console.warn(err.stack)
-  }
-
-  return new Promise(function (resolve, reject) {
-    if (args.length === 0) return resolve([])
-    var remaining = args.length
-    function res(i, val) {
-      try {
-        if (val && (typeof val === 'object' || typeof val === 'function')) {
-          var then = val.then
-          if (typeof then === 'function') {
-            then.call(val, function (val) { res(i, val) }, reject)
-            return
-          }
-        }
-        args[i] = val
-        if (--remaining === 0) {
-          resolve(args);
-        }
-      } catch (ex) {
-        reject(ex)
-      }
-    }
-    for (var i = 0; i < args.length; i++) {
-      res(i, args[i])
-    }
-  })
-}
-
-Promise.reject = function (value) {
-  return new Promise(function (resolve, reject) { 
-    reject(value);
-  });
-}
-
-Promise.race = function (values) {
-  return new Promise(function (resolve, reject) { 
-    values.forEach(function(value){
-      Promise.resolve(value).then(resolve, reject);
-    })
-  });
-}
-
-/* Prototype Methods */
-
-Promise.prototype.done = function (onFulfilled, onRejected) {
-  var self = arguments.length ? this.then.apply(this, arguments) : this
-  self.then(null, function (err) {
-    asap(function () {
-      throw err
-    })
-  })
-}
-
-Promise.prototype.nodeify = function (callback) {
-  if (typeof callback != 'function') return this
-
-  this.then(function (value) {
-    asap(function () {
-      callback(null, value)
-    })
-  }, function (err) {
-    asap(function () {
-      callback(err)
-    })
-  })
-}
-
-Promise.prototype['catch'] = function (onRejected) {
-  return this.then(null, onRejected);
-}
-
-});
-require.register("chrissrogers-promise/core.js", function(exports, require, module){
-'use strict';
-
-var asap = require('asap')
-
-module.exports = Promise
-function Promise(fn) {
-  if (typeof this !== 'object') throw new TypeError('Promises must be constructed via new')
-  if (typeof fn !== 'function') throw new TypeError('not a function')
-  var state = null
-  var value = null
-  var deferreds = []
-  var self = this
-
-  this.then = function(onFulfilled, onRejected) {
-    return new self.constructor(function(resolve, reject) {
-      handle(new Handler(onFulfilled, onRejected, resolve, reject))
-    })
-  }
-
-  function handle(deferred) {
-    if (state === null) {
-      deferreds.push(deferred)
-      return
-    }
-    asap(function() {
-      var cb = state ? deferred.onFulfilled : deferred.onRejected
-      if (cb === null) {
-        (state ? deferred.resolve : deferred.reject)(value)
-        return
-      }
-      var ret
-      try {
-        ret = cb(value)
-      }
-      catch (e) {
-        deferred.reject(e)
-        return
-      }
-      deferred.resolve(ret)
-    })
-  }
-
-  function resolve(newValue) {
-    try { //Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure
-      if (newValue === self) throw new TypeError('A promise cannot be resolved with itself.')
-      if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
-        var then = newValue.then
-        if (typeof then === 'function') {
-          doResolve(then.bind(newValue), resolve, reject)
-          return
-        }
-      }
-      state = true
-      value = newValue
-      finale()
-    } catch (e) { reject(e) }
-  }
-
-  function reject(newValue) {
-    state = false
-    value = newValue
-    finale()
-  }
-
-  function finale() {
-    for (var i = 0, len = deferreds.length; i < len; i++)
-      handle(deferreds[i])
-    deferreds = null
-  }
-
-  doResolve(fn, resolve, reject)
-}
-
-
-function Handler(onFulfilled, onRejected, resolve, reject){
-  this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null
-  this.onRejected = typeof onRejected === 'function' ? onRejected : null
-  this.resolve = resolve
-  this.reject = reject
-}
-
-/**
- * Take a potentially misbehaving resolver function and make sure
- * onFulfilled and onRejected are only called once.
- *
- * Makes no guarantees about asynchrony.
- */
-function doResolve(fn, onFulfilled, onRejected) {
-  var done = false;
-  try {
-    fn(function (value) {
-      if (done) return
-      done = true
-      onFulfilled(value)
-    }, function (reason) {
-      if (done) return
-      done = true
-      onRejected(reason)
-    })
-  } catch (ex) {
-    if (done) return
-    done = true
-    onRejected(ex)
-  }
-}
-
-});
-require.register("kewah-mixin/index.js", function(exports, require, module){
-if (typeof Object.keys === 'function') {
-  module.exports = function(to, from) {
-    Object.keys(from).forEach(function(property) {
-      Object.defineProperty(to, property, Object.getOwnPropertyDescriptor(from, property));
-    });
-  };
-} else {
-  module.exports = function(to, from) {
-    for (var property in from) {
-      if (from.hasOwnProperty(property)) {
-        to[property] = from[property];
-      }
-    }
-  };
-}
-
-});
-require.register("pluma-par/dist/par.js", function(exports, require, module){
-/*! par 0.3.0 Original author Alan Plum <me@pluma.io>. Released into the Public Domain under the UNLICENSE. @preserve */
-var slice = Array.prototype.slice;
-
-function par(fn) {
-    var args0 = slice.call(arguments, 1);
-    return function() {
-        var argsN = slice.call(arguments, 0),
-            args = [];
-        args.push.apply(args, args0);
-        args.push.apply(args, argsN);
-        return fn.apply(this, args);
-    };
-}
-
-function rpartial(fn) {
-    var argsN = slice.call(arguments, 1);
-    return function() {
-        var args = slice.call(arguments, 0);
-        args.push.apply(args, argsN);
-        return fn.apply(this, args);
-    };
-}
-
-par.rpartial = rpartial;
-par.lpartial = par;
-
-module.exports = par;
-
-});
-require.register("ianstormtaylor-to-no-case/index.js", function(exports, require, module){
-
-/**
- * Expose `toNoCase`.
- */
-
-module.exports = toNoCase;
-
-
-/**
- * Test whether a string is camel-case.
- */
-
-var hasSpace = /\s/;
-var hasCamel = /[a-z][A-Z]/;
-var hasSeparator = /[\W_]/;
-
-
-/**
- * Remove any starting case from a `string`, like camel or snake, but keep
- * spaces and punctuation that may be important otherwise.
- *
- * @param {String} string
- * @return {String}
- */
-
-function toNoCase (string) {
-  if (hasSpace.test(string)) return string.toLowerCase();
-
-  if (hasSeparator.test(string)) string = unseparate(string);
-  if (hasCamel.test(string)) string = uncamelize(string);
-  return string.toLowerCase();
-}
-
-
-/**
- * Separator splitter.
- */
-
-var separatorSplitter = /[\W_]+(.|$)/g;
-
-
-/**
- * Un-separate a `string`.
- *
- * @param {String} string
- * @return {String}
- */
-
-function unseparate (string) {
-  return string.replace(separatorSplitter, function (m, next) {
-    return next ? ' ' + next : '';
-  });
-}
-
-
-/**
- * Camelcase splitter.
- */
-
-var camelSplitter = /(.)([A-Z]+)/g;
-
-
-/**
- * Un-camelcase a `string`.
- *
- * @param {String} string
- * @return {String}
- */
-
-function uncamelize (string) {
-  return string.replace(camelSplitter, function (m, previous, uppers) {
-    return previous + ' ' + uppers.toLowerCase().split('').join(' ');
-  });
-}
-});
-require.register("ianstormtaylor-to-space-case/index.js", function(exports, require, module){
-
-var clean = require('to-no-case');
-
-
-/**
- * Expose `toSpaceCase`.
- */
-
-module.exports = toSpaceCase;
-
-
-/**
- * Convert a `string` to space case.
- *
- * @param {String} string
- * @return {String}
- */
-
-
-function toSpaceCase (string) {
-  return clean(string).replace(/[\W_]+(.|$)/g, function (matches, match) {
-    return match ? ' ' + match : '';
-  });
-}
-});
-require.register("ianstormtaylor-to-slug-case/index.js", function(exports, require, module){
-
-var toSpace = require('to-space-case');
-
-
-/**
- * Expose `toSlugCase`.
- */
-
-module.exports = toSlugCase;
-
-
-/**
- * Convert a `string` to slug case.
- *
- * @param {String} string
- * @return {String}
- */
-
-
-function toSlugCase (string) {
-  return toSpace(string).replace(/\s/g, '-');
-}
-});
-require.register("recurly/lib/index.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var Recurly = require('./recurly');
-
-/**
- * Export a single instance.
- */
-
-module.exports = exports = new Recurly();
-
-/**
- * Hack for testing.
- */
-
-exports.Recurly = Recurly;
-
-});
-require.register("recurly/lib/recurly.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var bind = require('bind');
-var json = require('json');
-var each = require('each');
-var type = require('type');
-var merge = require('merge');
-var mixin = require('mixin');
-var jsonp = require('jsonp');
-var qs = require('querystring');
-var Emitter = require('emitter');
-var errors = require('./errors');
-var version = require('./version');
-var debug = require('debug')('recurly');
-
-/**
- * Default configuration values.
- *
- * @private
- * @type {Object}
- */
-
-var defaults = {
-    currency: 'USD'
-  , timeout: 60000
-  , publicKey: ''
-  , api: 'https://api.recurly.com/js/v1'
-};
-
-/**
- * API mixins.
- *
- * @type {Array}
- * @private
- */
-
-var mixins = [
-    'open'
-  , 'coupon'
-  , 'paypal'
-  , 'plan'
-  , 'tax'
-  , 'token'
-  , 'pricing'
-  , 'validate'
-];
-
-/**
- * Export `Recurly`.
- */
-
-module.exports = Recurly;
-
-/**
- * Initialize defaults.
- *
- * @param {Object} options
- * @constructor
- * @public
- */
-
-function Recurly (options) {
-  this.id = 0;
-  this.version = version;
-  this.configured = false;
-  this.config = merge({}, defaults);
-  if (options) this.configure(options);
-}
-
-/**
- * Inherits `Emitter`.
- */
-
-Emitter(Recurly.prototype);
-
-/**
- * Configure settings.
- *
- * @param {String|Object} options Either publicKey or object containing
- *                                publicKey and other optional members
- * @param {String} options.publicKey
- * @param {String} [options.currency]
- * @param {String} [options.api]
- * @public
- */
-
-Recurly.prototype.configure = function configure (options) {
-  if (this.configured) throw errors('already-configured');
-
-  debug('configure');
-
-  if (type(options) === 'string') options = { publicKey: options };
-
-  if ('publicKey' in options) {
-    this.config.publicKey = options.publicKey;
-  } else {
-    throw errors('missing-public-key');
-  }
-
-  if ('api' in options) {
-    this.config.api = options.api;
-  }
-
-  if ('currency' in options) {
-    this.config.currency = options.currency;
-  }
-
-  this.configured = true;
-};
-
-/**
- * Assembles the API endpoint.
- *
- * @return {String} route
- * @private
- */
-
-Recurly.prototype.url = function url (route) {
-  return this.config.api + route;
-};
-
-/**
- * Issues an API request.
- *
- * @param {String} route
- * @param {Object} [data]
- * @param {Function} done
- * @throws {Error} If `configure` has not been called.
- * @private
- */
-
-Recurly.prototype.request = function request (route, data, done) {
-  debug('request');
-
-  if (false === this.configured) {
-    throw errors('not-configured');
-  }
-
-  if ('function' == type(data)) {
-    done = data;
-    data = {};
-  }
-
-  var url = this.url(route);
-  var timeout = this.config.timeout;
-
-  data.version = this.version;
-  data.key = this.config.publicKey;
-
-  url += '?' + qs.stringify(data);
-
-  this.cache(url, function (res, set) {
-    if (res) return done(null, res);
-    jsonp(url, { timeout: timeout }, function (err, res) {
-      if (err) return done(err);
-      if (res.error) {
-        done(errors('api-error', res.error));
-      } else {
-        done(null, set(res));
-      }
-    });
-  });
-};
-
-/**
- * Caches an object
- *
- * TODO: figure out invalidation & expiry
- *
- * @param {String} url
- * @param {Function} done
- * @private
- */
- 
-Recurly.prototype.cache = function cache (url, done) {
-  debug('cache');
-  var stored = localStorage.getItem(url);
-  if (stored) {
-    debug('cache found ' + url);
-    return done(json.parse(stored));
-  } else {
-    debug('cache set ' + url);
-    return done(null, set);
-  }
-  function set (obj) {
-    // disabled for now
-    // localStorage.setItem(url, json.stringify(obj));
-    return obj;
-  }
-};
-
-/**
- * Load the `mixins` onto Recurly.prototype.
- */
-
-each(mixins, function (name) {
-  mixin(Recurly.prototype, require('./recurly/' + name));
-});
-
-});
-require.register("recurly/lib/version.js", function(exports, require, module){
-
-/**
- * Current package/component version.
- */
-
-module.exports = '3.0.5';
-
-});
-require.register("recurly/lib/errors.js", function(exports, require, module){
-/**
- * dependencies
- */
-
-var mixin = require('mixin');
-
-/**
- * Export `errors`.
- */
-
-module.exports = exports = errors;
-
-/**
- * Error accessor.
- *
- * @param {String} name
- * @param {Object} options
- * @return {Error}
- */
-
-function errors (name, options) {
-  return errors.get(name, options);
-}
-
-/**
- * Defined errors.
- *
- * @type {Object}
- * @private
- */
-
-errors.map = {};
-
-/**
- * Base url for documention.
- *
- * @type {String}
- * @private
- */
-
-errors.baseURL = '';
-
-/**
- * Sets the `baseURL` for docs.
- *
- * @param {String} url
- * @public
- */
-
-errors.doc = function (baseURL) {
-  errors.baseURL = baseURL;
-};
-
-/**
- * Gets errors defined by `name`.
- *
- * @param {String} name
- * @param {Object} context
- * @return {Error}
- * @public
- */
-
-errors.get = function (name, context) {
-  if (!(name in errors.map)) {
-    throw new Error('invalid error');
-  } else {
-    return new errors.map[name](context);
-  }
-};
-
-/**
- * Registers an error defined by `name` with `config`.
- *
- * @param {String} name
- * @param {Object} config
- * @return {Error}
- * @public
- */
-
-errors.add = function (name, config) {
-  config = config || {};
-
-  function RecurlyError (context) {
-    Error.call(this);
-
-    this.name = this.code = name;
-    this.message = config.message;
-    mixin(this, context || {});
-
-    if (config.help) {
-      this.help = errors.baseURL + config.help;
-      this.message += ' (need help? ' + this.help + ')';
-    }
-  };
-
-  RecurlyError.prototype = new Error();
-  return errors.map[name] = RecurlyError;
-};
-
-/**
- * Internal definations.
- *
- * TODO(gjohnson): open source this as a component
- * and move these out.
- */
-
-errors.doc('https://docs.recurly.com/js');
-
-errors.add('already-configured', {
-  message: 'Configuration may only be set once.',
-  help: '#identify-your-site'
-});
-
-errors.add('not-configured', {
-  message: 'Not configured. You must first call recurly.configure().',
-  help: '#identify-your-site'
-});
-
-errors.add('missing-public-key', {
-  message: 'The publicKey setting is required.',
-  help: '#identify-your-site'
-});
-
-errors.add('api-error', {
-  message: 'There was an error with your request.'
-});
-
-errors.add('validation', {
-  message: 'There was an error validating your request.'
-});
-
-errors.add('missing-callback', {
-  message: 'Missing callback'
-});
-
-errors.add('invalid-options', {
-  message: 'Options must be an object'
-});
-
-errors.add('missing-plan', {
-  message: 'A plan must be specified.'
-});
-
-errors.add('missing-coupon', {
-  message: 'A coupon must be specified.'
-});
-
-errors.add('invalid-item', {
-  message: 'The given item does not appear to be a valid recurly plan, coupon, addon, or taxable address.'
-});
-
-errors.add('invalid-addon', {
-  message: 'The given addon_code is not among the valid addons for the specified plan.'
-});
-
-errors.add('invalid-currency', {
-  message: 'The given currency is not among the valid codes for the specified plan.'
-});
-
-errors.add('unremovable-item', {
-  message: 'The given item cannot be removed.'
-});
-
-});
-require.register("recurly/lib/util/dom.js", function(exports, require, module){
-/**
- * dependencies
- */
-
-var slug = require('to-slug-case');
-var type = require('type');
-var each = require('each');
-var map = require('map');
-
-/**
- * expose
- */
-
-module.exports = {
-  element: element,
-  value: value,
-  data: data
-};
-
-/**
- * Detects whether an object is an html element.
- *
- * @param {Mixed} node
- * @return {HTMLElement|Boolean} node
- */
-
-function element (node) {
-  var isJQuery = window.jQuery && node instanceof jQuery;
-  var isArray = type(node) === 'array';
-  if (isJQuery || isArray) node = node[0];
-
-  var isElem = typeof HTMLElement !== 'undefined'
-    ? node instanceof HTMLElement
-    : node && node.nodeType === 1;
-
-  return isElem && node;
-};
-
-/**
- * Gets or sets the value of a given HTML form element
- *
- * supports text inputs, radio inputs, and selects
- *
- * @param {HTMLElement} node
- * @return {String} value of the element
- */
-
-function value (node, value) {
-  if (!element(node)) return null;
-  return typeof value !== 'undefined'
-    ? valueSet(node, value)
-    : valueGet(node);
-}
-
-/**
- * Gets an HTMLElement's value property in the context of a form
- *
- * @param {HTMLElement} node
- * @return {String} node's value
- */
-
-function valueGet (node) {
-  node = element(node);
-
-  var nodeType = node && node.type && node.type.toLowerCase();
-  var value;
-
-  if (!nodeType) {
-    value = '';
-  } else if ('options' in node) {
-    value = node.options[node.selectedIndex].value;
-  } else if (nodeType === 'checkbox') {
-    if (node.checked) value = node.value;
-  } else if (nodeType === 'radio') {
-    var radios = document.querySelectorAll('input[data-recurly="' + data(node, 'recurly') + '"]');
-    each(radios, function (radio) {
-      if (radio.checked) value = radio.value;
-    });
-  } else if ('value' in node) {
-    value = node.value;
-  }
-
-  return value;
-}
-
-/**
- * Updates an element's value property if
- * one exists; else innerText if it exists
- *
- * @param {Array[HTMLElement]} nodes
- * @param {Mixed} value
- */
-
-function valueSet (nodes, value) {
-  if (type(nodes) !== 'array') nodes = [nodes];
-  each(nodes, function (node) {
-    if (!node) return;
-    else if ('value' in node)
-      node.value = value;
-    else if ('textContent' in node)
-      node.textContent = value;
-    else if ('innerText' in node)
-      node.innerText = value;
-  });
-}
-
-/**
- * Gets or sets a node's data attribute
- *
- * @param {HTMLElement} node
- * @param {String} key
- * @param {Mixed} [value]
- */
-
-function data (node, key, value) {
-  node = element(node);
-  if (!node) return;
-  return typeof value !== 'undefined'
-    ? dataSet(node, key, value)
-    : dataGet(node, key);
-}
-
-/**
- * Gets a node's data attribute
- *
- * @param {HTMLElement} node
- * @param {String} key
- */
-
-function dataGet (node, key) {
-  return node.dataset
-    ? node.dataset[key]
-    : node.getAttribute('data-' + slug(key));
-}
-
-/**
- * sets a node's data attribute
- *
- * @param {HTMLElement} node
- * @param {String} key
- * @param {Mixed} value
- */
-
-function dataSet (node, key, value) {
-  if (node.dataset) node.dataset[key] = value;
-  else node.setAttribute('data-' + slug(key), value);
-}
-
-});
-require.register("recurly/lib/util/parse-card.js", function(exports, require, module){
-
-/**
- * Removes dashes and spaces from a card number.
- *
- * @param {Number|String} number
- * @return {String} parsed card number
- */
-
-module.exports = function parseCard (number) {
-  return number && number.toString().replace(/[-\s]/g, '');
-};
-
-});
-require.register("recurly/lib/recurly/open.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var bind = require('bind');
-var type = require('type');
-var json = require('json');
-var events = require('event');
-var qs = require('querystring');
-var errors = require('../errors');
-var debug = require('debug')('recurly:open');
-
-/**
- * Issues an API request to a popup window.
- *
- * TODO(*): configurable window name?
- * TODO(*): configurable window properties?
- *
- * @param {String} url
- * @param {Object} [data]
- * @param {Function} [done]
- * @throws {Error} If `configure` has not been called.
- * @return {Window}
- * @private
- */
-
-exports.open = function (url, data, done) {
-  debug('open');
-  
-  if (false === this.configured) {
-    throw errors('not-configured');
-  }
-
-  if ('function' == type(data)) {
-    done = data;
-    data = {};
-  }
-
-  data = data || {};
-  data.version = this.version;
-  data.event = 'recurly-open-' + this.id++;
-  data.key = this.config.publicKey;
-  this.once(data.event, done);
-
-  if (!/^https?:\/\//.test(url)) url = this.url(url);
-  url += (~url.indexOf('?') ? '&' : '?') + qs.stringify(data);
-
-  this.relay(function () {
-    window.open(url);
-  });
-};
-
-/**
- * Relay mixin.
- *
- * Inspects the window for intent to relay a message,
- * then attempts to send it off. closes the window once
- * dispatched.
- *
- * @param {Function} done
- * @private
- */
-
-exports.relay = function (done) {
-  var self = this;
-
-  if (false === this.configured) {
-    throw errors('not-configured');
-  }
-
-  events.bind(window, 'message', function listener (event) {
-    var data = json.parse(event.data);
-    var name = data.recurly_event;
-    var body = data.recurly_message;
-    var err = body.error ? errors('api-error', body.error) : null;
-    events.unbind(window, 'message', listener);
-    if (name) self.emit(name, err, body);
-    if (frame) document.body.removeChild(frame);
-  });
-
-  if ('documentMode' in document) {
-    var frame = document.createElement('iframe');
-    frame.width = frame.height = 0;
-    frame.src = this.url('/relay');
-    frame.name = 'recurly-relay';
-    frame.style.display = 'none';
-    frame.onload = bind(this, done);
-    document.body.appendChild(frame);
-  } else {
-    done();
-  }
-};
-
-});
-require.register("recurly/lib/recurly/coupon.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var type = require('type');
-var debug = require('debug')('recurly:coupon');
-var errors = require('../errors');
-
-/**
- * Coupon mixin.
- *
- * Retrieves coupon information for the `plan`. The `callback` signature
- * is `err, plan` where `err` may be a request or server error, and `plan`
- * is a representation of the requested plan.
- *
- * @param {Object} options
- * @param {Function} callback
- */
-
-exports.coupon = function (options, callback) {
-  debug('%j', options);
-
-  if ('function' !== type(callback)) {
-    throw errors('missing-callback');
-  }
-
-  if ('object' !== type(options)) {
-    throw errors('invalid-options');
-  }
-
-  if (!('plan' in options)) {
-    throw errors('missing-plan');
-  }
-
-  if (!('coupon' in options)) {
-    throw errors('missing-coupon');
-  }
-
-  this.request('/plans/' + options.plan + '/coupons/' + options.coupon, options, callback);
-};
-
-});
-require.register("recurly/lib/recurly/paypal.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var debug = require('debug')('recurly:paypal');
-
-/**
- * Paypal mixin.
- *
- * @param {Object} data
- * @param {Function} done callback
- */
-
-exports.paypal = function (data, done) {
-  debug('start');
-  this.open('/paypal/start', data, done);
-};
-
-});
-require.register("recurly/lib/recurly/plan.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var type = require('type');
-var debug = require('debug')('recurly:plan');
-
-/**
- * Plan mixin.
- *
- * Retrieves information for the `plan`. The `callback` signature
- * is `err, plan` where `err` may be a request or server error, and `plan`
- * is a representation of the requested plan.
- *
- * @param {String} code
- * @param {Function} callback
- */
-
-exports.plan = function (code, callback) {
-  debug('%s', code);
-
-  if ('function' != type(callback)) {
-    throw new Error('Missing callback');
-  }
-
-  if ('undefined' == type(code)) {
-    return callback(new Error('Missing plan code'));
-  }
-
-  this.request('/plans/' + code, callback);
-};
-
-});
-require.register("recurly/lib/recurly/tax.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var type = require('type');
-var clone = require('clone');
-var debug = require('debug')('recurly:tax');
-
-/**
- * Tax mixin.
- *
- * Provides a tax estiamte for the given address.
- *
- * @param {Object} options
- * @param {Object} options.postal_code
- * @param {Object} options.country
- * @param {Object} [options.vat_number] Used for VAT exemptions
- * @param {Function} callback
- */
-
-exports.tax = function (options, callback) {
-  var request = clone(options);
-
-  if ('function' != type(callback)) {
-    throw new Error('Missing callback');
-  }
-
-  if (!('currency' in request)) {
-    request.currency = this.config.currency;
-  }
-
-  this.request('/tax', request, callback);
-};
-
-});
-require.register("recurly/lib/recurly/token.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var bind = require('bind');
-var each = require('each');
-var type = require('type');
-var index = require('indexof');
-var debug = require('debug')('recurly:token');
-var dom = require('../util/dom');
-var parseCard = require('../util/parse-card');
-var errors = require('../errors');
-
-/**
- * Fields that are sent to API.
- *
- * @type {Array}
- * @private
- */
-
-var fields = [
-    'first_name'
-  , 'last_name'
-  , 'number'
-  , 'month'
-  , 'year'
-  , 'cvv'
-  , 'address1'
-  , 'address2'
-  , 'country'
-  , 'city'
-  , 'state'
-  , 'postal_code'
-  , 'phone'
-  , 'vat_number'
-  , 'token'
-];
-
-/**
- * Generates a token from customer data.
- *
- * The callback signature: `err, response` where `err` is a
- * connection, request, or server error, and `response` is the
- * recurly service response. The generated token is accessed
- * at `response.token`.
- *
- * @param {Object|HTMLFormElement} options Billing properties or an HTMLFormElement
- * with children corresponding to billing properties via 'data-reurly' attributes.
- * @param {String} options.first_name customer first name
- * @param {String} options.last_name customer last name
- * @param {String|Number} options.number card number
- * @param {String|Number} options.month card expiration month
- * @param {String|Number} options.year card expiration year
- * @param {String|Number} options.cvv card verification value
- * @param {String} [options.address1]
- * @param {String} [options.address2]
- * @param {String} [options.country]
- * @param {String} [options.city]
- * @param {String} [options.state]
- * @param {String|Number} [options.postal_code]
- * @param {Function} done callback
- */
-
-exports.token = function (options, done) {
-  var open = bind(this, this.open);
-  var data = normalize(options);
-  var input = data.values;
-  var userErrors = validate.call(this, input);
-
-  if ('function' !== type(done)) {
-    throw errors('missing-callback');
-  }
-
-  if (userErrors.length) {
-    return done(errors('validation', { fields: userErrors }));
-  }
-
-  this.request('/token', input, function (err, res) {
-    if (err) return done(err);
-    if (data.fields.token && res.id) {
-      data.fields.token.value = res.id;
-    }
-    done(null, res);
-  });
-};
-
-/**
- * Parses options out of a form element and normalizes according to rules.
- *
- * @param {Object|HTMLFormElement} options
- * @return {Object}
- */
-
-function normalize (options) {
-  var el = dom.element(options);
-  var data = { fields: {}, values: {} };
-
-  if (el && 'form' === el.nodeName.toLowerCase()) {
-    each(el.querySelectorAll('[data-recurly]'), function (field) {
-      var name = dom.data(field, 'recurly');
-      if (~index(fields, name)) {
-        data.fields[name] = field;
-        data.values[name] = dom.value(field);
-      }
-    });
-  } else {
-    data.values = options;
-  }
-
-  data.values.number = parseCard(data.values.number);
-
-  return data;
-}
-
-/**
- * Checks user input on a token call
- *
- * @param {Object} input
- * @return {Array} indicates which fields are not valid
- */
-
-function validate (input) {
-  var errors = [];
-
-  if (!this.validate.cardNumber(input.number)) {
-    errors.push('number');
-  }
-
-  if (!this.validate.expiry(input.month, input.year)) {
-    errors.push('month', 'year');
-  }
-
-  if (!input.first_name) {
-    errors.push('first_name');
-  }
-
-  if (!input.last_name) {
-    errors.push('last_name');
-  }
-
-  return errors;
-}
-
-});
-require.register("recurly/lib/recurly/validate.js", function(exports, require, module){
-
-/*!
- * Module dependencies.
- */
-
-var find = require('find');
-var trim = require('trim');
-var index = require('indexof');
-var parseCard = require('../util/parse-card');
-
-/**
- * Card patterns.
- *
- * @private
- */
-
-var types = [
-  {
-    type: 'discover',
-    pattern: /^(6011|622|64[4-9]|65)/,
-    lengths: [16]
-  }
-  , {
-    type: 'master',
-    pattern: /^5[0-5]/,
-    lengths: [16]
-  }
-  , {
-    type: 'american_express',
-    pattern: /^3[47]/,
-    lengths: [15]
-  }
-  , {
-    type: 'visa',
-    pattern: /^4/,
-    lengths: [13, 16]
-  }
-  , {
-    type: 'jcb',
-    pattern: /^35[2-8]\d/,
-    lengths: [16]
-  }
-  , {
-    type: 'diners_club',
-    pattern: /^(30[0-5]|309|36|3[89]|54|55|2014|2149)/,
-    lengths: [14]
-  }
-];
-
-/**
- * Validate mixin.
- *
- * @public
- */
-
-exports.validate = {
-
-  /**
-   * Validates a credit card number via luhn algorithm.
-   *
-   * @param {Number|String} number The card number.
-   * @return {Boolean}
-   * @see https://sites.google.com/site/abapexamples/javascript/luhn-validation
-   */
-
-  cardNumber: function (number) {
-    var str = parseCard(number);
-    var ca, sum = 0, mul = 1;
-    var i = str.length;
-
-    while (i--) {
-      ca = parseInt(str.charAt(i), 10) * mul;
-      sum += ca - (ca > 9) * 9;
-      mul ^= 3;
-    }
-
-    return sum % 10 === 0 && sum > 0;
-  },
-
-  /**
-   * Returns the type of the card number as a string.
-   *
-   * TODO(chrissrogers): Maybe undefined instread of "unknown"?
-   *
-   * @param {Number|String} number The card number
-   * @return {String} card type
-   */
-
-  cardType: function (number) {
-    var str = parseCard(number);
-    var card = find(types, function (card) {
-      return card.pattern.test(str) && ~index(card.lengths, str.length);
-    });
-    return card && card.type || 'unknown';
-  },
-
-  /**
-   * Validates whether an expiry month is present or future.
-   *
-   * @param {Numer|String} month The 2 digit month
-   * @param {Numer|String} year The 2 or 4 digit year
-   * @return {Boolean}
-   */
-
-  expiry: function (month, year) {
-    month = parseInt(month, 10) - 1;
-    if (month < 0 || month > 11) return false;
-    year = parseInt(year, 10);
-    year += year < 100 ? 2000 : 0;
-
-    var expiry = new Date;
-    expiry.setYear(year);
-    expiry.setDate(1);
-    expiry.setHours(0);
-    expiry.setMinutes(0);
-    expiry.setSeconds(0);
-    expiry.setMonth(month + 1);
-    return new Date < expiry;
-  },
-
-  /**
-   * Validates whether a number looks like a cvv.
-   *
-   * e.g.: '123', '0321'
-   *
-   * @param {Number|String} number The card verification value
-   * @return {Boolean}
-   */
-
-  cvv: function (number) {
-    number = trim(number + '');
-    return /^\d+$/.test(number) && (number.length === 3 || number.length === 4);
-  }
-
-};
-
-});
-require.register("recurly/lib/recurly/pricing/index.js", function(exports, require, module){
-/**
- * dependencies
- */
-
-var Emitter = require('emitter');
-var index = require('indexof');
-var each = require('each');
-var type = require('type');
-var bind = require('bind');
-var find = require('find');
-var mixin = require('mixin');
-var keys = require('object').keys;
-var json = require('json');
-var debug = require('debug')('recurly:pricing');
-var PricingPromise = require('./promise');
-var Calculations = require('./calculations');
-var errors = require('../../errors');
-
-/**
- * expose
- */
-
-exports.Pricing = Pricing;
-
-/**
- * Pricing
- *
- * @constructor
- * @param {Recurly} recurly
- * @public
- */
-
-function Pricing (recurly) {
-  if (this instanceof require('../../recurly')) return new Pricing(this);
-  this.recurly = recurly;
-  this.reset();
-}
-
-Emitter(Pricing.prototype);
-
-/**
- * Subscription properties
- */
-
-Pricing.properties = [
-    'plan'
-  , 'addon'
-  , 'coupon'
-  , 'address'
-  , 'currency'
-];
-
-/**
- * Resets the pricing calculator
- *
- * @public
- */
-
-Pricing.prototype.reset = function () {
-  this.items = {};
-  this.items.addons = [];
-  this.currency(this.recurly.config.currency);
-};
-
-/**
- * Removes an object from the pricing model
- *
- * example
- *
- *   .remove({ plan: 'plan_code' });
- *   .remove({ addon: 'addon_code' });
- *   .remove({ coupon: 'coupon_code' });
- *   .remove({ address: true }); // to remove without specifying a code
- *
- * @param {Object} opts
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.remove = function (opts, done) {
-  var self = this;
-  var item;
-  debug('remove');
-
-  return new PricingPromise(function (resolve, reject) {
-    var prop = keys(opts)[0];
-    var id = opts[prop];
-    if (!~index(Pricing.properties, prop)) return reject(errors('invalid-item'));
-    if (prop === 'addon') {
-      var pos = index(self.items.addons, findAddon(self.items.addons, { code: id }));
-      if (~pos) {
-        item = self.items.addons.splice(pos);
-      }
-    } else if (self.items[prop] && (id === self.items[prop].code || id === true)) {
-      item = self.items[prop]
-      delete self.items[prop];
-    } else {
-      return reject(errors('unremovable-item', {
-          type: prop
-        , id: id
-        , reason: 'does not exist on this pricing instance.'
-      }));
-    }
-  }, this).nodeify(done);
-};
-
-/**
- * Provides a subscription price estimate using current state
- *
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.reprice = function (done) {
-  var self = this;
-  debug('reprice');
-
-  return new PricingPromise(function (resolve, reject) {
-    if (!self.items.plan) return reject(errors('missing-plan'));
-
-    Calculations(self, function (price) {
-      if (json.stringify(price) === json.stringify(self.price)) return resolve(price);
-      self.price = price;
-      self.emit('change', price);
-      resolve(price);
-    });
-  }, this).nodeify(done);
-};
-
-/**
- * Updates plan
- *
- * @param {String} planCode
- * @param {Object} [meta]
- * @param {Number} [meta.quantity]
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.plan = function (planCode, meta, done) {
-  var self = this;
-  var plan = this.items.plan;
-  var quantity;
-
-  if (type(meta) === 'function') {
-    done = meta;
-    meta = undefined;
-  }
-
-  meta = meta || {};
-
-  // meta.quantity, plan.quantity, 1
-  if (plan && plan.quantity) quantity = plan.quantity;
-  if (meta.quantity) quantity = parseInt(meta.quantity, 10);
-  if (!quantity || quantity < 1) quantity = 1;
-
-  return new PricingPromise(function (resolve, reject) {
-    if (plan && plan.code === planCode) {
-      plan.quantity = quantity;
-      return resolve(plan);
-    }
-
-    self.recurly.plan(planCode, function (err, plan) {
-      if (err) return reject(err);
-
-      plan.quantity = quantity;
-      self.items.plan = plan;
-
-      if (!(self.items.currency in plan.price)) {
-        self.currency(keys(plan.price)[0]);
-      }
-
-      debug('set.plan');
-      self.emit('set.plan', plan);
-      resolve(plan);
-    });
-  }, this).nodeify(done);
-};
-
-/**
- * Updates addon
- *
- * @param {String} addonCode
- * @param {Object} [meta]
- * @param {Number} [meta.quantity]
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.addon = function (addonCode, meta, done) {
-  var self = this;
-
-  if (type(meta) === 'function') {
-    done = meta;
-    meta = undefined;
-  }
-
-  meta = meta || {};
-
-  return new PricingPromise(function (resolve, reject) {
-    if (!self.items.plan) return reject(errors('missing-plan'));
-
-    var planAddon = findAddon(self.items.plan.addons, addonCode);
-    if (!planAddon) {
-      return reject(errors('invalid-addon', {
-          planCode: self.items.plan.code
-        , addonCode: addonCode
-      }));
-    }
-
-    var quantity = addonQuantity(meta, planAddon);
-    var addon = findAddon(self.items.addons, addonCode);
-
-    if (quantity === 0) {
-      self.remove({ addon: addonCode });
-    }
-
-    if (addon) {
-      addon.quantity = quantity;
-    } else {
-      addon = json.parse(json.stringify(planAddon));
-      addon.quantity = quantity;
-      self.items.addons.push(addon);
-    }
-
-    debug('set.addon');
-    self.emit('set.addon', addon);
-    resolve(addon);
-  }, this).nodeify(done);
-};
-
-/**
- * Updates coupon
- *
- * @param {String} couponCode
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.coupon = function (couponCode, done) {
-  var self = this;
-  var coupon = this.items.coupon;
-
-  return new PricingPromise(function (resolve, reject) {
-    if (!self.items.plan) return reject(errors('missing-plan'));
-    if (coupon) {
-      if (coupon.code === couponCode) return resolve(coupon);
-      else self.remove({ coupon: coupon.code });
-    }
-    if (!couponCode) return resolve();
-
-    self.recurly.coupon({ plan: self.items.plan.code, coupon: couponCode }, function (err, coupon) {
-      if (err && err.code !== 'not_found') return reject(err);
-
-      self.items.coupon = coupon;
-
-      debug('set.coupon');
-      self.emit('set.coupon', coupon);
-      resolve(coupon);
-    });
-  }, this).nodeify(done);
-};
-
-/**
- * Updates address
- *
- * @param {Object} address
- * @param {String} address.country
- * @param {String|Number} address.postal_code
- * @param {String} address.vat_number
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.address = function (address, done) {
-  var self = this;
-
-  return new PricingPromise(function (resolve, reject) {
-    if (json.stringify(address) === json.stringify(self.items.address)) {
-      return resolve(self.items.address);
-    }
-
-    self.items.address = address;
-
-    debug('set.address');
-    self.emit('set.address', address);
-    resolve(address);
-  }, this).nodeify(done);
-};
-
-/**
- * Updates or retrieves currency code
- *
- * @param {String} code
- * @param {Function} [done] callback
- * @public
- */
-
-Pricing.prototype.currency = function (code, done) {
-  var self = this;
-  var plan = this.items.plan
-  var currency = this.items.currency;
-
-  return new PricingPromise(function (resolve, reject) {
-    if (currency === code) return resolve(currency);
-    if (plan && !(code in plan.price)) {
-      return reject(errors('invalid-currency', {
-          currencyCode: code
-        , planCurrencies: keys(plan.price)
-      }));
-    }
-
-    self.items.currency = code;
-
-    debug('set.currency');
-    self.emit('set.currency', code);
-    resolve(code);
-  }, this).nodeify(done);
-};
-
-/**
- * DOM attachment mixin
- */
-
-mixin(Pricing.prototype, require('./attach'));
-
-/**
- * Utility functions
- */
-
-function addonQuantity (meta, planAddon) {
-  var qty = 1;
-  if ('quantity' in planAddon) qty = planAddon.quantity;
-  if ('quantity' in meta) qty = meta.quantity;
-  return parseInt(qty, 10) || 0;
-}
-
-function findAddon (addons, code) {
-  return addons && find(addons, { code: code });
-}
-
-});
-require.register("recurly/lib/recurly/pricing/promise.js", function(exports, require, module){
-/**
- * Dependencies
- */
-
-var Promise = require('promise');
-var mixin = require('mixin');
-var bind = require('bind');
-var each = require('each');
-var type = require('type');
-var par = require('par');
-var debug = require('debug')('recurly:pricing:promise');
-
-/**
- * Expose
- */
-
-module.exports = PricingPromise;
-
-/**
- * PricingPromise
- *
- * issues repricing when .done
- *
- * contains .then wrappers for Pricing property methods
- *
- * Usage
- *
- *   var pricing = recurly.Pricing();
- *   
- *   pricing
- *     .plan('basic')
- *     .addon('addon1')
- *     .then(process)
- *     .catch(errors)
- *     .done();
- *
- * @param {Function} resolver
- * @param {Pricing} pricing bound instance
- * @constructor
- * @public
- */
-
-function PricingPromise (resolver, pricing) {
-  if (!(this instanceof PricingPromise)) return new PricingPromise(resolver, pricing);
-
-  var self = this;
-  this.pricing = pricing;
-  this.constructor = par.rpartial(this.constructor, pricing);
-
-  Promise.call(this, resolver);
-
-  // for each pricing method, create a promise wrapper method
-  each(require('./').Pricing.prototype, function (method) {
-    self[method] = function () {
-      var args = arguments;
-      return self.then(function () {
-        return self.pricing[method].apply(self.pricing, args);
-      });
-    };
-  });
-}
-
-mixin(PricingPromise.prototype, Promise.prototype);
-PricingPromise.prototype.constructor = PricingPromise;
-
-/**
- * Adds a reprice and completes the control flow
- *
- * @param {Function} onFulfilled
- * @param {Function} onRejected
- * @return {Pricing} bound pricing instance
- * @public
- */
-
-PricingPromise.prototype.done = function () {
-  Promise.prototype.done.apply(this.then(this.reprice), arguments);
-  return this.pricing;
-};
-
-/**
- * Adds a reprice if a callback is passed
- *
- * @param {Function} [done] callback
- * @public
- */
-
-PricingPromise.prototype.nodeify = function (done) {
-  if (type(done) === 'function') this.reprice();
-  return Promise.prototype.nodeify.apply(this, arguments);
-};
-
-});
-require.register("recurly/lib/recurly/pricing/calculations.js", function(exports, require, module){
-/**
- * dependencies
- */
-
-var each = require('each');
-var bind = require('bind');
-var find = require('find');
-
-/**
- * expose
- */
-
-module.exports = Calculations;
-
-/**
- * Subscription calculation calculation
- *
- * @param {Pricing} pricing
- * @constructor
- * @public
- */
-
-function Calculations (pricing, done) {
-  if (!(this instanceof Calculations)) {
-    return new Calculations(pricing, done);
-  }
-
-  this.pricing = pricing;
-  this.items = pricing.items;
-
-  this.price = {
-    now: {},
-    next: {},
-    addons: {},
-    currency: {
-      code: this.items.currency,
-      symbol: this.planPrice().symbol
-    }
-  };
-
-  this.subtotal();
-
-  this.tax(function () {
-    this.total();
-    each(this.price.now, decimal, this.price.now);
-    each(this.price.next, decimal, this.price.next);
-    each(this.price.addons, decimal, this.price.addons);
-    done(this.price);
-  });
-}
-
-/**
- * Calculates subtotal
- *
- * @private
- */
-
-Calculations.prototype.subtotal = function () {
-  var subtotal = this.planPrice().amount;
-
-  this.price.now.subtotal = subtotal;
-  this.price.next.subtotal = subtotal;
-
-  if (this.items.plan.trial) this.price.now.subtotal = 0;
-
-  this.addons();
-  this.price.now.subtotal += this.price.now.addons;
-  this.price.next.subtotal += this.price.next.addons;
-
-  this.discount();
-  this.price.now.subtotal -= this.price.now.discount;
-  this.price.next.subtotal -= this.price.next.discount;
-
-  this.setupFee();
-  this.price.now.subtotal += this.price.now.setup_fee;
-};
-
-/**
- * Calculates tax
- * 
- * @param {Function} done
- * @private
- */
-
-Calculations.prototype.tax = function (done) {
-  this.price.now.tax = 0;
-  this.price.next.tax = 0;
-
-  if (this.items.address) {
-    var self = this;
-    this.pricing.recurly.tax(this.items.address, function applyTax (err, taxes) {
-      if (err) {
-        self.pricing.emit('error', err);
-      } else {
-        each(taxes, function (tax) {
-          if (tax.type === 'usst' && self.items.plan.tax_exempt) return;
-          self.price.now.tax += self.price.now.subtotal * tax.rate;
-          self.price.next.tax += self.price.next.subtotal * tax.rate;
-        });
-
-        // tax estimation prefers partial cents to always round up
-        self.price.now.tax = Math.ceil(self.price.now.tax * 100) / 100;
-        self.price.next.tax = Math.ceil(self.price.next.tax * 100) / 100;
-      }
-      done.call(self);
-    });
-  } else done.call(this);
-};
-
-/**
- * Calculates total
- *
- * @private
- */
-
-Calculations.prototype.total = function () {
-  this.price.now.total = this.price.now.subtotal + this.price.now.tax;
-  this.price.next.total = this.price.next.subtotal + this.price.next.tax;
-};
-
-/**
- * Computes addon prices and applies addons to the subtotal
- *
- * @private
- */
-
-Calculations.prototype.addons = function () {
-  this.price.now.addons = 0;
-  this.price.next.addons = 0;
-
-  each(this.items.plan.addons, function (addon) {
-    var price = addon.price[this.items.currency].unit_amount;
-
-    this.price.addons[addon.code] = price;
-
-    var selected = find(this.items.addons, { code: addon.code });
-    if (selected) {
-      price = price * selected.quantity;
-      if (!this.items.plan.trial) this.price.now.addons += price;
-      this.price.next.addons += price;
-    }
-  }, this);
-};
-
-/**
- * Applies coupon discount to the subtotal
- *
- * @private
- */
-
-Calculations.prototype.discount = function () {
-  var coupon = this.items.coupon;
-
-  this.price.now.discount = 0;
-  this.price.next.discount = 0;
-
-  if (coupon) {
-    if (coupon.discount.rate) {
-      this.price.now.discount = Math.round(this.price.now.subtotal * coupon.discount.rate * 100) / 100;
-      this.price.next.discount = Math.round(this.price.next.subtotal * coupon.discount.rate * 100) / 100;
-    } else {
-      this.price.now.discount = coupon.discount.amount[this.items.currency];
-      this.price.next.discount = coupon.discount.amount[this.items.currency];
-    }
-  }
-};
-
-/**
- * Applies plan setup fee to the subtotal
- *
- * @private
- */
-
-Calculations.prototype.setupFee = function () {
-  this.price.now.setup_fee = this.planPrice().setup_fee;
-  this.price.next.setup_fee = 0;
-};
-
-/**
- * Get the price structure of a plan based on currency
- *
- * @return {Object}
- * @private
- */
-
-Calculations.prototype.planPrice = function () {
-  var plan = this.items.plan;
-  var price = plan.price[this.items.currency];
-  price.amount = price.unit_amount * (plan.quantity || 1);
-  return price;
-};
-
-/**
- * Applies a decimal transform on an object's member
- *
- * @param {String} prop Property on {this} to transform
- * @this {Object} on which to apply decimal transformation
- * @private
- */
-
-function decimal (prop) {
-  this[prop] = (Math.round(Math.max(this[prop], 0) * 100) / 100).toFixed(2);
-}
-
-});
-require.register("recurly/lib/recurly/pricing/attach.js", function(exports, require, module){
-/**
- * dependencies
- */
-
-var each = require('each');
-var events = require('event');
-var find = require('find');
-var type = require('type');
-var dom = require('../../util/dom');
-var debug = require('debug')('recurly:pricing:attach');
-
-/**
- * bind a dom element to pricing values
- *
- * @param {HTMLElement} el
- */
-
-exports.attach = function (el) {
-  var self = this;
-  var elems = {};
-  var el = dom.element(el);
-
-  if (!el) throw new Error('invalid dom element');
-
-  if (this.attach.detatch) this.attach.detatch();
-
-  self.on('change', update);
-
-  each(el.querySelectorAll('[data-recurly]'), function (elem) {
-    // 'zip' -> 'postal_code'
-    if (dom.data(elem, 'recurly') === 'zip') dom.data(elem, 'recurly', 'postal_code');
-
-    var name = dom.data(elem, 'recurly');
-    if (!elems[name]) elems[name] = [];
-    elems[name].push(elem);
-    events.bind(elem, 'change', change);
-    events.bind(elem, 'propertychange', change);
-  });
-
-  this.attach.detatch = detatch;
-
-  change();
-
-  function change (event) {
-    debug('change');
-
-    var targetName = event && event.target && dom.data(event.target, 'recurly');
-        targetName = targetName || window.event && window.event.srcElement;
-
-    var pricing = self.plan(dom.value(elems.plan), { quantity: dom.value(elems.plan_quantity) });
-    
-    if (target('currency')) {
-      pricing = pricing.currency(dom.value(elems.currency));
-    }
-
-    if (target('addon') && elems.addon) {
-      addons();
-    }
-
-    if (target('coupon') && elems.coupon) {
-      pricing = pricing.coupon(dom.value(elems.coupon)).then(null, ignoreBadCoupons);
-    }
-
-    if (target('country') || target('postal_code') || target('vat_number')) {
-      pricing = pricing.address({
-        country: dom.value(elems.country),
-        postal_code: dom.value(elems.postal_code),
-        vat_number: dom.value(elems.vat_number)
-      });
-    }
-
-    pricing.done();
-
-    function addons () {
-      each(elems.addon, function (node) {
-        var plan = self.items.plan;
-        var addonCode = dom.data(node, 'recurlyAddon');
-        if (plan.addons && find(plan.addons, { code: addonCode })) {
-          pricing = pricing.addon(addonCode, { quantity: dom.value(node) });
-        }
-      });
-    }
-
-    function target (name) {
-      if (!targetName) return true;
-      if (targetName === name) return true;
-      return false
-    }
-  };
-
-  function update (price) {
-    dom.value(elems.currency_code, price.currency.code);
-    dom.value(elems.currency_symbol, price.currency.symbol);
-    each(['addons', 'discount', 'setup_fee', 'subtotal', 'tax', 'total'], function (value) {
-      dom.value(elems[value + '_now'], price.now[value]);
-      dom.value(elems[value + '_next'], price.next[value]);
-    });
-    if (elems.addonPrice) {
-      each(elems.addonPrice, function (elem) {
-        var addonPrice = price.addons[dom.data(elem, 'recurlyAddon')];
-        if (addonPrice) dom.value(elem, addonPrice);
-      });
-    }
-  }
-
-  function detatch () {
-    each(elems, function (name, elems) {
-      each(elems, function (elem) {
-        events.unbind(elem, 'change', change);
-        events.unbind(elem, 'propertychange', change);
-      }, this);
-    }, this);
-  }
-};
-
-function ignoreBadCoupons (err) {
-  if (err.code === 'not-found') return;
-  else throw err;
-}
-
-/**
- * Backward-compatibility
- *
- * @deprecated
- */
-
-exports.binding = exports.attach;
-
-});
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-require.alias("visionmedia-node-querystring/index.js", "recurly/deps/querystring/index.js");
-require.alias("visionmedia-node-querystring/index.js", "querystring/index.js");
-
-require.alias("component-emitter/index.js", "recurly/deps/emitter/index.js");
-require.alias("component-emitter/index.js", "emitter/index.js");
-
-require.alias("component-indexof/index.js", "recurly/deps/indexof/index.js");
-require.alias("component-indexof/index.js", "indexof/index.js");
-
-require.alias("component-object/index.js", "recurly/deps/object/index.js");
-require.alias("component-object/index.js", "object/index.js");
-
-require.alias("component-event/index.js", "recurly/deps/event/index.js");
-require.alias("component-event/index.js", "event/index.js");
-
-require.alias("component-clone/index.js", "recurly/deps/clone/index.js");
-require.alias("component-clone/index.js", "clone/index.js");
-require.alias("component-type/index.js", "component-clone/deps/type/index.js");
-
-require.alias("component-bind/index.js", "recurly/deps/bind/index.js");
-require.alias("component-bind/index.js", "bind/index.js");
-
-require.alias("component-each/index.js", "recurly/deps/each/index.js");
-require.alias("component-each/index.js", "each/index.js");
-require.alias("component-to-function/index.js", "component-each/deps/to-function/index.js");
-require.alias("component-props/index.js", "component-to-function/deps/props/index.js");
-
-require.alias("component-type/index.js", "component-each/deps/type/index.js");
-
-require.alias("component-find/index.js", "recurly/deps/find/index.js");
-require.alias("component-find/index.js", "find/index.js");
-require.alias("component-to-function/index.js", "component-find/deps/to-function/index.js");
-require.alias("component-props/index.js", "component-to-function/deps/props/index.js");
-
-require.alias("component-json/index.js", "recurly/deps/json/index.js");
-require.alias("component-json/index.js", "json/index.js");
-
-require.alias("component-type/index.js", "recurly/deps/type/index.js");
-require.alias("component-type/index.js", "type/index.js");
-
-require.alias("component-trim/index.js", "recurly/deps/trim/index.js");
-require.alias("component-trim/index.js", "trim/index.js");
-
-require.alias("component-map/index.js", "recurly/deps/map/index.js");
-require.alias("component-map/index.js", "map/index.js");
-require.alias("component-to-function/index.js", "component-map/deps/to-function/index.js");
-require.alias("component-props/index.js", "component-to-function/deps/props/index.js");
-
-require.alias("yields-merge/index.js", "recurly/deps/merge/index.js");
-require.alias("yields-merge/index.js", "merge/index.js");
-
-require.alias("learnboost-jsonp/index.js", "recurly/deps/jsonp/index.js");
-require.alias("learnboost-jsonp/index.js", "recurly/deps/jsonp/index.js");
-require.alias("learnboost-jsonp/index.js", "jsonp/index.js");
-require.alias("visionmedia-debug/debug.js", "learnboost-jsonp/deps/debug/debug.js");
-require.alias("visionmedia-debug/debug.js", "learnboost-jsonp/deps/debug/index.js");
-require.alias("visionmedia-debug/debug.js", "visionmedia-debug/index.js");
-require.alias("learnboost-jsonp/index.js", "learnboost-jsonp/index.js");
-require.alias("visionmedia-debug/debug.js", "recurly/deps/debug/debug.js");
-require.alias("visionmedia-debug/debug.js", "recurly/deps/debug/index.js");
-require.alias("visionmedia-debug/debug.js", "debug/index.js");
-require.alias("visionmedia-debug/debug.js", "visionmedia-debug/index.js");
-require.alias("chrissrogers-promise/index.js", "recurly/deps/promise/index.js");
-require.alias("chrissrogers-promise/core.js", "recurly/deps/promise/core.js");
-require.alias("chrissrogers-promise/index.js", "promise/index.js");
-require.alias("johntron-asap/asap.js", "chrissrogers-promise/deps/asap/asap.js");
-require.alias("johntron-asap/asap.js", "chrissrogers-promise/deps/asap/index.js");
-require.alias("johntron-asap/asap.js", "johntron-asap/index.js");
-require.alias("kewah-mixin/index.js", "recurly/deps/mixin/index.js");
-require.alias("kewah-mixin/index.js", "recurly/deps/mixin/index.js");
-require.alias("kewah-mixin/index.js", "mixin/index.js");
-require.alias("kewah-mixin/index.js", "kewah-mixin/index.js");
-require.alias("pluma-par/dist/par.js", "recurly/deps/par/dist/par.js");
-require.alias("pluma-par/dist/par.js", "recurly/deps/par/index.js");
-require.alias("pluma-par/dist/par.js", "par/index.js");
-require.alias("pluma-par/dist/par.js", "pluma-par/index.js");
-require.alias("ianstormtaylor-to-slug-case/index.js", "recurly/deps/to-slug-case/index.js");
-require.alias("ianstormtaylor-to-slug-case/index.js", "to-slug-case/index.js");
-require.alias("ianstormtaylor-to-space-case/index.js", "ianstormtaylor-to-slug-case/deps/to-space-case/index.js");
-require.alias("ianstormtaylor-to-no-case/index.js", "ianstormtaylor-to-space-case/deps/to-no-case/index.js");
-
-require.alias("recurly/lib/index.js", "recurly/index.js");if (typeof exports == "object") {
-  module.exports = require("recurly");
-} else if (typeof define == "function" && define.amd) {
-  define([], function(){ return require("recurly"); });
-} else {
-  this["recurly"] = require("recurly");
-}})();

File diff suppressed because it is too large
+ 0 - 0
services/web/public/js/libs/recurly-4.8.5.js


File diff suppressed because it is too large
+ 0 - 0
services/web/public/js/libs/recurly.min.js


BIN
services/web/public/ol-apple-touch-icon-precomposed.png


+ 4 - 0
services/web/public/ol-mask-favicon.svg

@@ -0,0 +1,4 @@
+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+  <title>favicon</title>
+  <path d="M6.12,4.38A7.08,7.08,0,0,0,2.67,10.2,5.07,5.07,0,1,0,9.55,5.46a4.68,4.68,0,0,0-1.8-.34A7.48,7.48,0,0,0,5.2,8.07a3.33,3.33,0,1,1,2.54,5.47A3.33,3.33,0,0,1,5.2,12.37,4,4,0,0,1,4.24,9C4.89,5.11,9.55,2.87,13,2a35.85,35.85,0,0,0-4.6,2.65c4.17,1.61,4.84-1.9,6.79-3.47C13.25.42,6.13.14,6.12,4.38Z"/>
+</svg>

BIN
services/web/public/ol-touch-icon-192x192.png


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

@@ -84,8 +84,4 @@
 @import	"../js/libs/pdfListView/TextLayer.css";
 @import	"../js/libs/pdfListView/AnnotationsLayer.css";
 @import	"../js/libs/pdfListView/HighlightsLayer.css";
-
-// CodeMirror
-& when (@show-rich-text) {
-  @import "vendor/codemirror.css";
-}
+@import "vendor/codemirror.css";

+ 10 - 4
services/web/public/stylesheets/app/base.less

@@ -1,8 +1,14 @@
 .system-message {
-    padding: (@line-height-computed / 4) (@line-height-computed / 2);
-    background-color: @state-warning-bg;
-    color: #333;
-    border-bottom: 1px solid @common-border-color;
+  padding: (@line-height-computed / 4) (@line-height-computed / 2);
+  background-color: @sys-msg-background;
+  color: @sys-msg-color;
+  border-bottom: @sys-msg-border;
+}
+
+.system-message .close when (@is-overleaf = true) {
+  color: #FFF;
+  opacity: 1;
+  text-shadow: none;
 }
 
 .clickable {

+ 81 - 75
services/web/public/stylesheets/app/editor.less

@@ -12,9 +12,11 @@
 @import "./editor/online-users.less";
 @import "./editor/hotkeys.less";
 @import "./editor/review-panel.less";
+@import "./editor/rich-text.less";
+@import "./editor/publish-modal.less";
 
 @ui-layout-toggler-def-height: 50px;
-@ui-resizer-extra-hit-area: 8px;
+@ui-resizer-size: 7px;
 
 @keyframes blink {
 	0% { 
@@ -79,11 +81,13 @@
 	.full-size;
 }
 
-#editor when (@show-rich-text = true) {
-	top: 40px; // TODO: replace with toolbar height var
+#editor-rich-text {
+	top: @editor-toolbar-height;
 }
-#editor-rich-text when (@show-rich-text = true) {
-	top: 40px; // TODO: replace with toolbar height var
+
+.toolbar-editor {
+	height: @editor-toolbar-height;
+	background-color: @editor-toolbar-bg;
 }
 
 .loading-screen {
@@ -107,7 +111,7 @@
 			height: 0;
 			background: @editor-loading-logo-background-url no-repeat bottom / 100%;
 
-    		&::after {
+			&::after {
 				content: '';
 				position: absolute;
 				height: inherit;
@@ -116,7 +120,7 @@
 				left: 0;
 				background: @editor-loading-logo-foreground-url no-repeat bottom / 100%;
 				transition: height .5s;
-    		}
+			}
 		}
 		.loading-screen-label {
 			margin: 0;
@@ -302,40 +306,31 @@
 }
 
 .ui-layout-resizer when (@is-overleaf = true) {
-	margin-left: -(@ui-resizer-extra-hit-area) !important;
-	margin-right: -(@ui-resizer-extra-hit-area - 1px) !important;
-	padding-left: @ui-resizer-extra-hit-area !important;
-	padding-right: @ui-resizer-extra-hit-area !important;
-	z-index: 5 !important;
-	box-sizing: content-box;
-	background-image: linear-gradient(90deg, 
-		transparent,
-		transparent (@ui-resizer-extra-hit-area - 1px),
-		@editor-resizer-bg-color (@ui-resizer-extra-hit-area - 1px),
-		@editor-resizer-bg-color (@ui-resizer-extra-hit-area + 1px),
-		transparent (@ui-resizer-extra-hit-area + 1px),
-		transparent);
-
-	.ui-layout-toggler {
-		padding: 0 @ui-resizer-extra-hit-area !important;
-		background-image: linear-gradient(90deg, 
-			transparent,
-			transparent (@ui-resizer-extra-hit-area - 1px),
-			@editor-toggler-bg-color (@ui-resizer-extra-hit-area - 1px),
-			@editor-toggler-bg-color (@ui-resizer-extra-hit-area + 1px),
-			transparent (@ui-resizer-extra-hit-area + 1px),
-			transparent);
-
-		&:hover {
-			background-image: linear-gradient(90deg, 
-				transparent,
-				transparent (@ui-resizer-extra-hit-area - 2px),
-				@editor-toggler-hover-bg-color (@ui-resizer-extra-hit-area - 2px),
-				@editor-toggler-hover-bg-color (@ui-resizer-extra-hit-area + 2px),
-				transparent (@ui-resizer-extra-hit-area + 2px),
-				transparent);
+	width: @ui-resizer-size !important;
+	background-color: @editor-resizer-bg-color;
+	&.ui-layout-resizer-closed {
+		&::before,
+		&::after {
+			content: none;
 		}
 	}
+
+	&::before,
+	&::after {
+		content: '\2847';
+		display: block;
+		position: absolute;
+		text-align: center;
+		left: -2px;
+		-webkit-font-smoothing: antialiased;
+		width: 100%;
+		font-size: 24px;
+		top: 25%;
+		color: @ol-blue-gray-2;
+	}
+	&::after {
+		top: 75%;
+	}
 }
 
 .ui-layout-resizer-west.ui-layout-resizer-open, .ui-layout-resizer-east.ui-layout-resizer-closed {
@@ -353,54 +348,66 @@
 	}
 }
 
-.ui-layout-toggler.ui-layout-toggler-closed when (@is-overleaf = true) {
-	background-color: @editor-resizer-bg-color;
-	background-image: none;
-	line-height: @ui-layout-toggler-def-height;
-	
+.ui-layout-toggler when (@is-overleaf = true) {
+	display: none !important;
+}
+
+.custom-toggler when (@is-overleaf = true) {
+	position: absolute;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: @ui-resizer-size !important;;	
+	height: 50px;
+	margin-top: -25px;
+	top: 50%;
+	z-index: 6;
+	background-color: @editor-toggler-bg-color;
+
+	&:hover,
+	&:focus {
+		outline: none;
+		text-decoration: none;
+	}
+	// Increase hit area
 	&::before {
-		content: "\22EE"; // Vertical ellipsis
+		content: '';
 		display: block;
+		position: absolute;
+		top: 0;
+		right: -3px;
+		bottom: 0;
+		left: -3px;
+	}
+
+	&::after {
+    	font-family: FontAwesome;
+    	-webkit-font-smoothing: antialiased;
+		-moz-osx-font-smoothing: grayscale;
+    	font-size: 65%;
+    	font-weight: bold;
 		color: #FFF;
-		font-weight: 700;
-		font-size: @font-size-h2;
-		width: @ui-resizer-extra-hit-area / 2;
+		user-select: none;
+		pointer-events: none;
 	}
+
 	&:hover {
 		background-color: @editor-toggler-hover-bg-color;
-		background-image: none;
-	}
-	.ui-layout-resizer-west > & {
-		border-radius: 0 @border-radius-base @border-radius-base 0;
-		&::before {
-			margin-left: -2px;
-		}
-	}
-	.ui-layout-resizer-east > & {
-		border-radius: @border-radius-base 0 0 @border-radius-base;
-		&::before {
-			margin-left: (-1 - @ui-resizer-extra-hit-area);
-		}
 	}
 }
-
-.ui-layout-toggler-east when (@is-overleaf = true) {
-	&.ui-layout-toggler-open {
-		cursor: e-resize !important
+	.custom-toggler-east::after {
+		content: '\f105';
 	}
-	&.ui-layout-toggler-closed {
-		cursor: w-resize !important
+	.custom-toggler-west::after {
+		content: '\f104';
 	}
-}
 
-.ui-layout-toggler-west when (@is-overleaf = true) {
-	&.ui-layout-toggler-open {
-		cursor: w-resize !important
+	.custom-toggler-closed.custom-toggler-east::after {
+		content: '\f104';
 	}
-	&.ui-layout-toggler-closed {
-		cursor: e-resize !important
+	.custom-toggler-closed.custom-toggler-west::after {
+		content: '\f105';
 	}
-}
 
 .ui-layout-resizer-dragging {
 	background-color: @editor-resizer-bg-color-dragging;
@@ -624,4 +631,3 @@
 	height: auto;
 	border-bottom: 1px solid @modal-header-border-color;
 }
-

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

@@ -270,7 +270,7 @@
 }
 
 .synctex-controls when (@is-overleaf = true) {
-	margin-right: -11px;
+	margin-right: -8px;
 }
 	.synctex-control {
 		display: block;

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

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

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

@@ -0,0 +1,222 @@
+@rt-font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif;
+// @rt-font-family-serif: 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
+
+.rich-text {
+  font-family: @rt-font-family;
+  font-size: 1.15em;
+
+  pre, .CodeMirror-linewidget {
+    font-family: @rt-font-family;
+  }
+
+  // TODO: Change prefix away from wl- ?
+
+  /****************************************************************************/
+
+  .preamble h1 {
+    text-align: center;
+    font-size: 2em;
+    color: @text-color;
+  }
+
+  .preamble ul.authors {
+    margin-left: 0;
+    padding-left: 0;
+    list-style: none;
+    text-align: center;
+  }
+
+  .preamble ul.authors li {
+    padding-bottom: 5px;
+  }
+
+  /****************************************************************************/
+
+  .wl-indent-0 {
+    padding-left: 2.5em !important;
+  }
+
+  .wl-indent-1 {
+    padding-left: 3.5em !important;
+  }
+
+  .wl-indent-2 {
+    padding-left: 4.5em !important;
+  }
+
+  .wl-indent-3 {
+    padding-left: 5.5em !important;
+  }
+
+  .wl-indent-4 {
+    padding-left: 6.5em !important;
+  }
+
+  .wl-indent-env-0 {
+    padding-left: 4px !important;
+  }
+
+  .wl-indent-env-1 {
+    padding-left: 1.5em !important;
+  }
+
+  .wl-indent-env-2 {
+    padding-left: 2.5em !important;
+  }
+
+  .wl-indent-env-3 {
+    padding-left: 3.5em !important;
+  }
+
+  .wl-indent-env-4 {
+    padding-left: 4.5em !important;
+  }
+
+  .wl-enumerate-item-open {
+    text-align: right;
+    width: 1.5em;
+    display: inline-block;
+    padding-right: 0.2em;
+  }
+
+  .wl-item-open {
+    text-align: right;
+    width: 1.5em;
+    display: inline-block;
+    padding-right: 0.2em;
+  }
+
+  .wl-input {
+    font-style: oblique;
+  }
+
+  /****************************************************************************/
+
+  .wl-abstract-open, .wl-abstract-close {
+    border-top: 1px solid #999;
+    font-size: large;
+    font-weight: bold;
+    width: 100%;
+  }
+
+  .wl-figure {
+    max-height: 120px;
+    width: auto;
+    margin: 0 auto;
+  }
+
+  .wl-figure-wrap {
+    padding: 10px 0;
+    background-color: #f5f5f5;
+    box-shadow: 2px 2px 2px #DFDFDF;
+    width: 96%;
+    margin: 0 auto;
+    text-align: center;
+  }
+
+  .wl-figure-caption {
+    padding: 3px 0 4px;
+    font-size: small;
+    margin: 0 auto;
+    text-align: center;
+  }
+
+  /****************************************************************************/
+
+  .wl-chapter, .wl-chapter-open, .wl-chapter-close {
+    font-size: 2.2em;
+    font-weight: bold;
+  }
+
+  .wl-chapter-open, .wl-chapter-close {
+    color: #999;
+  }
+
+  /****************************************************************************/
+
+  .wl-section, .wl-section-open, .wl-section-close {
+    font-size: 1.8em;
+    font-weight: bold;
+  }
+
+  .wl-section-open, .wl-section-close {
+    color: #999;
+  }
+
+  /****************************************************************************/
+  .wl-subsection, .wl-subsection-open, .wl-subsection-close {
+    font-size: 1.5em;
+    font-weight: bold;
+  }
+
+  .wl-subsection-open, .wl-subsection-close {
+    color: #999;
+  }
+
+  /****************************************************************************/
+  .wl-subsubsection, .wl-subsubsection-open, .wl-subsubsection-close {
+    font-size: 1.1em;
+    font-weight: bold;
+  }
+
+  .wl-subsubsection-open, .wl-subsubsection-close {
+    color: #999;
+  }
+
+  /****************************************************************************/
+  .wl-textbf {
+    font-weight: bold;
+  }
+
+  .wl-textbf-open {
+    font-weight: bold;
+    color: #999;
+  }
+
+  .wl-textbf-close {
+    font-weight: bold;
+    color: #999;
+  }
+
+  .wl-label-bracket {
+    font-weight: bold;
+    color: #999;
+  }
+
+  .wl-label-open,
+  .wl-input-link {
+    .wl-icon {
+      padding-left: 5px;
+      padding-right: 2px;
+      vertical-align: middle;
+      // @include wl-icon-size(inherit);
+    }
+  }
+
+  .wl-img-default {
+    width: 0.9em ;
+    padding: 0 1px 1px;
+  }
+
+  .wl-label-close {
+    background-color: #f7f7f9;
+    border: 1px solid #e1e1e8;
+    border-radius: 4px;
+    font-size: small;
+  }
+
+  /****************************************************************************/
+  .wl-textit {
+    font-style: italic;
+  }
+
+  .wl-textit-open {
+    font-style: italic;
+    color: #999;
+  }
+
+  .wl-textit-close {
+    font-style: italic;
+    color: #999;
+  }
+}

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

@@ -183,3 +183,61 @@
 		}
 	}
 }
+
+.toggle-wrapper {
+	width: 200px;
+	height: 24px;
+}
+
+.toggle-switch {
+  position: relative;
+	height: 100%;
+	width: 100%;
+  background-color: @toggle-switch-bg;
+  border-radius: @btn-border-radius-base;
+}
+
+.toggle-switch-label {
+	position: relative;
+	display: block;
+	font-weight: normal;
+  z-index: 2;
+  float: left;
+  width: 50%;
+  height: 100%;
+  line-height: 24px;
+	text-align: center;
+	margin-bottom: 0;
+  cursor: pointer;
+  user-select: none;
+  transition: color 0.12s ease-out;
+}
+
+.toggle-switch-input {
+  position: absolute;
+  opacity: 0;
+}
+
+.toggle-switch-input:checked + .toggle-switch-label {
+  color: #fff;
+  font-weight: bold;
+}
+
+.toggle-switch-selection {
+  display: block;
+  position: absolute;
+  z-index: 1;
+  top: 2px;
+  left: 2px;
+  right: 2px;
+  width: calc(~"50% - 2px");
+  height: calc(~"100% - 4px");
+  background: @toggle-switch-highlight-color;
+  border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+  transition: transform 0.12s ease-out, border-radius 0.12s ease-out;
+}
+
+.toggle-switch-input:checked:nth-child(4) ~ .toggle-switch-selection {
+  transform: translate(100%);
+  border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
+}

+ 25 - 1
services/web/public/stylesheets/app/error-pages.less

@@ -7,6 +7,11 @@
 	display: flex;
 	align-items: center;
 }
+
+.error-container.full-height when (@is-overleaf = true) {
+	margin-top: -(@header-height + @content-margin-vertical) / 2;
+}
+
 	.error-figure {
 		display: none;
 		flex: 0 0 50%;
@@ -15,6 +20,11 @@
 			display: block;
 		}
 	}
+
+	.error-figure when (@is-overleaf = true) {
+		display: none;
+	}
+
 	.error-figure-500 {
 		&::before {
 			content: '';
@@ -56,6 +66,11 @@
 		flex: 0 1 50%;
 		padding: @line-height-computed * 2;
 	}
+	
+	.error-details when (@is-overleaf = true) {
+		flex-grow: 1;
+	}
+
 		.error-status {
 			font-family: @font-family-serif;
 			margin-bottom: (@line-height-computed / 4);
@@ -68,7 +83,7 @@
 			color: @gray;
 			margin-bottom: @line-height-computed * 2;
 		}
-		.error-btn {
+		.error-btn when (@is-overleaf = false) {
 			color: @navbar-default-link-color;
 			border: 2px solid @navbar-default-link-color;
 			border-radius: @border-radius-base;
@@ -83,4 +98,13 @@
 				background-color: @navbar-default-link-hover-bg;
 				border: 2px solid @navbar-default-link-hover-color;
 			}
+		}
+
+		.error-btn when (@is-overleaf = true) {
+			.btn;
+			.btn-primary;
+			display: block;
+			@media (min-width: @screen-sm-min) {
+				display: inline-block;
+			}
 		}

+ 3 - 6
services/web/public/stylesheets/app/list/v1-import-modal.less

@@ -1,5 +1,6 @@
 .v1-import-title {
 	text-align: center;
+	margin-top: @line-height-computed / 2;
 }
 
 .v1-import-row {
@@ -8,9 +9,6 @@
 }
 
 .v1-import-col {
-	flex-basis: 50%;
-	flex-grow: 0;
-	flex-shrink: 0;
 	padding-left: 15px;
 	padding-right: 15px;
 }
@@ -33,9 +31,8 @@
 }
 
 .v1-import-warning {
-	text-align: center;
-	color: #fdce02;
-	font-size: 14em;
+	color: #4B7FD1;
+	font-size: 10em;
 	line-height: 1em;
 }
 

+ 5 - 0
services/web/public/stylesheets/app/recurly.less

@@ -1,3 +1,8 @@
+.recurly-hosted-field {
+	&:extend(.form-control);
+}
+
+
 .recurly {
 	display: block;
 	position: relative;

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

@@ -35,7 +35,7 @@
 			color: white;
 			padding: 0;
 			line-height: 1;
-			&:hover, &:active {
+			&:hover, &:active, &:focus {
 				color: white;
 			}
 		}

+ 4 - 0
services/web/public/stylesheets/app/subscription.less

@@ -71,4 +71,8 @@
 			}
 		}
 	}
+}
+
+.capitalised {
+	text-transform:capitalize;
 }

+ 16 - 7
services/web/public/stylesheets/app/translations.less

@@ -1,14 +1,23 @@
-
 .translations-message {
-    padding: (@line-height-computed / 4) (@line-height-computed / 2);
-    background-color: @state-warning-bg;
-    color: #333;
-    border-bottom: 1px solid @common-border-color;
+    .system-message;
 	text-align:center;
-    
     img {
     	vertical-align: text-bottom;
         margin-bottom: -1px;
     }
+}
 
-}
+.translations-message when (@is-overleaf = true) {
+	.close {
+		color: #FFF;
+		opacity: 1;
+		text-shadow: none;
+	}
+	a {
+		color: #FFF;
+		&:hover,
+		&:focus {
+			color: #FFF;
+		}
+	}
+}

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

@@ -309,8 +309,9 @@ input[type="checkbox"],
 .has-warning {
   .form-control-validation(@state-warning-text; @state-warning-text; @state-warning-bg);
 }
-.has-error {
+.has-external-error {
   .form-control-validation(@state-danger-text; @state-danger-text; @state-danger-bg);
+  color:@red;
 }
 
 .form-control.ng-dirty.ng-invalid:not(:focus) {

+ 0 - 7
services/web/public/stylesheets/components/navbar.less

@@ -576,10 +576,3 @@
     padding: @navbar-default-padding-v 0;
   }
 }
-
-.system-messages {
-  padding-top: @header-height;
-  position: absolute;
-  width: 100%;
-  z-index: 1;
-}

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

@@ -932,6 +932,14 @@
 @synctex-controls-z-index         : 3;
 @synctex-controls-padding         : 0 2px;
 
+// Editor toolbar
+@editor-toolbar-height            : 32px;
+@editor-toolbar-bg                : #fff;
+
+// Toggle switch
+@toggle-switch-bg                  : @gray-lightest;
+@toggle-switch-highlight-color     : @brand-primary;
+
 // Chat
 @chat-bg                          : transparent;
 @chat-message-color               : @text-color;
@@ -959,4 +967,9 @@
 @tag-max-width      : 150px;
 @tag-bg-hover-color : darken(@label-default-bg, 10%);
 @tag-top-adjustment : -2px;
-@labels-font-size   : 75%;
+@labels-font-size   : 75%;
+
+// System messages
+@sys-msg-background : @state-warning-bg;
+@sys-msg-color      : #333;
+@sys-msg-border     : 1px solid @common-border-color;

Some files were not shown because too many files changed in this diff