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

Merge branch 'master' into pr-email-tokens

Conflicts:
	app/coffee/Features/Notifications/NotificationsBuilder.coffee
	public/coffee/ide/share/controllers/ShareController.coffee
Shane Kilkelly 10 лет назад
Родитель
Сommit
158afbb157
45 измененных файлов с 585 добавлено и 374 удалено
  1. 0 54
      services/web/Gruntfile.coffee
  2. 7 0
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  3. 43 0
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  4. 2 0
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 1 0
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  6. 2 0
      services/web/app/coffee/Features/Compile/CompileController.coffee
  7. 7 7
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  8. 1 1
      services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
  9. 2 2
      services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee
  10. 2 0
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  11. 4 2
      services/web/app/coffee/router.coffee
  12. 0 70
      services/web/app/views/layout.jade
  13. 1 1
      services/web/app/views/project/editor/chat.jade
  14. 3 0
      services/web/app/views/project/editor/header.jade
  15. 11 3
      services/web/app/views/project/editor/pdf.jade
  16. 15 3
      services/web/app/views/project/editor/track-changes.jade
  17. 2 2
      services/web/config/settings.defaults.coffee
  18. 168 163
      services/web/npm-shrinkwrap.json
  19. 3 0
      services/web/package.json
  20. 25 0
      services/web/public/coffee/filters/wrapLongWords.coffee
  21. 11 3
      services/web/public/coffee/ide.coffee
  22. 78 0
      services/web/public/coffee/ide/SafariScrollPatcher.coffee
  23. 1 0
      services/web/public/coffee/ide/chat/index.coffee
  24. 1 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  25. 6 6
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee
  26. 1 1
      services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
  27. 10 2
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
  28. 15 0
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
  29. 83 27
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  30. 3 3
      services/web/public/coffee/ide/settings/services/settings.coffee
  31. 1 1
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  32. 3 0
      services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee
  33. 17 5
      services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee
  34. 2 1
      services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee
  35. 1 1
      services/web/public/coffee/main/account-upgrade.coffee
  36. 2 2
      services/web/public/coffee/main/contact-us.coffee
  37. 20 10
      services/web/public/coffee/main/event.coffee
  38. 3 3
      services/web/public/coffee/main/new-subscription.coffee
  39. BIN
      services/web/public/img/crests/harvard.gif
  40. 4 0
      services/web/public/stylesheets/app/editor/chat.less
  41. 4 0
      services/web/public/stylesheets/app/editor/track-changes.less
  42. 6 0
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  43. 1 0
      services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee
  44. 5 0
      services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee
  45. 8 0
      services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee

+ 0 - 54
services/web/Gruntfile.coffee

@@ -386,57 +386,3 @@ module.exports = (grunt) ->
 
 	grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
 
-
-	grunt.registerTask 'create-admin-user', "Create a user with the given email address and make them an admin. Update in place if the user already exists", () ->
-		done = @async()
-		email = grunt.option("email")
-		if !email?
-			console.error "Usage: grunt create-admin-user --email joe@example.com"
-			process.exit(1)
-
-		settings = require "settings-sharelatex"
-		UserRegistrationHandler = require "./app/js/Features/User/UserRegistrationHandler"
-		OneTimeTokenHandler = require "./app/js/Features/Security/OneTimeTokenHandler"
-		UserRegistrationHandler.registerNewUser {
-			email: email
-			password: require("crypto").randomBytes(32).toString("hex")
-		}, (error, user) ->
-			if error? and error?.message != "EmailAlreadyRegistered"
-				throw error
-			user.isAdmin = true
-			user.save (error) ->
-				throw error if error?
-				ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-				OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
-					return next(err) if err?
-					
-					console.log ""
-					console.log """
-						Successfully created #{email} as an admin user.
-						
-						Please visit the following URL to set a password for #{email} and log in:
-						
-							#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}
-						
-					"""
-					done()
-
-	grunt.registerTask 'delete-user', "deletes a user and all their data", () ->
-		done = @async()
-		email = grunt.option("email")
-		if !email?
-			console.error "Usage: grunt delete-user --email joe@example.com"
-			process.exit(1)
-		settings = require "settings-sharelatex"
-		UserGetter = require "./app/js/Features/User/UserGetter"
-		UserDeleter = require "./app/js/Features/User/UserDeleter"
-		UserGetter.getUser email:email, (error, user) ->
-			if error?
-				throw error
-			if !user?
-				console.log("user #{email} not in database, potentially already deleted")
-				return done()
-			UserDeleter.deleteUser user._id, (err)->
-				if err?
-					throw err
-				done()

+ 7 - 0
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -0,0 +1,7 @@
+AnalyticsManager = require "./AnalyticsManager"
+
+module.exports = AnalyticsController =
+	recordEvent: (req, res, next) ->
+		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
+			return next(error) if error?
+			res.send 204

+ 43 - 0
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -0,0 +1,43 @@
+Settings = require "settings-sharelatex"
+logger = require "logger-sharelatex"
+_ = require "underscore"
+
+if !Settings.analytics?.postgres?
+	module.exports =
+		recordEvent: (user_id, event, segmentation, callback = () ->) ->
+			logger.log {user_id, event, segmentation}, "no event tracking configured, logging event"
+			callback()
+else
+	Sequelize = require "sequelize"
+	options = _.extend {logging:false}, Settings.analytics.postgres
+
+	sequelize = new Sequelize(
+		Settings.analytics.postgres.database,
+		Settings.analytics.postgres.username,
+		Settings.analytics.postgres.password,
+		options
+	)
+	
+	Event = sequelize.define("Event", {
+		user_id: Sequelize.STRING,
+		event: Sequelize.STRING,
+		segmentation: Sequelize.JSONB
+	})
+
+	module.exports =
+		recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
+			if user_id? and typeof(user_id) != "string"
+				user_id = user_id.toString()
+			if user_id == Settings.smokeTest?.userId
+				# Don't record smoke tests analytics
+				return callback()
+			Event
+				.create({ user_id, event, segmentation })
+				.then(
+					(result) -> callback(),
+					(error) ->
+						logger.err {err: error, user_id, event, segmentation}, "error recording analytics event"
+						callback(error)
+				)
+			
+		sync: () -> sequelize.sync()

+ 2 - 0
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -10,6 +10,7 @@ Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
 UserSessionsManager = require("../User/UserSessionsManager")
+Analytics = require "../Analytics/AnalyticsManager"
 
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
@@ -37,6 +38,7 @@ module.exports = AuthenticationController =
 						return next(error) if error?
 						req.session.justLoggedIn = true
 						logger.log email: email, user_id: user._id.toString(), "successful log in"
+						Analytics.recordEvent user._id, "user-logged-in"
 						res.json redir: redir
 				else
 					AuthenticationController._recordFailedLogin()

+ 1 - 0
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -147,6 +147,7 @@ module.exports = ClsiManager =
 								timeout: options.timeout
 								imageName: project.imageName
 								draft: !!options.draft
+								check: options.check
 							rootResourcePath: rootResourcePath
 							resources: resources
 					}

+ 2 - 0
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -29,6 +29,8 @@ module.exports = CompileController =
 				options.compiler = req.body.compiler
 			if req.body?.draft
 				options.draft = req.body.draft
+			if req.body?.check
+				options.check = if req.body.check is "validate" then "validate" else undefined
 			logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
 			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
 				return next(error) if error?

+ 7 - 7
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -4,7 +4,7 @@ settings = require("settings-sharelatex")
 ProjectGetter = require "../Project/ProjectGetter"
 UserGetter = require "../User/UserGetter"
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-U = require('underscore')
+_ = require('underscore')
 Async = require('async')
 
 oneMinInMs = 60 * 1000
@@ -22,24 +22,24 @@ module.exports = ReferencesHandler =
 	_findBibFileIds: (project) ->
 		ids = []
 		_process = (folder) ->
-			(folder.fileRefs or []).forEach (file) ->
+			_.each (folder.fileRefs or []), (file) ->
 				if file?.name?.match(/^.*\.bib$/)
 					ids.push(file._id)
-			(folder.folders or []).forEach (folder) ->
+			_.each (folder.folders or []), (folder) ->
 				_process(folder)
-		(project.rootFolder or []).forEach (rootFolder) ->
+		_.each (project.rootFolder or []), (rootFolder) ->
 			_process(rootFolder)
 		return ids
 
 	_findBibDocIds: (project) ->
 		ids = []
 		_process = (folder) ->
-			(folder.docs or []).forEach (doc) ->
+			_.each (folder.docs or []), (doc) ->
 				if doc?.name?.match(/^.*\.bib$/)
 					ids.push(doc._id)
-			(folder.folders or []).forEach (folder) ->
+			_.each (folder.folders or []), (folder) ->
 				_process(folder)
-		(project.rootFolder or []).forEach (rootFolder) ->
+		_.each (project.rootFolder or []), (rootFolder) ->
 			_process(rootFolder)
 		return ids
 

+ 1 - 1
services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee

@@ -30,5 +30,5 @@ module.exports =
 		multi.get buildKey(token)
 		multi.del buildKey(token)
 		multi.exec (err, results)->
-			callback err, results[0]
+			callback err, results?[0]
 

+ 2 - 2
services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee

@@ -27,8 +27,8 @@ module.exports = ProjectUploadController =
 	
 	uploadFile: (req, res, next) ->
 		timer = new metrics.Timer("file-upload")
-		name = req.files.qqfile.originalname
-		path = req.files.qqfile.path
+		name = req.files.qqfile?.originalname
+		path = req.files.qqfile?.path
 		project_id   = req.params.Project_id
 		folder_id    = req.query.folder_id
 		if !name? or name.length == 0 or name.length > 150

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

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 crypto = require("crypto")
 EmailHandler = require("../Email/EmailHandler")
 OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+Analytics = require "../Analytics/AnalyticsManager"
 settings = require "settings-sharelatex"
 
 module.exports = UserRegistrationHandler =
@@ -62,6 +63,7 @@ module.exports = UserRegistrationHandler =
 						cb() #this can be slow, just fire it off
 				], (err)->
 					logger.log user: user, "registered"
+					Analytics.recordEvent user._id, "user-registered"
 					callback(err, user)
 	
 	registerNewUserAndSendActivationEmail: (email, callback = (error, user, setNewPasswordUrl) ->) ->

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

@@ -38,6 +38,7 @@ ContactRouter = require("./Features/Contacts/ContactRouter")
 ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
+AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -68,7 +69,8 @@ module.exports = class Router
 		StaticPagesRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
-
+		AnalyticsRouter.apply(webRouter, apiRouter)
+		
 		Modules.applyRouter(webRouter, apiRouter)
 
 
@@ -280,4 +282,4 @@ module.exports = class Router
 			metrics.inc("client-side-error")
 			res.sendStatus(204)
 
-		webRouter.get '*', ErrorController.notFound
+		webRouter.get '*', ErrorController.notFound

+ 0 - 70
services/web/app/views/layout.jade

@@ -46,76 +46,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 		- else
 			script(type='text/javascript').
 				window.ga = function() { console.log("would send to GA", arguments) };
-
-		// Countly Analytics
-		if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
-			script(type="text/javascript").
-				var Countly = Countly || {};
-				Countly.q = Countly.q || [];
-				Countly.app_key = '#{settings.analytics.countly.token}';
-				Countly.url = '#{settings.analytics.countly.server}';
-				!{ session.user ? 'Countly.device_id = "' + session.user._id + '";' : '' } 
-
-				(function() {
-					var cly = document.createElement('script'); cly.type = 'text/javascript';
-					cly.async = true;
-					//enter url of script here
-					cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
-					cly.onload = function(){Countly.init()};
-					var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
-				})();
-
-			script(type="text/javascript")
-				if (session && session.user)
-					- var name = session.user.first_name + (session.user.last_name ? ' ' + session.user.last_name : '');
-					| Countly.q.push(['user_details', { email: '#{session.user.email}', name: '#{name}' }]);
-
-				if (justRegistered)
-					| Countly.q.push(['add_event',{ key: 'user-registered' }]);
-
-				if (justLoggedIn)
-					| Countly.q.push(['add_event',{ key: 'user-logged-in' }]);
-
-				if (user && user.features) 
-					- featureFlagSet = false;
-
-					if user.features.hasOwnProperty('collaborators')
-						| Countly.q.push([ 'userData.set', 'features-collaborators', #{ user.features.collaborators } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('compileGroup')
-						| Countly.q.push([ 'userData.set', 'features-compileGroup', '#{ user.features.compileGroup }' ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('compileTimeout')
-						| Countly.q.push([ 'userData.set', 'features-compileTimeout', #{ user.features.compileTimeout } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('dropbox')
-						| Countly.q.push([ 'userData.set', 'features-dropbox', #{ user.features.dropbox } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('github')
-						| Countly.q.push([ 'userData.set', 'features-github', #{ user.features.github } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('references')
-						| Countly.q.push([ 'userData.set', 'features-references', #{ user.features.references } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('templates')
-						| Countly.q.push([ 'userData.set', 'features-templates', #{ user.features.templates } ]);
-						- featureFlagSet = true;
-
-					if user.features.hasOwnProperty('versioning')
-						| Countly.q.push([ 'userData.set', 'features-versioning', #{ user.features.versioning } ]);
-						- featureFlagSet = true;
-
-
-					if featureFlagSet
-						| Countly.q.push(['userData.save'])
-
-		// End countly Analytics
 				
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";

+ 1 - 1
services/web/app/views/project/editor/chat.jade

@@ -45,7 +45,7 @@ aside.chat(
 								mathjax,
 								ng-repeat="content in message.contents track by $index"
 							)
-								span(ng-bind-html="content | linky:'_blank'")
+								span(ng-bind-html="content | linky:'_blank' | wrapLongWords")
 
 	.new-message
 		textarea(

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

@@ -91,12 +91,14 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 			ng-if="permissions.admin",
 			tooltip="#{translate('share')}",
 			tooltip-placement="bottom",
+			ng-mouseenter="trackHover('share')"
 			ng-click="openShareProjectModal()",
 			ng-controller="ShareController"
 		)
 			i.fa.fa-fw.fa-group
 		a.btn.btn-full-height(
 			href,
+			ng-mouseenter="trackHover('track-changes')"
 			ng-click="toggleTrackChanges()",
 			ng-class="{ active: (ui.view == 'track-changes') }"
 			tooltip="#{translate('recent_changes')}",
@@ -108,6 +110,7 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 			tooltip="#{translate('chat')}",
 			tooltip-placement="bottom",
 			ng-class="{ active: ui.chatOpen }",
+			ng-mouseenter="trackHover('chat')"
 			ng-click="toggleChat()",
 			ng-controller="ChatButtonController",
 			ng-show="!anonymous"

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

@@ -25,7 +25,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				dropdown-toggle
 			)
 				span.caret
-			ul.dropdown-menu.dropdown-menu-right
+			ul.dropdown-menu.dropdown-menu-left
 				li.dropdown-header #{translate("compile_mode")}
 				li
 					a(href, ng-click="draft = false")
@@ -36,6 +36,13 @@ div.full-size.pdf(ng-controller="PdfController")
 						i.fa.fa-fw(ng-class="{'fa-check': draft}")
 						|  #{translate("fast")} 
 						span.subdued [draft]
+				if user.betaProgram
+					li.dropdown-header #{translate("file_checks")}
+					li
+						a(href, ng-click="recompile({check:true})")
+							i.fa.fa-fw()
+							|  #{translate("run_syntax_check")}
+							span.beta-feature-badge
 		a(
 			href
 			ng-click="stop()"
@@ -115,7 +122,8 @@ div.full-size.pdf(ng-controller="PdfController")
 					|  
 					span(ng-show="entry.file") {{ entry.file }}
 					span(ng-show="entry.line") , line {{ entry.line }}
-				p.entry-message(ng-show="entry.message") {{ entry.message }}
+				p.entry-message(ng-show="entry.message")
+					| {{ entry.type }} {{ entry.message }}
 				.card.card-hint(
 					ng-if="entry.humanReadableHint"
 					stop-propagation="click"
@@ -126,7 +134,7 @@ div.full-size.pdf(ng-controller="PdfController")
 						ng-show="entry.humanReadableHint",
 						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
 					.card-hint-actions.clearfix
-						.card-hint-ext-link(ng-if="wikiEnabled")
+						.card-hint-ext-link(ng-if="wikiEnabled && entry.extraInfoURL")
 							a(
 								ng-href="{{ entry.extraInfoURL }}",
 								ng-click="trackLogHintsLearnMore()"

+ 15 - 3
services/web/app/views/project/editor/track-changes.jade

@@ -143,15 +143,27 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 				navigate-highlights="true"
 			)
 		.diff-deleted.text-centered(
-			ng-show="trackChanges.diff.deleted"
+			ng-show="trackChanges.diff.deleted && !trackChanges.diff.restoreDeletedSuccess"
 		)
 			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ trackChanges.diff.doc.name }} "})}
-
 			p
 				a.btn.btn-primary.btn-lg(
 					href,
-					ng-click="restoreDeletedDoc()"
+					ng-click="restoreDeletedDoc()",
+					ng-disabled="trackChanges.diff.restoreInProgress"
 				) #{translate("restore")}
+
+		.diff-deleted.text-centered(
+			ng-show="trackChanges.diff.deleted && trackChanges.diff.restoreDeletedSuccess"
+		)
+			p.text-serif  #{translate("file_restored", {filename:"{{ trackChanges.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="trackChanges.diff.loading")
 			i.fa.fa-spin.fa-refresh
 			|   #{translate("loading")}...

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

@@ -106,8 +106,8 @@ module.exports = settings =
 			url: ""
 		# references:
 		# 	url: "http://localhost:3040"
-		# notifications:
-		# 	url: "http://localhost:3042" 
+		notifications:
+			url: "http://localhost:3042" 
 			
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"

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


+ 3 - 0
services/web/package.json

@@ -46,6 +46,8 @@
     "nodemailer-sendgrid-transport": "^0.2.0",
     "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
+    "pg": "^6.0.3",
+    "pg-hstore": "^2.3.2",
     "redback": "0.4.0",
     "redis": "0.10.1",
     "redis-sharelatex": "0.0.9",
@@ -53,6 +55,7 @@
     "requests": "^0.1.7",
     "rimraf": "2.2.6",
     "sanitizer": "0.1.1",
+    "sequelize": "^3.2.0",
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "sixpack-client": "^1.0.0",
     "temp": "^0.8.3",

+ 25 - 0
services/web/public/coffee/filters/wrapLongWords.coffee

@@ -0,0 +1,25 @@
+define [
+	"base"
+], (App) ->
+	DEF_MIN_LENGTH = 20
+
+	_decodeHTMLEntities = (str) ->
+		str.replace /&#(\d+);/g, (match, dec) ->
+    		String.fromCharCode dec;
+
+	_getWrappedWordsString = (baseStr, wrapperElName, minLength) ->
+		minLength = minLength || DEF_MIN_LENGTH
+		words = baseStr.split ' '
+
+		wordsWrapped = for word in words
+			if _decodeHTMLEntities(word).length >= minLength
+				"<#{wrapperElName} class=\"break-word\">#{word}</#{wrapperElName}>"
+			else
+				word
+
+		outputStr = wordsWrapped.join ' '
+
+
+	App.filter "wrapLongWords", () ->
+		(input, minLength) ->
+			_getWrappedWordsString input, "span", minLength

+ 11 - 3
services/web/public/coffee/ide.coffee

@@ -9,6 +9,7 @@ define [
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
 	"ide/references/ReferencesManager"
+	"ide/SafariScrollPatcher"
 	"ide/settings/index"
 	"ide/share/index"
 	"ide/chat/index"
@@ -40,6 +41,7 @@ define [
 	PdfManager
 	BinaryFilesManager
 	ReferencesManager
+	SafariScrollPatcher
 ) ->
 
 	App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
@@ -72,13 +74,16 @@ define [
 		# Tracking code.
 		$scope.$watch "ui.view", (newView, oldView) ->
 			if newView? and newView != "editor" and newView != "pdf"
-				event_tracking.sendCountlyOnce "ide-open-view-#{ newView }-once" 
+				event_tracking.sendMBOnce "ide-open-view-#{ newView }-once" 
 
 		$scope.$watch "ui.chatOpen", (isOpen) ->
-			event_tracking.sendCountlyOnce "ide-open-chat-once" if isOpen
+			event_tracking.sendMBOnce "ide-open-chat-once" if isOpen
 
 		$scope.$watch "ui.leftMenuShown", (isOpen) ->
-			event_tracking.sendCountlyOnce "ide-open-left-menu-once" if isOpen
+			event_tracking.sendMBOnce "ide-open-left-menu-once" if isOpen
+
+		$scope.trackHover = (feature) ->
+			event_tracking.sendMBOnce "ide-hover-#{feature}-once"
 		# End of tracking code.
 
 		window._ide = ide
@@ -135,6 +140,9 @@ define [
 		catch err
 			console.error err
 
+		if ide.browserIsSafari
+			ide.safariScrollPatcher = new SafariScrollPatcher($scope)
+
 		# User can append ?ft=somefeature to url to activate a feature toggle
 		ide.featureToggle = location?.search?.match(/^\?ft=(\w+)$/)?[1]
 

+ 78 - 0
services/web/public/coffee/ide/SafariScrollPatcher.coffee

@@ -0,0 +1,78 @@
+define [
+], () ->
+	class SafariScrollPatcher
+		constructor: ($scope) ->
+			@isOverAce = false # Flag to control if the pointer is over Ace.
+			@pdfDiv = null
+			@aceDiv = null
+
+			# Start listening to PDF wheel events when the pointer leaves the PDF region.
+			# P.S. This is the problem in a nutshell: although the pointer is elsewhere,
+			# wheel events keep being dispatched to the PDF.
+			@handlePdfDivMouseLeave = () =>
+				@pdfDiv.addEventListener "wheel", @dispatchToAce
+
+			# Stop listening to wheel events when the pointer enters the PDF region. If 
+			# the pointer is over the PDF, native behaviour is adequate. 
+			@handlePdfDivMouseEnter = () =>
+				@pdfDiv.removeEventListener "wheel", @dispatchToAce
+
+			# Set the "pointer over Ace" flag as false, when the mouse leaves its area.
+			@handleAceDivMouseLeave = () =>
+				@isOverAce = false
+
+			# Set the "pointer over Ace" flag as true, when the mouse enters its area.
+			@handleAceDivMouseEnter = () =>
+				@isOverAce = true
+
+			# Grab the elements (pdfDiv, aceDiv) and set the "hover" event listeners.
+			# If elements are already defined, clear existing event listeners and do
+			# the process again (grab elements, set listeners).	
+			@setListeners = () =>
+				@isOverAce = false
+
+				# If elements aren't null, remove existing listeners.
+				if @pdfDiv?
+					@pdfDiv.removeEventListener @handlePdfDivMouseLeave
+					@pdfDiv.removeEventListener @handlePdfDivMouseEnter
+
+				if @aceDiv?
+					@aceDiv.removeEventListener @handleAceDivMouseLeave
+					@aceDiv.removeEventListener @handleAceDivMouseEnter
+
+				# Grab elements.
+				@pdfDiv = document.querySelector ".pdfjs-viewer"	# Grab the PDF div.
+				@aceDiv = document.querySelector ".ace_content"		# Also the editor.
+
+				# Set hover-related listeners.
+				@pdfDiv.addEventListener "mouseleave", @handlePdfDivMouseLeave
+				@pdfDiv.addEventListener "mouseenter", @handlePdfDivMouseEnter
+				@aceDiv.addEventListener "mouseleave", @handleAceDivMouseLeave
+				@aceDiv.addEventListener "mouseenter", @handleAceDivMouseEnter
+
+			# Handler for wheel events on the PDF.
+			# If the pointer is over Ace, grab the event, prevent default behaviour
+			# and dispatch it to Ace.
+			@dispatchToAce = (e) =>
+				if @isOverAce
+					# If this is logged, the problem just happened: the event arrived
+					# here (the PDF wheel handler), but it should've gone to Ace.
+
+					# Small timeout - if we dispatch immediately, an exception is thrown.
+					window.setTimeout(() =>
+						# Dispatch the exact same event to Ace (this will keep values
+						# values e.g. `wheelDelta` consistent with user interaction).
+						@aceDiv.dispatchEvent e
+					, 1)
+
+					# Avoid scrolling the PDF, as we assume this was intended to the 
+					# editor.
+					e.preventDefault()
+
+			# "loaded" event is emitted from the pdfViewer controller $scope. This means
+			# that the previous PDF DOM element was destroyed and a new one is available,
+			# so we need to grab the elements and set the listeners again.
+			$scope.$on "loaded", () =>
+				@setListeners()
+
+

+ 1 - 0
services/web/public/coffee/ide/chat/index.coffee

@@ -3,4 +3,5 @@ define [
 	"ide/chat/controllers/ChatController"
 	"ide/chat/controllers/ChatMessageController"
 	"ide/chat/directives/mathjax"
+	"filters/wrapLongWords"
 ], () ->

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

@@ -46,7 +46,7 @@ define [
 
 			done = () =>
 				if options.gotoLine?
-					@$scope.$broadcast "editor:gotoLine", options.gotoLine
+					@$scope.$broadcast "editor:gotoLine", options.gotoLine, options.gotoColumn
 			
 			if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
 				@$scope.$apply () =>

+ 6 - 6
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee

@@ -23,10 +23,10 @@ define [], () ->
 				@storeCursorPosition(@editor.getSession())
 				@storeScrollTopPosition(@editor.getSession())
 
-			@$scope.$on "#{@$scope.name}:gotoLine", (editor, value) =>
-				if value?
+			@$scope.$on "#{@$scope.name}:gotoLine", (editor, line, column) =>
+				if line?
 					setTimeout () =>
-						@gotoLine(value)
+						@gotoLine(line, column)
 					, 10 # Hack: Must happen after @gotoStoredPosition
 
 		storeScrollTopPosition: (session) ->
@@ -53,6 +53,6 @@ define [], () ->
 			@editor.getSession().setScrollTop(pos.scrollTop or 0)
 			delete @ignoreCursorPositionChanges
 
-		gotoLine: (line) ->
-			@editor.gotoLine(line)
-			@editor.focus()
+		gotoLine: (line, column) ->
+			@editor.gotoLine(line, column)
+			@editor.focus()

+ 1 - 1
services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee

@@ -4,7 +4,7 @@ define [
 ], (App) ->
 	App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
 		$scope.openHotkeysModal = ->
-			event_tracking.sendCountly "ide-open-hotkeys-modal"
+			event_tracking.sendMB "ide-open-hotkeys-modal"
 
 			$modal.open {
 				templateUrl: "hotkeysModalTemplate"

+ 10 - 2
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee

@@ -3,7 +3,10 @@ define [
 	"ide/human-readable-logs/HumanReadableLogsRules"
 ], (LogParser, ruleset) ->
 	parse : (rawLog, options) ->
-		parsedLogEntries = LogParser.parse(rawLog, options)
+		if typeof rawLog is 'string'
+			parsedLogEntries = LogParser.parse(rawLog, options)
+		else
+			parsedLogEntries = rawLog
 
 		_getRule = (logMessage) ->
 			return rule for rule in ruleset when rule.regexToMatch.test logMessage
@@ -12,7 +15,12 @@ define [
 			ruleDetails = _getRule entry.message
 
 			if (ruleDetails?)
-				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
+				if ruleDetails.ruleId?
+					entry.ruleId = ruleDetails.ruleId
+				else if ruleDetails.regexToMatch?
+					entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1)
+				if ruleDetails.newMessage?
+					entry.message = entry.message.replace ruleDetails.regexToMatch, ruleDetails.newMessage
 				
 				entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
 				entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?

+ 15 - 0
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee

@@ -88,4 +88,19 @@ define -> [
 		humanReadableHint: """
 			You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
 		"""
+	,
+		ruleId: "hint_mismatched_environment"
+		regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/
+		newMessage: "Error: environment does not match \\begin{$1} ... \\end{$2}"
+		humanReadableHint: """
+			You have used \\begin{...} without a corresponding \\end{...}.
+		"""
+	,
+		ruleId: "hint_mismatched_brackets"
+		regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/
+		newMessage: "Error: brackets do not match, found '$2' instead of '$1'"
+		humanReadableHint: """
+			You have used an open bracket without a corresponding close bracket.
+		"""
+
 ]

+ 83 - 27
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -36,11 +36,11 @@ define [
 		$scope.logHintsNegFeedbackValues = logHintsFeedback.feedbackOpts
 		
 		$scope.trackLogHintsLearnMore = () ->
-			event_tracking.sendCountly "logs-hints-learn-more"
+			event_tracking.sendMB "logs-hints-learn-more"
 
 		trackLogHintsFeedback = (isPositive, hintId) ->
 			event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
-			event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
+			event_tracking.sendMB (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
 
 		$scope.trackLogHintsNegFeedbackDetails = (hintId, feedbackOpt, feedbackOtherVal) ->
 			logHintsFeedback.submitFeedback hintId, feedbackOpt, feedbackOtherVal
@@ -86,11 +86,15 @@ define [
 			return $http.post url, {
 				rootDoc_id: options.rootDocOverride_id or null
 				draft: $scope.draft
+				check: if options.check then "validate" else null
 				_csrf: window.csrfToken
 			}, {params: params}
 
 		parseCompileResponse = (response) ->		
 
+			# keep last url
+			last_pdf_url = $scope.pdf.url
+
 			# Reset everything
 			$scope.pdf.error      = false
 			$scope.pdf.timedout   = false
@@ -120,11 +124,23 @@ define [
 			if response.status == "timedout"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.timedout = true
-				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+				fetchLogs(fileByPath)
 			else if response.status == "terminated"
 				$scope.pdf.view = 'errors'
 				$scope.pdf.compileTerminated = true
-				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+				fetchLogs(fileByPath)
+			else if response.status in ["validation-fail", "validation-pass"]
+				$scope.pdf.view = 'pdf'
+				$scope.pdf.compileExited = true
+				$scope.pdf.url = last_pdf_url
+				$scope.shouldShowLogs = true
+				fetchLogs(fileByPath, { validation: true })
+			else if response.status == "exited"
+				$scope.pdf.view = 'pdf'
+				$scope.pdf.compileExited = true
+				$scope.pdf.url = last_pdf_url
+				$scope.shouldShowLogs = true
+				fetchLogs(fileByPath)
 			else if response.status == "autocompile-backoff"
 				$scope.pdf.view = 'uncompiled'
 			else if response.status == "project-too-large"
@@ -134,7 +150,7 @@ define [
 				$scope.pdf.view = 'errors'
 				$scope.pdf.failure = true
 				$scope.shouldShowLogs = true
-				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+				fetchLogs(fileByPath)
 			else if response.status == 'clsi-maintenance'
 				$scope.pdf.view = 'errors'
 				$scope.pdf.clsiMaintenance = true
@@ -165,7 +181,7 @@ define [
 				qs.popupDownload = true
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
 
-				fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+				fetchLogs(fileByPath)
 
 			IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
 			$scope.pdf.outputFiles = []
@@ -186,7 +202,13 @@ define [
 					}
 
 
-		fetchLogs = (logFile, blgFile) ->
+		fetchLogs = (fileByPath, options) ->
+
+			if options?.validation
+				chktexFile = fileByPath['output.chktex']
+			else
+				logFile = fileByPath['output.log']
+				blgFile = fileByPath['output.blg']
 
 			getFile = (name, file) ->
 				opts =
@@ -213,6 +235,8 @@ define [
 
 			accumulateResults = (newEntries) ->
 				for key in ['all', 'errors', 'warnings']
+					if newEntries.type?
+						entry.type = newEntries.type for entry in newEntries[key]
 					logEntries[key] = logEntries[key].concat newEntries[key]
 
 			# use the parsers for each file type
@@ -222,10 +246,24 @@ define [
 				all = [].concat errors, warnings, typesetting
 				accumulateResults {all, errors, warnings}
 
+			processChkTex = (log) ->
+				errors = []
+				warnings = []
+				for line in log.split("\n")
+					if m = line.match /^(\S+):(\d+):(\d+): (Error|Warning): (.*)/
+						result = { file:m[1], line:m[2], column:m[3], level:m[4].toLowerCase(), message: "#{m[4]}: #{m[5]}"}
+						if result.level is 'error'
+							errors.push result
+						else
+							warnings.push result
+				all = [].concat errors, warnings
+				logHints = HumanReadableLogs.parse {type: "Validation", all, errors, warnings}
+				accumulateResults logHints
+
 			processBiber = (log) ->
 				{errors, warnings} = BibLogParser.parse(log, {})
 				all = [].concat errors, warnings
-				accumulateResults {all, errors, warnings}
+				accumulateResults {type: "BibTeX", all, errors, warnings}
 
 			# output the results
 			handleError = () ->
@@ -248,19 +286,35 @@ define [
 							}
 
 			# retrieve the logfile and process it
-			response = getFile('output.log', logFile)
-				.success processLog
-				.error handleError
-
-			if blgFile?	# retrieve the blg file if present
-				response.success () ->
-					getFile('output.blg', blgFile)
-						# ignore errors in biber file
-						.success processBiber
-						# display the combined result
-						.then annotateFiles
-			else # otherwise just display the result
-				response.success annotateFiles
+			if logFile?
+				response = getFile('output.log', logFile)
+					.then	(response) -> processLog(response.data)
+
+				if blgFile?	# retrieve the blg file if present
+					response = response.then () ->
+						getFile('output.blg', blgFile)
+							.then(
+								(response) -> processBiber(response.data),
+								() ->	true # ignore errors in biber file
+							)
+
+			if response?
+				response.catch handleError
+			else
+				handleError()
+
+			if chktexFile?
+				getChkTex = () ->
+					getFile('output.chktex', chktexFile)
+						.then	(response) -> processChkTex(response.data)
+				# always retrieve the chktex file if present
+				if response?
+					response = response.then getChkTex, getChkTex
+				else
+					response = getChkTex()
+
+			# display the combined result
+			response.finally annotateFiles
 
 		getRootDocOverride_id = () ->
 			doc = ide.editorManager.getCurrentDocValue()
@@ -284,7 +338,7 @@ define [
 		$scope.recompile = (options = {}) ->
 			return if $scope.pdf.compiling
 
-			event_tracking.sendCountlySampled "editor-recompile-sampled", options
+			event_tracking.sendMBSampled "editor-recompile-sampled", options
 
 			$scope.pdf.compiling = true
 
@@ -333,7 +387,7 @@ define [
 
 		$scope.toggleLogs = () ->
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
-			event_tracking.sendCountlyOnce "ide-open-logs-once" if $scope.shouldShowLogs
+			event_tracking.sendMBOnce "ide-open-logs-once" if $scope.shouldShowLogs
 
 		$scope.showPdf = () ->
 			$scope.pdf.view = "pdf"
@@ -341,7 +395,7 @@ define [
 
 		$scope.toggleRawLog = () ->
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
-			event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
+			event_tracking.sendMB "logs-view-raw" if $scope.pdf.showRawLog
 
 		$scope.openClearCacheModal = () ->
 			modalInstance = $modal.open(
@@ -376,7 +430,7 @@ define [
 		$scope.startFreeTrial = (source) ->
 			ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
 
-			event_tracking.sendCountly "subscription-start-trial", { source }
+			event_tracking.sendMB "subscription-start-trial", { source }
 
 			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
 			$scope.startedFreeTrial = true
@@ -499,12 +553,14 @@ define [
 
 	App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
 		$scope.openInEditor = (entry) ->
-			event_tracking.sendCountlyOnce "logs-jump-to-location-once"
+			event_tracking.sendMBOnce "logs-jump-to-location-once"
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			return if !entity? or entity.type != "doc"
 			if entry.line?
 				line = entry.line
-			ide.editorManager.openDoc(entity, gotoLine: line)
+			if entry.column?
+				column = entry.column
+			ide.editorManager.openDoc(entity, gotoLine: line, gotoColumn: column)
 	]
 
 	App.controller 'ClearCacheModalController', ["$scope", "$modalInstance", ($scope, $modalInstance) ->

+ 3 - 3
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -8,7 +8,7 @@ define [
 				for key in Object.keys(data)
 					changedSetting = key
 					changedSettingVal = data[key]
-					event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
+					event_tracking.sendMB "setting-changed", { changedSetting, changedSettingVal }
 				# End of tracking code.
 				
 				data._csrf = window.csrfToken
@@ -20,7 +20,7 @@ define [
 				for key in Object.keys(data)
 					changedSetting = key
 					changedSettingVal = data[key]
-					event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
+					event_tracking.sendMB "project-setting-changed", { changedSetting, changedSettingVal}
 				# End of tracking code.
 				
 				data._csrf = window.csrfToken
@@ -32,7 +32,7 @@ define [
 				for key in Object.keys(data)
 					changedSetting = key
 					changedSettingVal = data[key]
-					event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
+					event_tracking.sendMB "project-admin-setting-changed", { changedSetting, changedSettingVal }
 				# End of tracking code.
 				
 				data._csrf = window.csrfToken

+ 1 - 1
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -4,7 +4,7 @@ define [
 	App.controller "ShareController", ["$scope", "$modal", "ide", "projectInvites", "projectMembers", "event_tracking",
 	($scope, $modal, ide, projectInvites, projectMembers, event_tracking) ->
 			$scope.openShareProjectModal = () ->
-				event_tracking.sendCountlyOnce "ide-open-share-modal-once"
+				event_tracking.sendMBOnce "ide-open-share-modal-once"
 
 				$modal.open(
 					templateUrl: "shareProjectModalTemplate"

+ 3 - 0
services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee

@@ -117,6 +117,9 @@ define [
 						diff.error = true
 			else
 				diff.deleted = true
+				diff.restoreInProgress = false
+				diff.restoreDeletedSuccess = false
+				diff.restoredDocNewId = null
 
 		restoreDeletedDoc: (doc) ->
 			url = "/project/#{@$scope.project_id}/doc/#{doc.id}/restore"

+ 17 - 5
services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee

@@ -1,13 +1,21 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "TrackChangesDiffController", ($scope, $modal, ide) ->
+	App.controller "TrackChangesDiffController", ($scope, $modal, ide, event_tracking) ->
 		$scope.restoreDeletedDoc = () ->
-			ide.trackChangesManager.restoreDeletedDoc(
-				$scope.trackChanges.diff.doc
-			)
+			event_tracking.sendMB "track-changes-restore-deleted"
+			$scope.trackChanges.diff.restoreInProgress = true
+			ide.trackChangesManager
+				.restoreDeletedDoc(
+					$scope.trackChanges.diff.doc
+				)
+				.success (response) ->
+					$scope.trackChanges.diff.restoredDocNewId = response.doc_id
+					$scope.trackChanges.diff.restoreInProgress = false
+					$scope.trackChanges.diff.restoreDeletedSuccess = true
 
 		$scope.openRestoreDiffModal = () ->
+			event_tracking.sendMB "track-changes-restore-modal"
 			$modal.open {
 				templateUrl: "trackChangesRestoreDiffModalTemplate"
 				controller: "TrackChangesRestoreDiffModalController"
@@ -15,13 +23,17 @@ define [
 					diff: () -> $scope.trackChanges.diff
 			}
 
-	App.controller "TrackChangesRestoreDiffModalController", ($scope, $modalInstance, diff, ide) ->
+		$scope.backToEditorAfterRestore = () ->
+			ide.editorManager.openDoc({ id: $scope.trackChanges.diff.restoredDocNewId })
+
+	App.controller "TrackChangesRestoreDiffModalController", ($scope, $modalInstance, diff, ide, event_tracking) ->
 		$scope.state =
 			inflight: false
 
 		$scope.diff = diff
 
 		$scope.restore = () ->
+			event_tracking.sendMB "track-changes-restored"
 			$scope.state.inflight = true
 			ide.trackChangesManager
 				.restoreDiff(diff)

+ 2 - 1
services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee

@@ -78,7 +78,7 @@ define [
 			$scope.recalculateSelectedUpdates()
 	]
 
-	App.controller "TrackChangesListItemController", ["$scope", ($scope) ->
+	App.controller "TrackChangesListItemController", ["$scope", "event_tracking", ($scope, event_tracking) ->
 		$scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) ->
 			if selectedFrom
 				for update in $scope.trackChanges.updates
@@ -92,6 +92,7 @@ define [
 				$scope.recalculateSelectedUpdates()
 
 		$scope.select = () ->
+			event_tracking.sendMB "track-changes-view-change"
 			$scope.update.selectedTo = true
 			$scope.update.selectedFrom = true
 

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

@@ -6,7 +6,7 @@ define [
 		$scope.buttonClass = "btn-primary"
 
 		$scope.startFreeTrial = (source, couponCode) ->
-			event_tracking.sendCountly "subscription-start-trial", { source }
+			event_tracking.sendMB "subscription-start-trial", { source }
 
 			w = window.open()
 			sixpack.convert "track-changes-discount", ->

+ 2 - 2
services/web/public/coffee/main/contact-us.coffee

@@ -24,7 +24,7 @@ define [
 					url :"/learn/kb/#{page_underscored}"
 					name : hit._highlightResult.pageName.value
 
-			event_tracking.sendCountly "contact-form-suggestions-shown" if results.hits.length
+			event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
 
 			$scope.$applyAsync () -> 
 				$scope.suggestions = suggestions
@@ -60,7 +60,7 @@ define [
 				$scope.suggestions = [];
 
 		$scope.clickSuggestionLink = (url) ->
-			event_tracking.sendCountly "contact-form-suggestions-clicked", { url }
+			event_tracking.sendMB "contact-form-suggestions-clicked", { url }
 
 		$scope.close = () ->
 			$modalInstance.close()

+ 20 - 10
services/web/public/coffee/main/event.coffee

@@ -2,9 +2,14 @@ define [
 	"base"
 	"modules/localStorage"
 ], (App) ->
-	CACHE_KEY = "countlyEvents"
+	CACHE_KEY = "mbEvents"
 
-	App.factory "event_tracking", (localStorage) ->
+	send = (category, action, attributes = {})->
+		ga('send', 'event', category, action)
+		event_name = "#{action}-#{category}"
+		Intercom?("trackEvent", event_name, attributes)
+		
+	App.factory "event_tracking", ($http, localStorage) ->
 		_getEventCache = () -> 
 			eventCache = localStorage CACHE_KEY
 
@@ -29,18 +34,23 @@ define [
 			send: (category, action, label, value)->
 				ga('send', 'event', category, action, label, value)
 
-			sendCountly: (key, segmentation) ->
-				eventData = { key }
-				eventData.segmentation = segmentation if segmentation?				
-				Countly?.q.push([ "add_event", eventData ])
+			sendMB: (key, segmentation = {}) ->
+				$http {
+					url: "/event/#{key}",
+					method: "POST",
+					data: segmentation
+					headers: {
+						"X-CSRF-Token": window.csrfToken
+					}
+				}
 
-			sendCountlySampled: (key, segmentation) ->
-				@sendCountly key, segmentation if Math.random() < .01 
+			sendMBSampled: (key, segmentation) ->
+				@sendMB key, segmentation if Math.random() < .01 
 
-			sendCountlyOnce: (key, segmentation) ->
+			sendMBOnce: (key, segmentation) ->
 				if ! _eventInCache(key)
 					_addEventToCache(key)
-					@sendCountly key, segmentation
+					@sendMB key, segmentation
 		}
 
 	# App.directive "countlyTrack", () ->

+ 3 - 3
services/web/public/coffee/main/new-subscription.coffee

@@ -51,7 +51,7 @@ define [
 			.done()
 
 		pricing.on "change", =>
-			event_tracking.sendCountly "subscription-form", { plan : pricing.items.plan.code }
+			event_tracking.sendMB "subscription-form", { plan : pricing.items.plan.code }
 
 			$scope.planName = pricing.items.plan.name
 			$scope.price = pricing.price
@@ -125,7 +125,7 @@ define [
 							state:       $scope.data.state
 							postal_code: $scope.data.postal_code
 				
-				event_tracking.sendCountly "subscription-form-submitted", { 
+				event_tracking.sendMB "subscription-form-submitted", { 
 					currencyCode	: postData.subscriptionDetails.currencyCode,
 					plan_code		: postData.subscriptionDetails.plan_code,
 					coupon_code		: postData.subscriptionDetails.coupon_code,
@@ -135,7 +135,7 @@ define [
 				$http.post("/user/subscription/create", postData)
 					.success (data, status, headers)->
 						sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)->
-							event_tracking.sendCountly "subscription-submission-success"
+							event_tracking.sendMB "subscription-submission-success"
 							window.location.href = "/user/subscription/thank-you"
 					.error (data, status, headers)->
 						$scope.processing = false

BIN
services/web/public/img/crests/harvard.gif


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

@@ -129,6 +129,10 @@
 	}
 }
 
+.break-word {
+	word-break: break-all;
+}
+
 .editor-dark {
 	.chat {
 		.new-message {

+ 4 - 0
services/web/public/stylesheets/app/editor/track-changes.less

@@ -267,6 +267,10 @@
 	}
 }
 
+.diff-deleted {
+	padding-top: 15px;
+}
+
 .editor-dark {
 	#trackChanges {
 		aside.change-list {

+ 6 - 0
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -19,6 +19,7 @@ describe "AuthenticationController", ->
 			"../../infrastructure/Metrics": @Metrics = { inc: sinon.stub() }
 			"../Security/LoginRateLimiter": @LoginRateLimiter = { processLoginRequest:sinon.stub(), recordSuccessfulLogin:sinon.stub() }
 			"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}
+			"../Analytics/AnalyticsManager": @AnalyticsManager = { recordEvent: sinon.stub() }
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"settings-sharelatex": {}
 			"../User/UserSessionsManager": @UserSessionsManager =
@@ -100,6 +101,11 @@ describe "AuthenticationController", ->
 					.calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in")
 					.should.equal true
 
+			it "should track the login event", ->
+				@AnalyticsManager.recordEvent
+					.calledWith(@user._id, "user-logged-in")
+					.should.equal true
+
 
 		describe 'when the user is not authenticated', ->
 			beforeEach ->

+ 1 - 0
services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee

@@ -174,6 +174,7 @@ describe "ClsiManager", ->
 							timeout : 100
 							imageName: @image
 							draft: false
+							check: undefined
 						rootResourcePath: "main.tex"
 						resources: [{
 							path:    "main.tex"

+ 5 - 0
services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee

@@ -322,6 +322,11 @@ describe 'ReferencesHandler', ->
 			result = @handler._findBibDocIds(@fakeProject)
 			expect(result).to.deep.equal @expectedIds
 
+		it 'should not error with a non array of folders from dirty data', ->
+			@fakeProject.rootFolder[0].folders[0].folders = {}
+			result = @handler._findBibDocIds(@fakeProject)
+			expect(result).to.deep.equal @expectedIds
+
 	describe '_findBibFileIds', ->
 
 		beforeEach ->

+ 8 - 0
services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee

@@ -33,6 +33,7 @@ describe "UserRegistrationHandler", ->
 			"crypto": @crypto = {}
 			"../Email/EmailHandler": @EmailHandler
 			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler
+			"../Analytics/AnalyticsManager": @AnalyticsManager = { recordEvent: sinon.stub() }
 			"settings-sharelatex": @settings = {siteUrl: "http://sl.example.com"}
 
 		@passingRequest = {email:"something@email.com", password:"123"}
@@ -132,6 +133,13 @@ describe "UserRegistrationHandler", ->
 					@NewsLetterManager.subscribe.calledWith(@user).should.equal true
 					done()
 
+			it "should track the registration event", (done)->
+				@handler.registerNewUser @passingRequest, (err)=>
+					@AnalyticsManager.recordEvent
+						.calledWith(@user._id, "user-registered")
+						.should.equal true
+					done()
+
 
 		it "should call the ReferalAllocator", (done)->
 			done()

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