diff --git a/services/web/.gitignore b/services/web/.gitignore index a48481690a..5ab3a0e3f6 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -39,6 +39,7 @@ data/* app.js app/js/* test/unit/js/* +test/unit_frontend/js/* test/smoke/js/* test/acceptance/js/* cookies.txt @@ -67,6 +68,11 @@ public/minjs/ Gemfile.lock +public/stylesheets/ol-style.*.css +public/stylesheets/style.*.css +public/js/libs/require*.js + + *.swp .DS_Store diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index c49add4a91..a0000360b3 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -205,12 +205,12 @@ module.exports = (grunt) -> modules: [ { name: "main", - exclude: ["libs"] + exclude: ["libraries"] }, { name: "ide", - exclude: ["libs", "pdfjs-dist/build/pdf"] - }, { - name: "libs" + exclude: ["pdfjs-dist/build/pdf", "libraries"] + },{ + name: "libraries" },{ name: "ace/mode-latex" },{ diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 6421296330..152d980787 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -60,7 +60,7 @@ pipeline { sh 'git config --global core.logallrefupdates false' sh 'mv app/views/external/robots.txt public/robots.txt' sh 'mv app/views/external/googlebdb0f8f7f4a17241.html public/googlebdb0f8f7f4a17241.html' - sh 'npm install' + sh 'npm --quiet install' sh 'npm rebuild' // It's too easy to end up shrinkwrapping to an outdated version of translations. // Ensure translations are always latest, regardless of shrinkwrap @@ -71,16 +71,9 @@ pipeline { } } - stage('Unit Tests') { + stage('Test') { steps { - sh 'make clean install' // Removes js files, so do before compile - sh 'make test_unit MOCHA_ARGS="--reporter=tap"' - } - } - - stage('Acceptance Tests') { - steps { - sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"' + sh 'make ci' } } @@ -155,6 +148,10 @@ pipeline { } post { + always { + sh 'make ci_clean' + } + failure { mail(from: "${EMAIL_ALERT_FROM}", to: "${EMAIL_ALERT_TO}", diff --git a/services/web/Makefile b/services/web/Makefile index 98695a8c1f..5db188e2b7 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -16,9 +16,10 @@ add_dev: docker-shared.yml $(NPM) install --save-dev ${P} install: docker-shared.yml + bin/generate_volumes_file $(NPM) install -clean: +clean: ci_clean rm -f app.js rm -rf app/js rm -rf test/unit/js @@ -30,9 +31,8 @@ clean: rm -rf $$dir/test/unit/js; \ rm -rf $$dir/test/acceptance/js; \ done - # Regenerate docker-shared.yml - not stictly a 'clean', - # but lets `make clean install` work nicely - bin/generate_volumes_file + +ci_clean: # Deletes node_modules volume docker-compose down --volumes @@ -40,11 +40,14 @@ clean: docker-shared.yml: bin/generate_volumes_file -test: test_unit test_acceptance +test: test_unit test_frontend test_acceptance test_unit: docker-shared.yml docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:unit -- ${MOCHA_ARGS} +test_frontend: docker-shared.yml + docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:frontend -- ${MOCHA_ARGS} + test_acceptance: test_acceptance_app test_acceptance_modules test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service @@ -71,7 +74,11 @@ test_acceptance_modules: docker-shared.yml test_acceptance_module: docker-shared.yml cd $(MODULE) && make test_acceptance +ci: + MOCHA_ARGS="--reporter tap" \ + $(MAKE) install test + .PHONY: - all add install update test test_unit test_acceptance \ + all add install update test test_unit test_frontend test_acceptance \ test_acceptance_start_service test_acceptance_stop_service \ - test_acceptance_run + test_acceptance_run ci ci_clean diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index f7dd9f3e17..e18b8c8123 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -205,10 +205,10 @@ module.exports = DocumentUpdaterHandler = callback new Error("doc updater returned a non-success status code: #{res.statusCode}") updateProjectStructure : (project_id, userId, changes, callback = (error) ->)-> - return callback() if !settings.apis.project_history?.enabled + return callback() if !settings.apis.project_history?.sendProjectStructureOps - docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', changes.oldDocs, changes.newDocs) - fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', changes.oldFiles, changes.newFiles) + docUpdates = DocumentUpdaterHandler._getUpdates('doc', changes.oldDocs, changes.newDocs) + fileUpdates = DocumentUpdaterHandler._getUpdates('file', changes.oldFiles, changes.newFiles) timer = new metrics.Timer("set-document") url = "#{settings.apis.documentupdater.url}/project/#{project_id}" @@ -230,7 +230,7 @@ module.exports = DocumentUpdaterHandler = logger.error {project_id, url}, "doc updater returned a non-success status code: #{res.statusCode}" callback new Error("doc updater returned a non-success status code: #{res.statusCode}") - _getRenameUpdates: (entityType, oldEntities, newEntities) -> + _getUpdates: (entityType, oldEntities, newEntities) -> oldEntities ||= [] newEntities ||= [] updates = [] @@ -255,6 +255,16 @@ module.exports = DocumentUpdaterHandler = pathname: oldEntity.path newPathname: newEntity.path + for id, oldEntity of oldEntitiesHash + newEntity = newEntitiesHash[id] + + if !newEntity? + # entity deleted + updates.push + id: id + pathname: oldEntity.path + newPathname: '' + updates PENDINGUPDATESKEY = "PendingUpdates" diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index f66094f9e8..532f515a10 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -105,19 +105,19 @@ module.exports = EditorController = async.series jobs, (err)-> callback err, newFolders, lastFolder - deleteEntity : (project_id, entity_id, entityType, source, callback)-> + deleteEntity : (project_id, entity_id, entityType, source, userId, callback)-> LockManager.getLock project_id, (err)-> if err? logger.err err:err, project_id:project_id, "could not get lock to deleteEntity" return callback(err) - EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, (err)-> + EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, userId, (err)-> LockManager.releaseLock project_id, ()-> callback(err) - deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)-> + deleteEntityWithoutLock: (project_id, entity_id, entityType, source, userId, callback)-> logger.log {project_id, entity_id, entityType, source}, "start delete process of entity" Metrics.inc "editor.delete-entity" - ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)-> + ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, userId, (err)-> if err? logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity" return callback(err) diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index cbc296b69f..16b1a79e31 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -147,6 +147,7 @@ module.exports = EditorHttpController = project_id = req.params.Project_id entity_id = req.params.entity_id entity_type = req.params.entity_type - EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) -> + user_id = AuthenticationController.getLoggedInUserId(req) + EditorController.deleteEntity project_id, entity_id, entity_type, "editor", user_id, (error) -> return next(error) if error? res.sendStatus 204 diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index 2e46dd692d..d88b243418 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/services/web/app/coffee/Features/Errors/Errors.coffee @@ -26,8 +26,16 @@ InvalidNameError = (message) -> return error InvalidNameError.prototype.__proto__ = Error.prototype +UnsupportedFileTypeError = (message) -> + error = new Error(message) + error.name = "UnsupportedFileTypeError" + error.__proto__ = UnsupportedFileTypeError.prototype + return error +UnsupportedFileTypeError.prototype.__proto___ = Error.prototype + module.exports = Errors = NotFoundError: NotFoundError ServiceNotConfiguredError: ServiceNotConfiguredError TooManyRequestsError: TooManyRequestsError InvalidNameError: InvalidNameError + UnsupportedFileTypeError: UnsupportedFileTypeError diff --git a/services/web/app/coffee/Features/History/HistoryController.coffee b/services/web/app/coffee/Features/History/HistoryController.coffee index 70e0828160..76476c8248 100644 --- a/services/web/app/coffee/Features/History/HistoryController.coffee +++ b/services/web/app/coffee/Features/History/HistoryController.coffee @@ -5,35 +5,13 @@ AuthenticationController = require "../Authentication/AuthenticationController" ProjectDetailsHandler = require "../Project/ProjectDetailsHandler" module.exports = HistoryController = - initializeProject: (callback = (error, history_id) ->) -> - return callback() if !settings.apis.project_history?.enabled - request.post { - url: "#{settings.apis.project_history.url}/project" - }, (error, res, body)-> - return callback(error) if error? - - if res.statusCode >= 200 and res.statusCode < 300 - try - project = JSON.parse(body) - catch error - return callback(error) - - overleaf_id = project?.project?.id - if !overleaf_id - error = new Error("project-history did not provide an id", project) - return callback(error) - - callback null, { overleaf_id } - else - error = new Error("project-history returned a non-success status code: #{res.statusCode}") - callback error - selectHistoryApi: (req, res, next = (error) ->) -> project_id = req.params?.Project_id # find out which type of history service this project uses ProjectDetailsHandler.getDetails project_id, (err, project) -> return next(err) if err? - if project?.overleaf?.history?.display + history = project.overleaf?.history + if history?.id? and history?.display req.useProjectHistory = true else req.useProjectHistory = false @@ -58,7 +36,7 @@ module.exports = HistoryController = buildHistoryServiceUrl: (useProjectHistory) -> # choose a history service, either document-level (trackchanges) # or project-level (project_history) - if settings.apis.project_history?.enabled && useProjectHistory + if useProjectHistory return settings.apis.project_history.url else return settings.apis.trackchanges.url diff --git a/services/web/app/coffee/Features/History/HistoryManager.coffee b/services/web/app/coffee/Features/History/HistoryManager.coffee new file mode 100644 index 0000000000..75f552c907 --- /dev/null +++ b/services/web/app/coffee/Features/History/HistoryManager.coffee @@ -0,0 +1,26 @@ +request = require "request" +settings = require "settings-sharelatex" + +module.exports = HistoryManager = + initializeProject: (callback = (error, history_id) ->) -> + return callback() if !settings.apis.project_history?.initializeHistoryForNewProjects + request.post { + url: "#{settings.apis.project_history.url}/project" + }, (error, res, body)-> + return callback(error) if error? + + if res.statusCode >= 200 and res.statusCode < 300 + try + project = JSON.parse(body) + catch error + return callback(error) + + overleaf_id = project?.project?.id + if !overleaf_id + error = new Error("project-history did not provide an id", project) + return callback(error) + + callback null, { overleaf_id } + else + error = new Error("project-history returned a non-success status code: #{res.statusCode}") + callback error \ No newline at end of file diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index c7fad83bf6..fb27611bca 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -216,7 +216,7 @@ module.exports = ProjectController = project: (cb)-> ProjectGetter.getProject( project_id, - { name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1 }, + { name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, 'overleaf.history.display': 1 }, cb ) user: (cb)-> @@ -253,7 +253,7 @@ module.exports = ProjectController = # Extract data from user's ObjectId timestamp = parseInt(user_id.toString().substring(0, 8), 16) - rolloutPercentage = 40 # Percentage of users to roll out to + rolloutPercentage = 60 # Percentage of users to roll out to if !ProjectController._isInPercentageRollout('autocompile', user_id, rolloutPercentage) # Don't show if user is not part of roll out return cb(null, { enabled: false, showOnboarding: false }) @@ -351,6 +351,7 @@ module.exports = ProjectController = themes: THEME_LIST maxDocLength: Settings.max_doc_length showLinkSharingOnboarding: !!results.couldShowLinkSharingOnboarding + useV2History: !!project.overleaf?.history?.display timer.done() _buildProjectList: (allProjects, v1Projects = [])-> diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index cd78cddc09..ceddc2ad61 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -7,7 +7,7 @@ Project = require('../../models/Project').Project Folder = require('../../models/Folder').Folder ProjectEntityHandler = require('./ProjectEntityHandler') ProjectDetailsHandler = require('./ProjectDetailsHandler') -HistoryController = require('../History/HistoryController') +HistoryManager = require('../History/HistoryManager') User = require('../../models/User').User fs = require('fs') Path = require "path" @@ -27,7 +27,7 @@ module.exports = ProjectCreationHandler = if projectHistoryId? ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, callback else - HistoryController.initializeProject (error, history) -> + HistoryManager.initializeProject (error, history) -> return callback(error) if error? ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, callback diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index 815e31eb27..d4640db966 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -21,7 +21,7 @@ module.exports = ProjectDuplicator = if !doc?._id? return callback() content = docContents[doc._id.toString()] - projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)-> + projectEntityHandler.addDoc newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)-> if err? logger.err err:err, "error copying doc" return callback(err) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 446786fa57..30e573ea42 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -149,14 +149,35 @@ module.exports = ProjectEntityHandler = else DocstoreManager.getDoc project_id, doc_id, options, callback - addDoc: (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> - ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> + addDoc: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> + ProjectEntityHandler.addDocWithoutUpdatingHistory project_or_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path) -> + return callback(error) if error? + newDocs = [ + doc: doc + path: path + docLines: docLines.join('\n') + ] + project_id = project_or_id._id or project_or_id + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) -> + return callback(error) if error? + callback null, doc, folder_id + + addDocWithoutUpdatingHistory: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> + # This method should never be called directly, except when importing a project + # from Overleaf. It skips sending updates to the project history, which will break + # the history unless you are making sure it is updated in some other way. + getProject = (cb) -> + if project_or_id._id? # project + return cb(null, project_or_id) + else # id + return ProjectGetter.getProjectWithOnlyFolders project_or_id, cb + getProject (error, project) -> if err? logger.err project_id:project_id, err:err, "error getting project for add doc" return callback(err) - ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, userId, callback + ProjectEntityHandler._addDocWithProject project, folder_id, docName, docLines, userId, callback - addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> + _addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id, path) ->)=> project_id = project._id logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project" confirmFolder project, folder_id, (folder_id)=> @@ -176,14 +197,7 @@ module.exports = ProjectEntityHandler = rev: 0 }, (err) -> return callback(err) if err? - newDocs = [ - doc: doc - path: result?.path?.fileSystem - docLines: docLines.join('\n') - ] - DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) -> - return callback(error) if error? - callback null, doc, folder_id + callback(null, doc, folder_id, result?.path?.fileSystem) restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) -> # getDoc will return the deleted doc's lines, but we don't actually remove @@ -192,37 +206,37 @@ module.exports = ProjectEntityHandler = return callback(error) if error? ProjectEntityHandler.addDoc project_id, null, name, lines, callback - addFile: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)-> + addFileWithoutUpdatingHistory: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id, path, fileStoreUrl) ->)-> ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) -> if err? logger.err project_id:project_id, err:err, "error getting project for add file" return callback(err) - ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, userId, callback - - addFileWithProject: (project, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)-> - project_id = project._id - logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file" - return callback(err) if err? - confirmFolder project, folder_id, (folder_id)-> - fileRef = new File name : fileName - FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)-> - if err? - logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3" - return callback(err) - ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=> + logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file" + return callback(err) if err? + confirmFolder project, folder_id, (folder_id)-> + fileRef = new File name : fileName + FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)-> if err? - logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project" + logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3" return callback(err) - tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) -> - return callback(err) if err? - newFiles = [ - file: fileRef - path: result?.path?.fileSystem - url: fileStoreUrl - ] - DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) -> - return callback(error) if error? - callback null, fileRef, folder_id + ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=> + if err? + logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project" + return callback(err) + tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) -> + return callback(err) if err? + callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl) + + addFile: (project_id, folder_id, fileName, fsPath, userId, callback = (error, fileRef, folder_id) ->)-> + ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, fsPath, userId, (error, fileRef, folder_id, path, fileStoreUrl) -> + newFiles = [ + file: fileRef + path: path + url: fileStoreUrl + ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) -> + return callback(error) if error? + callback null, fileRef, folder_id replaceFile: (project_id, file_id, fsPath, userId, callback)-> self = ProjectEntityHandler @@ -412,7 +426,7 @@ module.exports = ProjectEntityHandler = callback() - deleteEntity: (project_id, entity_id, entityType, callback = (error) ->)-> + deleteEntity: (project_id, entity_id, entityType, userId, callback = (error) ->)-> self = @ logger.log entity_id:entity_id, entityType:entityType, project_id:project_id, "deleting project entity" if !entityType? @@ -423,7 +437,7 @@ module.exports = ProjectEntityHandler = return callback(error) if error? projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=> return callback(error) if error? - ProjectEntityHandler._cleanUpEntity project, entity, entityType, (error) -> + ProjectEntityHandler._cleanUpEntity project, entity, entityType, path.fileSystem, userId, (error) -> return callback(error) if error? tpdsUpdateSender.deleteEntity project_id:project_id, path:path.fileSystem, project_name:project.name, (error) -> return callback(error) if error? @@ -456,17 +470,17 @@ module.exports = ProjectEntityHandler = return callback(error) if error? DocumentUpdaterHandler.updateProjectStructure project_id, userId, {oldDocs, newDocs, oldFiles, newFiles}, callback - _cleanUpEntity: (project, entity, entityType, callback = (error) ->) -> + _cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) -> if(entityType.indexOf("file") != -1) - ProjectEntityHandler._cleanUpFile project, entity, callback + ProjectEntityHandler._cleanUpFile project, entity, path, userId, callback else if (entityType.indexOf("doc") != -1) - ProjectEntityHandler._cleanUpDoc project, entity, callback + ProjectEntityHandler._cleanUpDoc project, entity, path, userId, callback else if (entityType.indexOf("folder") != -1) - ProjectEntityHandler._cleanUpFolder project, entity, callback + ProjectEntityHandler._cleanUpFolder project, entity, path, userId, callback else callback() - _cleanUpDoc: (project, doc, callback = (error) ->) -> + _cleanUpDoc: (project, doc, path, userId, callback = (error) ->) -> project_id = project._id.toString() doc_id = doc._id.toString() unsetRootDocIfRequired = (callback) => @@ -483,26 +497,33 @@ module.exports = ProjectEntityHandler = return callback(error) if error? DocstoreManager.deleteDoc project_id, doc_id, (error) -> return callback(error) if error? - callback() + changes = oldDocs: [ {doc, path} ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback - _cleanUpFile: (project, file, callback = (error) ->) -> + _cleanUpFile: (project, file, path, userId, callback = (error) ->) -> project_id = project._id.toString() file_id = file._id.toString() - FileStoreHandler.deleteFile project_id, file_id, callback + FileStoreHandler.deleteFile project_id, file_id, (error) -> + return callback(error) if error? + changes = oldFiles: [ {file, path} ] + DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback - _cleanUpFolder: (project, folder, callback = (error) ->) -> + _cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) -> jobs = [] for doc in folder.docs do (doc) -> - jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, callback + docPath = path.join(folderPath, doc.name) + jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, docPath, userId, callback for file in folder.fileRefs do (file) -> - jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, callback + filePath = path.join(folderPath, file.name) + jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, filePath, userId, callback for childFolder in folder.folders do (childFolder) -> - jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, callback + folderPath = path.join(folderPath, childFolder.name) + jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, folderPath, userId, callback async.series jobs, callback diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index 12faf0e234..649551b5b2 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -62,6 +62,9 @@ module.exports = SubscriptionUpdater = invited_emails: email }, callback + refreshSubscription: (user_id, callback=(err)->) -> + SubscriptionUpdater._setUsersMinimumFeatures user_id, callback + deleteSubscription: (subscription_id, callback = (error) ->) -> SubscriptionLocator.getSubscription subscription_id, (err, subscription) -> return callback(err) if err? @@ -106,17 +109,29 @@ module.exports = SubscriptionUpdater = SubscriptionLocator.getUsersSubscription user_id, cb groupSubscription: (cb)-> SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb + v1PlanCode: (cb) -> + Modules = require '../../infrastructure/Modules' + Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) -> + cb(err, results?[0] || null) async.series jobs, (err, results)-> if err? - logger.err err:err, user_id:user, "error getting subscription or group for _setUsersMinimumFeatures" + logger.err err:err, user_id:user_id, + "error getting subscription or group for _setUsersMinimumFeatures" return callback(err) - {subscription, groupSubscription} = results - if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode - logger.log user_id:user_id, "using users subscription plan code for features" - UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback - else if groupSubscription? and groupSubscription.planCode? + {subscription, groupSubscription, v1PlanCode} = results + # Group Subscription + if groupSubscription? and groupSubscription.planCode? logger.log user_id:user_id, "using group which user is memor of for features" UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback + # Personal Subscription + else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode + logger.log user_id:user_id, "using users subscription plan code for features" + UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback + # V1 Subscription + else if v1PlanCode? + logger.log user_id: user_id, "using the V1 plan for features" + UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback + # Default else logger.log user_id:user_id, "using default features for user with no subscription or group" UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)-> diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee index c78b588e8a..78e3f12ed6 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee @@ -47,7 +47,7 @@ module.exports = logger.log user_id:user_id, filePath:path, projectName:projectName, project_id:project._id, "project found for delete update, path is root so marking project as deleted" return projectDeleter.markAsDeletedByExternalSource project._id, callback else - updateMerger.deleteUpdate project._id, path, source, (err)-> + updateMerger.deleteUpdate user_id, project._id, path, source, (err)-> callback(err) diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee index 010594d16a..e49c52aab2 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee @@ -32,13 +32,13 @@ module.exports = else self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback - deleteUpdate: (project_id, path, source, callback)-> + deleteUpdate: (user_id, project_id, path, source, callback)-> projectLocator.findElementByPath project_id, path, (err, element, type)-> if err? || !element? logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted" return callback() logger.log project_id:project_id, path:path, type:type, element:element, "processing update to delete entity from tpds" - editorController.deleteEntity project_id, element._id, type, source, (err)-> + editorController.deleteEntity project_id, element._id, type, source, user_id, (err)-> logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds" callback() diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index b126819f56..c99cc2935e 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -12,7 +12,7 @@ Modules = require "./Modules" Url = require "url" PackageVersions = require "./PackageVersions" htmlEncoder = new require("node-html-encoder").Encoder("numerical") -fingerprints = {} +hashedFiles = {} Path = require 'path' Features = require "./Features" @@ -30,43 +30,42 @@ getFileContent = (filePath)-> filePath = Path.join __dirname, "../../../", "public#{filePath}" exists = fs.existsSync filePath if exists - content = fs.readFileSync filePath + content = fs.readFileSync filePath, "UTF-8" return content else - logger.log filePath:filePath, "file does not exist for fingerprints" + logger.log filePath:filePath, "file does not exist for hashing" return "" -logger.log "Generating file fingerprints..." pathList = [ - ["#{jsPath}libs/#{fineuploader}.js"] - ["#{jsPath}libs/require.js"] - ["#{jsPath}ide.js"] - ["#{jsPath}main.js"] - ["#{jsPath}libs.js"] - ["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"] - ["#{jsPath}libs/#{pdfjs}/pdf.js"] - ["#{jsPath}libs/#{pdfjs}/pdf.worker.js"] - ["#{jsPath}libs/#{pdfjs}/compatibility.js"] - ["/stylesheets/style.css"] - ["/stylesheets/ol-style.css"] + "#{jsPath}libs/require.js" + "#{jsPath}ide.js" + "#{jsPath}main.js" + "#{jsPath}libraries.js" + "/stylesheets/style.css" + "/stylesheets/ol-style.css" ] -for paths in pathList - contentList = _.map(paths, getFileContent) - content = contentList.join("") - hash = crypto.createHash("md5").update(content).digest("hex") - _.each paths, (filePath)-> - logger.log "#{filePath}: #{hash}" - fingerprints[filePath] = hash +if !Settings.useMinifiedJs + logger.log "not using minified JS, not hashing static files" +else + logger.log "Generating file hashes..." + for path in pathList + content = getFileContent(path) + hash = crypto.createHash("md5").update(content).digest("hex") + + splitPath = path.split("/") + filenameSplit = splitPath.pop().split(".") + filenameSplit.splice(filenameSplit.length-1, 0, hash) + splitPath.push(filenameSplit.join(".")) -getFingerprint = (path) -> - if fingerprints[path]? - return fingerprints[path] - else - logger.err "No fingerprint for file: #{path}" - return "" + hashPath = splitPath.join("/") + hashedFiles[path] = hashPath -logger.log "Finished generating file fingerprints" + fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}" + fs.writeFileSync(fsHashPath, content) + + + logger.log "Finished hashing static content" cdnAvailable = Settings.cdn?.web?.host? darkCdnAvailable = Settings.cdn?.web?.darkHost? @@ -120,29 +119,35 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath) res.locals.lib = PackageVersions.lib + + res.locals.buildJsPath = (jsFile, opts = {})-> path = Path.join(jsPath, jsFile) - doFingerPrint = opts.fingerprint != false + if opts.hashedPath && hashedFiles[path]? + path = hashedFiles[path] if !opts.qs? opts.qs = {} - if !opts.qs?.fingerprint? and doFingerPrint - opts.qs.fingerprint = getFingerprint(path) - if opts.cdn != false path = Url.resolve(staticFilesBase, path) qs = querystring.stringify(opts.qs) + if opts.removeExtension == true + path = path.slice(0,-3) + if qs? and qs.length > 0 path = path + "?" + qs return path - res.locals.buildCssPath = (cssFile)-> + res.locals.buildCssPath = (cssFile, opts)-> path = Path.join("/stylesheets/", cssFile) - return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path) + if opts?.hashedPath && hashedFiles[path]? + hashedPath = hashedFiles[path] + return Url.resolve(staticFilesBase, hashedPath) + return Url.resolve(staticFilesBase, path) res.locals.buildImgPath = (imgFile)-> path = Path.join("/img/", imgFile) @@ -227,10 +232,6 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> return req.query?[field] next() - webRouter.use (req, res, next)-> - res.locals.fingerprint = getFingerprint - next() - webRouter.use (req, res, next)-> res.locals.formatPrice = SubscriptionFormatters.formatPrice next() @@ -296,10 +297,14 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> webRouter.use (req, res, next) -> isOl = (Settings.brandPrefix == 'ol-') res.locals.uiConfig = - defaultResizerSizeOpen : if isOl then 2 else 24 - defaultResizerSizeClosed : if isOl then 2 else 24 - eastResizerCursor : if isOl then "ew-resize" else null - westResizerCursor : if isOl then "ew-resize" else null - chatResizerSizeOpen : if isOl then 2 else 12 - chatResizerSizeClosed : 0 + defaultResizerSizeOpen : if isOl then 2 else 24 + defaultResizerSizeClosed : if isOl then 2 else 24 + eastResizerCursor : if isOl then "ew-resize" else null + westResizerCursor : if isOl then "ew-resize" else null + chatResizerSizeOpen : if isOl then 2 else 12 + chatResizerSizeClosed : 0 + chatMessageBorderSaturation: if isOl then "85%" else "70%" + chatMessageBorderLightness : if isOl then "40%" else "70%" + chatMessageBgSaturation : if isOl then "85%" else "60%" + chatMessageBgLightness : if isOl then "40%" else "97%" next() diff --git a/services/web/app/coffee/models/Project.coffee b/services/web/app/coffee/models/Project.coffee index e0013f8c5f..b434d35ab9 100644 --- a/services/web/app/coffee/models/Project.coffee +++ b/services/web/app/coffee/models/Project.coffee @@ -56,6 +56,7 @@ ProjectSchema = new Schema read_token : { type: String } history : id : { type: Number } + display : { type: Boolean } ProjectSchema.statics.getProject = (project_or_id, fields, callback)-> if project_or_id._id? diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index f8ec65ac53..2199bdd08e 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -197,6 +197,7 @@ module.exports = class Router webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject @@ -324,6 +325,10 @@ module.exports = class Router headers: req.headers }) + webRouter.get "/no-cache", (req, res, next)-> + res.header("Cache-Control", "max-age=0") + res.sendStatus(404) + webRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error")) webRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error") webRouter.get '/oops-mongo', (req, res, next) -> diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index e12d7d6f81..151df0b0ac 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -23,7 +23,7 @@ html(itemscope, itemtype='http://schema.org/Product') link(rel="apple-touch-icon-precomposed", href="/" + settings.brandPrefix + "apple-touch-icon-precomposed.png") link(rel="mask-icon", href="/" + settings.brandPrefix + "mask-favicon.svg", color="#a93529") - link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css")) + link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css", {hashedPath:true})) block _headLinks @@ -57,7 +57,7 @@ html(itemscope, itemtype='http://schema.org/Product') script(type="text/javascript"). window.csrfToken = "#{csrfToken}"; - script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false})) + script(src=buildJsPath("libs/jquery-1.11.1.min.js")) script(type="text/javascript"). var noCdnKey = "nocdn=true" var cdnBlocked = typeof jQuery === 'undefined' @@ -68,7 +68,7 @@ html(itemscope, itemtype='http://schema.org/Product') block scripts - script(src=buildJsPath("libs/angular-1.6.4.min.js", {fingerprint:false})) + script(src=buildJsPath("libs/angular-1.6.4.min.js")) script. window.sharelatex = { @@ -95,7 +95,11 @@ html(itemscope, itemtype='http://schema.org/Product') cdnDomain : '!{settings.templates.cdnDomain}', indexName : '!{settings.templates.indexName}' } - + + - if (settings.overleaf && settings.overleaf.useOLFreeTrial) + script. + window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial' + body if(settings.recaptcha) script(src="https://www.google.com/recaptcha/api.js?render=explicit") @@ -142,9 +146,10 @@ html(itemscope, itemtype='http://schema.org/Product') window.requirejs = { "paths" : { "moment": "libs/#{lib('moment')}", - "fineuploader": "libs/#{lib('fineuploader')}" + "fineuploader": "libs/#{lib('fineuploader')}", + "main": "#{buildJsPath('main.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", + "libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", }, - "urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}", "config":{ "moment":{ "noGlobal": true @@ -152,9 +157,9 @@ html(itemscope, itemtype='http://schema.org/Product') } }; script( - data-main=buildJsPath('main.js', {fingerprint:false}), + data-main=buildJsPath('main.js', {hashedPath:false}), baseurl=fullJsPath, - src=buildJsPath('libs/require.js') + src=buildJsPath('libs/require.js', {hashedPath:true}) ) include contact-us-modal diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index ec1d129714..4a35b930ff 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -98,14 +98,14 @@ block requirejs script(type="text/javascript" src='/socket.io/socket.io.js') //- don't use cdn for workers - - var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) - - var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false}) - - var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false,fingerprint:false}) + - var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false}) + - var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false}) + - var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false}) //- We need to do .replace(/\//g, '\\/') do that '' -> '<\/script>' //- and doesn't prematurely end the script tag. script#data(type="application/json"). - !{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState}).replace(/\//g, '\\/')} + !{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState, useV2History: useV2History}).replace(/\//g, '\\/')} script(type="text/javascript"). window.data = JSON.parse($("#data").text()); @@ -126,14 +126,15 @@ block requirejs window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)}; window.requirejs = { "paths" : { - "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}", + "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}", "moment": "libs/#{lib('moment')}", "pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf", "pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}", "ace": "#{lib('ace')}", - "fineuploader": "libs/#{lib('fineuploader')}" + "fineuploader": "libs/#{lib('fineuploader')}", + "ide": "#{buildJsPath('ide.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", + "libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", }, - "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}", "waitSeconds": 0, "shim": { "pdfjs-dist/build/pdf": { @@ -155,14 +156,13 @@ block requirejs } } }; - window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}" window.aceWorkerPath = "#{aceWorkerPath}"; window.pdfCMapsPath = "#{pdfCMapsPath}" window.uiConfig = JSON.parse('!{JSON.stringify(uiConfig).replace(/\//g, "\\/")}'); script( - data-main=buildJsPath("ide.js", {fingerprint:false}), + data-main=buildJsPath("ide.js", {hashedPath:false}), baseurl=fullJsPath, - data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}), - src=buildJsPath('libs/require.js') + data-ace-base=buildJsPath(lib('ace')), + src=buildJsPath('libs/require.js', {hashedPath:true}) ) diff --git a/services/web/app/views/project/editor/chat.pug b/services/web/app/views/project/editor/chat.pug index fcd47a81e3..cdfbcfc097 100644 --- a/services/web/app/views/project/editor/chat.pug +++ b/services/web/app/views/project/editor/chat.pug @@ -35,12 +35,9 @@ aside.chat( span(ng-if="message.user.first_name") {{ message.user.first_name }} span(ng-if="!message.user.first_name") {{ message.user.email }} .message( - ng-style="{\ - 'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)',\ - 'background-color': 'hsl({{ hue(message.user) }}, 60%, 97%)'\ - }" + ng-style="getMessageStyle(message.user);" ) - .arrow(ng-style="{'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)'}") + .arrow(ng-style="getArrowStyle(message.user)") .message-content p( mathjax, diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index 4806b0a9b8..6621cdb2d2 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -134,8 +134,17 @@ div#history(ng-show="ui.view == 'history'") div.description(ng-click="select()") div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} - div.docs(ng-repeat="(doc_id, doc) in update.docs") - span.doc {{ doc.entity.name }} + div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") + | Edited + div.docs(ng-repeat="pathname in update.pathnames") + .doc {{ pathname }} + div.docs(ng-repeat="project_op in update.project_ops") + div(ng-if="project_op.rename") + .action Renamed + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action Created + .doc {{ project_op.add.pathname }} div.users div.user(ng-repeat="update_user in update.meta.users") .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}") @@ -165,8 +174,8 @@ div#history(ng-show="ui.view == 'history'") 'other': 'changes'\ }" ) - | in {{history.diff.doc.name}} - .toolbar-right + | in {{history.diff.pathname}} + .toolbar-right(ng-if="!history.isV2") a.btn.btn-danger.btn-sm( href, ng-click="openRestoreDiffModal()" diff --git a/services/web/app/views/project/list.pug b/services/web/app/views/project/list.pug index deecc46911..0cd67d473c 100644 --- a/services/web/app/views/project/list.pug +++ b/services/web/app/views/project/list.pug @@ -63,6 +63,14 @@ block content aside.project-list-sidebar.col-md-2.col-xs-3 include ./list/side-bar + if isShowingV1Projects && settings.overleaf && settings.overleaf.host + .project-list-sidebar-v2-pane.col-md-2.col-xs-3 + span Welcome to the Overleaf v2 alpha! #[a(href="https://www.overleaf.com/help/v2") Find out more]. + span To tag or rename your v1 projects, please go back to Overleaf v1. + a.project-list-sidebar-v1-link( + href=settings.overleaf.host + "/dash?prefer-v1-dash=1" + ) Go back to v1 + .project-list-main.col-md-10.col-xs-9 include ./list/notifications include ./list/project-list diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index f246ad34d4..a4bb240c4b 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -2,6 +2,7 @@ input.select-item( select-individual, type="checkbox", + ng-disabled="shouldDisableCheckbox(project)", ng-model="project.selected" stop-propagation="click" aria-label=translate('select_project') + " '{{ project.name }}'" diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 1e12fb2293..c3d86feb70 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -317,38 +317,56 @@ script(type="text/ng-template", id="userProfileModalTemplate") script(type="text/ng-template", id="v1ImportModalTemplate") .modal-header button.close(ng-click="dismiss()") × - h3 #{translate("import_project_to_v2")} + h3 Move Project to Overleaf v2 .modal-body.v1-import-wrapper .v1-import-step-1(ng-show="step === 1") - img.v1-import-img( - src="/img/v1-import/v2-editor.png" - alt="The new V2 Editor." - ) - h2.v1-import-title Try importing your project to V2! - p Some exciting copy about the new features: - ul - li Some stuff - li Some more stuff - li Yet more stuff + .v1-import-row + .v1-import-col + img.v1-import-img( + src="/img/v1-import/v2-editor.png" + alt="The new V2 Editor." + ) + .v1-import-col + h2.v1-import-title Try the Overleaf v2 Editor + p The Overleaf v2 editor has many great new features including: + ul + li Faster real-time collaboration + li See your coauthors’ cursors + li Chat with math support + li Tracked changes and commenting + li Improved LaTeX autocomplete + li Two-way Dropbox sync + p.v1-import-cta Would you like to move #[strong {{project.name}}] into Overleaf v2? .v1-import-step-2(ng-show="step === 2") - div.v1-import-warning(aria-label="Warning symbol.") - i.fa.fa-exclamation-triangle - h2.v1-import-title #[strong Warning:] Overleaf V2 is in beta - p Once importing your project you will lose access to the some of the features of Overleaf V1. This includes the git bridge, journal integrations, WYSIWYG and linked files. We’re working on bringing these features to V2! - p Once you have imported a project to V2 you #[strong cannot go back to V1]. - p Are you sure you want to import to V2? + .v1-import-row + .v1-import-warning.v1-import-col(aria-label="Warning symbol.") + i.fa.fa-exclamation-triangle + .v1-import-col + h2.v1-import-title #[strong Warning:] Overleaf v2 is Experimental + p We are still working hard to bring some Overleaf v1 features to the v2 editor. If you move this project to v2 now, you will: + ul + li Lose access your project via git + li Not be able to use the Journals and Services menu to submit directly to our partners + li Not be able to use the Rich Text (WYSIWYG) mode + li Not be able to use linked files (to URLs or to files in other Overleaf projects) + li Not be able to use some bibliography integrations (Zotero, CiteULike) + li Lose access to your labelled versions and not be able to create new labelled versions + .v1-import-cta + p + strong Please note: you cannot move this project back to v1 once you have moved it to v2. If this is an important project, please consider making a clone in v1 before you move the project to v2. + p Are you sure you want to move #[strong {{project.name}}] into Overleaf v2? .modal-footer.v1-import-footer div(ng-show="step === 1") if settings.overleaf && settings.overleaf.host a.btn.btn-primary.v1-import-btn( ng-href=settings.overleaf.host + "/{{project.id}}" - ) #{translate("open_in_v1")} + ) No thanks, open in v1 button.btn.btn-primary.v1-import-btn( ng-click="moveToConfirmation()" - ) #{translate("import_to_v2")} + ) Yes, move project to v2 div(ng-show="step === 2") form( async-form="v1Import", @@ -363,9 +381,9 @@ script(type="text/ng-template", id="v1ImportModalTemplate") a.btn.btn-primary.v1-import-btn( ng-href=settings.overleaf.host + "/{{project.id}}" ng-class="{disabled: v1ImportForm.inflight || v1ImportForm.success}" - ) #{translate("never_mind_open_in_v1")} + ) No thanks, open in v1 input.btn.btn-primary.v1-import-btn( type="submit", - value=translate('yes_im_sure') + value="Yes, move project to v2" ng-disabled="v1ImportForm.inflight || v1ImportForm.success" ) diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 88584dba76..89a07abe1c 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -210,7 +210,7 @@ block content h3 #{translate("group_plan_enquiry")} .modal-body form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak) - span(ng-show="sent == false") + span(ng-show="sent == false && error == false") .form-group label#title9(for='Field9') | Name @@ -228,11 +228,13 @@ block content .form-group input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') .form-group - input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'ShareLaTeX for Universities';") + input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';") .form-group.text-center input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') - span(ng-show="sent") + span(ng-show="sent == true && error == false") p Request Sent, Thank you. + span(ng-show="error") + p Error sending request. .row .col-md-12 diff --git a/services/web/app/views/v1-tooltip.pug b/services/web/app/views/v1-tooltip.pug index 2934adbe8b..728962338a 100644 --- a/services/web/app/views/v1-tooltip.pug +++ b/services/web/app/views/v1-tooltip.pug @@ -1,6 +1,6 @@ script(type="text/ng-template", id="v1ProjectTooltipTemplate") - span This project is from Overleaf version 1 and has not been imported to the beta yet + span This project is from Overleaf v1 and has not been imported to v2 yet. script(type="text/ng-template", id="v1TagTooltipTemplate") - span This folder is from Overleaf version 1 and has not been imported to the beta yet \ No newline at end of file + span This folder/tag is from Overleaf v1. Please go back to v1 to manage. \ No newline at end of file diff --git a/services/web/bin/compile_app b/services/web/bin/compile_backend similarity index 100% rename from services/web/bin/compile_app rename to services/web/bin/compile_backend diff --git a/services/web/bin/compile_frontend b/services/web/bin/compile_frontend new file mode 100755 index 0000000000..bb1dde7dbb --- /dev/null +++ b/services/web/bin/compile_frontend @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +COFFEE=node_modules/.bin/coffee +echo Compiling public/coffee; +$COFFEE -o public/js -c public/coffee; diff --git a/services/web/bin/compile_frontend_tests b/services/web/bin/compile_frontend_tests new file mode 100755 index 0000000000..0351ad70cd --- /dev/null +++ b/services/web/bin/compile_frontend_tests @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +COFFEE=node_modules/.bin/coffee +echo Compiling test/unit_frontend/coffee; +$COFFEE -o test/unit_frontend/js -c test/unit_frontend/coffee; diff --git a/services/web/bin/frontend_test b/services/web/bin/frontend_test new file mode 100755 index 0000000000..599055803a --- /dev/null +++ b/services/web/bin/frontend_test @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +MOCHA="node_modules/.bin/mocha --recursive --reporter spec" +$MOCHA "$@" test/unit_frontend/js + diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 65a8bf1e91..844bffae3c 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -111,7 +111,8 @@ module.exports = settings = trackchanges: url : "http://localhost:3015" project_history: - enabled: process.env.PROJECT_HISTORY_ENABLED == 'true' or false + sendProjectStructureOps: process.env.PROJECT_HISTORY_ENABLED == 'true' or false + initializeHistoryForNewProjects: process.env.PROJECT_HISTORY_ENABLED == 'true' or false url : "http://localhost:3054" docstore: url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016" diff --git a/services/web/docker-shared.template.yml b/services/web/docker-shared.template.yml index d697e59e96..8acc63ef81 100644 --- a/services/web/docker-shared.template.yml +++ b/services/web/docker-shared.template.yml @@ -13,17 +13,15 @@ services: - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json - node_modules:/app/node_modules - ./bin:/app/bin - # Copying the whole public dir is fine for now, and needed for - # some unit tests to pass, but we will want to isolate the coffee - # and vendor js files, so that the compiled js files are not written - # back to the local filesystem. - - ./public:/app/public + - ./public/coffee:/app/public/coffee:ro + - ./public/js/ace-1.2.5:/app/public/js/ace-1.2.5 - ./app.coffee:/app/app.coffee:ro - ./app/coffee:/app/app/coffee:ro - ./app/templates:/app/app/templates:ro - ./app/views:/app/app/views:ro - ./config:/app/config - ./test/unit/coffee:/app/test/unit/coffee:ro + - ./test/unit_frontend/coffee:/app/test/unit_frontend/coffee:ro - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro - ./test/acceptance/files:/app/test/acceptance/files:ro - ./test/smoke/coffee:/app/test/smoke/coffee:ro diff --git a/services/web/package.json b/services/web/package.json index a1cf0fd6e0..398ce3dc78 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -14,11 +14,15 @@ "test:acceptance:run": "bin/acceptance_test $@", "test:acceptance:dir": "npm -q run compile:acceptance_tests && npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@", "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js", - "test:unit": "npm -q run compile:app && npm -q run compile:unit_tests && bin/unit_test $@", + "test:unit": "npm -q run compile:backend && npm -q run compile:unit_tests && bin/unit_test $@", + "test:frontend": "npm -q run compile:frontend && npm -q run compile:frontend_tests && bin/frontend_test $@", "compile:unit_tests": "bin/compile_unit_tests", + "compile:frontend_tests": "bin/compile_frontend_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", - "compile:app": "bin/compile_app", - "start": "npm -q run compile:app && node app.js" + "compile:frontend": "bin/compile_frontend", + "compile:backend": "bin/compile_backend", + "compile": "npm -q run compile:backend && npm -q run compile:frontend", + "start": "npm -q run compile && node app.js" }, "dependencies": { "archiver": "0.9.0", diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index 03d0ada365..c3847c9342 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -1,5 +1,5 @@ define [ - "libs" + "libraries" "modules/recursionHelper" "modules/errorCatcher" "modules/localStorage" diff --git a/services/web/public/coffee/directives/selectAll.coffee b/services/web/public/coffee/directives/selectAll.coffee index 4194d050e8..eb0bd05e86 100644 --- a/services/web/public/coffee/directives/selectAll.coffee +++ b/services/web/public/coffee/directives/selectAll.coffee @@ -49,18 +49,21 @@ define [ selectAllListController.clearSelectAllState() scope.$on "select-all:select", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = true ignoreChanges = false scope.$on "select-all:deselect", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = false ignoreChanges = false scope.$on "select-all:row-clicked", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = !scope.ngModel @@ -75,4 +78,4 @@ define [ link: (scope, element, attrs) -> element.on "click", (e) -> scope.$broadcast "select-all:row-clicked" - } \ No newline at end of file + } diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index dbf6a2724e..3d514d093c 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -5,6 +5,7 @@ define [ "ide/editor/EditorManager" "ide/online-users/OnlineUsersManager" "ide/history/HistoryManager" + "ide/history/HistoryV2Manager" "ide/permissions/PermissionsManager" "ide/pdf/PdfManager" "ide/binary-files/BinaryFilesManager" @@ -44,6 +45,7 @@ define [ EditorManager OnlineUsersManager HistoryManager + HistoryV2Manager PermissionsManager PdfManager BinaryFilesManager @@ -137,7 +139,10 @@ define [ ide.fileTreeManager = new FileTreeManager(ide, $scope) ide.editorManager = new EditorManager(ide, $scope) ide.onlineUsersManager = new OnlineUsersManager(ide, $scope) - ide.historyManager = new HistoryManager(ide, $scope) + if window.data.useV2History + ide.historyManager = new HistoryV2Manager(ide, $scope) + else + ide.historyManager = new HistoryManager(ide, $scope) ide.pdfManager = new PdfManager(ide, $scope) ide.permissionsManager = new PermissionsManager(ide, $scope) ide.binaryFilesManager = new BinaryFilesManager(ide, $scope) diff --git a/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee b/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee index 30fbccd05a..45e9821b96 100644 --- a/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee +++ b/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee @@ -3,9 +3,22 @@ define [ "ide/colors/ColorManager" ], (App, ColorManager) -> App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) -> - $scope.hue = (user) -> + hslColorConfigs = + borderSaturation: window.uiConfig?.chatMessageBorderSaturation or "70%" + borderLightness : window.uiConfig?.chatMessageBorderLightness or "70%" + bgSaturation : window.uiConfig?.chatMessageBgSaturation or "60%" + bgLightness : window.uiConfig?.chatMessageBgLightness or "97%" + + hue = (user) -> if !user? return 0 else return ColorManager.getHueForUserId(user.id) + + $scope.getMessageStyle = (user) -> + "border-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })" + "background-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.bgSaturation }, #{ hslColorConfigs.bgLightness })" + + $scope.getArrowStyle = (user) -> + "border-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })" ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 6908b25bce..ff5d1554e2 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -33,7 +33,7 @@ define [ if !ace.config._moduleUrl? ace.config._moduleUrl = ace.config.moduleUrl ace.config.moduleUrl = (args...) -> - url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}" + url = ace.config._moduleUrl(args...) return url App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, metadata, graphics, preamble, files, $http, $q) -> diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index 6b42714e79..f896fbb8b4 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -100,6 +100,7 @@ define [ end_ts: end_ts doc: doc error: false + pathname: doc.name } if !doc.deleted @@ -190,8 +191,10 @@ define [ previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1] for update in updates + update.pathnames = [] # Used for display for doc_id, doc of update.docs or {} doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true) + update.pathnames.push doc.entity.name for user in update.meta.users or [] if user? diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee new file mode 100644 index 0000000000..8198738e16 --- /dev/null +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -0,0 +1,280 @@ +define [ + "moment" + "ide/colors/ColorManager" + "ide/history/controllers/HistoryListController" + "ide/history/controllers/HistoryDiffController" + "ide/history/directives/infiniteScroll" +], (moment, ColorManager) -> + class HistoryManager + constructor: (@ide, @$scope) -> + @reset() + + @$scope.toggleHistory = () => + if @$scope.ui.view == "history" + @hide() + else + @show() + + @$scope.$watch "history.selection.updates", (updates) => + if updates? and updates.length > 0 + @_selectDocFromUpdates() + @reloadDiff() + + @$scope.$on "entity:selected", (event, entity) => + if (@$scope.ui.view == "history") and (entity.type == "doc") + @$scope.history.selection.pathname = _ide.fileTreeManager.getEntityPath(entity) + @reloadDiff() + + show: () -> + @$scope.ui.view = "history" + @reset() + + hide: () -> + @$scope.ui.view = "editor" + # Make sure we run the 'open' logic for whatever is currently selected + @$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity() + + reset: () -> + @$scope.history = { + isV2: true + updates: [] + nextBeforeTimestamp: null + atEnd: false + selection: { + updates: [] + pathname: null + range: { + fromV: null + toV: null + } + } + diff: null + } + + MAX_RECENT_UPDATES_TO_SELECT: 2 + autoSelectRecentUpdates: () -> + return if @$scope.history.updates.length == 0 + + @$scope.history.updates[0].selectedTo = true + + indexOfLastUpdateNotByMe = 0 + for update, i in @$scope.history.updates + if @_updateContainsUserId(update, @$scope.user.id) or i > @MAX_RECENT_UPDATES_TO_SELECT + break + indexOfLastUpdateNotByMe = i + + @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true + + BATCH_SIZE: 10 + fetchNextBatchOfUpdates: () -> + url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" + if @$scope.history.nextBeforeTimestamp? + url += "&before=#{@$scope.history.nextBeforeTimestamp}" + @$scope.history.loading = true + @ide.$http + .get(url) + .then (response) => + { data } = response + @_loadUpdates(data.updates) + @$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp + if !data.nextBeforeTimestamp? + @$scope.history.atEnd = true + @$scope.history.loading = false + + reloadDiff: () -> + diff = @$scope.history.diff + {updates} = @$scope.history.selection + {fromV, toV, pathname} = @_calculateDiffDataFromSelection() + + if !pathname? + @$scope.history.diff = null + return + + return if diff? and + diff.pathname == pathname and + diff.fromV == fromV and + diff.toV == toV + + @$scope.history.diff = diff = { + fromV: fromV + toV: toV + pathname: pathname + error: false + } + + diff.loading = true + url = "/project/#{@$scope.project_id}/diff" + query = ["pathname=#{encodeURIComponent(pathname)}"] + if diff.fromV? and diff.toV? + query.push "from=#{diff.fromV}", "to=#{diff.toV}" + url += "?" + query.join("&") + + @ide.$http + .get(url) + .then (response) => + { data } = response + diff.loading = false + {text, highlights} = @_parseDiff(data) + diff.text = text + diff.highlights = highlights + .catch () -> + diff.loading = false + diff.error = true + + _parseDiff: (diff) -> + row = 0 + column = 0 + highlights = [] + text = "" + for entry, i in diff.diff or [] + content = entry.u or entry.i or entry.d + content ||= "" + text += content + lines = content.split("\n") + startRow = row + startColumn = column + if lines.length > 1 + endRow = startRow + lines.length - 1 + endColumn = lines[lines.length - 1].length + else + endRow = startRow + endColumn = startColumn + lines[0].length + row = endRow + column = endColumn + + range = { + start: + row: startRow + column: startColumn + end: + row: endRow + column: endColumn + } + + if entry.i? or entry.d? + if entry.meta.user? + name = "#{entry.meta.user.first_name} #{entry.meta.user.last_name}" + else + name = "Anonymous" + if entry.meta.user?.id == @$scope.user.id + name = "you" + date = moment(entry.meta.end_ts).format("Do MMM YYYY, h:mm a") + if entry.i? + highlights.push { + label: "Added by #{name} on #{date}" + highlight: range + hue: ColorManager.getHueForUserId(entry.meta.user?.id) + } + else if entry.d? + highlights.push { + label: "Deleted by #{name} on #{date}" + strikeThrough: range + hue: ColorManager.getHueForUserId(entry.meta.user?.id) + } + + return {text, highlights} + + _loadUpdates: (updates = []) -> + previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1] + + for update in updates or [] + for user in update.meta.users or [] + if user? + user.hue = ColorManager.getHueForUserId(user.id) + + if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day") + update.meta.first_in_day = true + + update.selectedFrom = false + update.selectedTo = false + update.inSelection = false + + previousUpdate = update + + firstLoad = @$scope.history.updates.length == 0 + + @$scope.history.updates = + @$scope.history.updates.concat(updates) + + @autoSelectRecentUpdates() if firstLoad + + _perDocSummaryOfUpdates: (updates) -> + # Track current_pathname -> original_pathname + original_pathnames = {} + + # Map of original pathname -> doc summary + docs_summary = {} + + updatePathnameWithUpdateVersions = (pathname, update) -> + # docs_summary is indexed by the original pathname the doc + # had at the start, so we have to look this up from the current + # pathname via original_pathname first + if !original_pathnames[pathname]? + original_pathnames[pathname] = pathname + original_pathname = original_pathnames[pathname] + doc_summary = docs_summary[original_pathname] ?= { + fromV: update.fromV, toV: update.toV, + } + doc_summary.fromV = Math.min( + doc_summary.fromV, + update.fromV + ) + doc_summary.toV = Math.max( + doc_summary.toV, + update.toV + ) + + # Put updates in ascending chronological order + updates = updates.slice().reverse() + for update in updates + for pathname in update.pathnames or [] + updatePathnameWithUpdateVersions(pathname, update) + for project_op in update.project_ops or [] + if project_op.rename? + rename = project_op.rename + updatePathnameWithUpdateVersions(rename.pathname, update) + original_pathnames[rename.newPathname] = original_pathnames[rename.pathname] + delete original_pathnames[rename.pathname] + if project_op.add? + add = project_op.add + updatePathnameWithUpdateVersions(add.pathname, update) + + return docs_summary + + _calculateDiffDataFromSelection: () -> + fromV = toV = pathname = null + + selected_pathname = @$scope.history.selection.pathname + + for pathname, doc of @_perDocSummaryOfUpdates(@$scope.history.selection.updates) + if pathname == selected_pathname + {fromV, toV} = doc + return {fromV, toV, pathname} + + return {} + + # Set the track changes selected doc to one of the docs in the range + # of currently selected updates. If we already have a selected doc + # then prefer this one if present. + _selectDocFromUpdates: () -> + affected_docs = @_perDocSummaryOfUpdates(@$scope.history.selection.updates) + + selected_pathname = @$scope.history.selection.pathname + if selected_pathname? and affected_docs[selected_pathname] + # Selected doc is already open + else + # Set to first possible candidate + for pathname, doc of affected_docs + selected_pathname = pathname + break + + @$scope.history.selection.pathname = selected_pathname + if selected_pathname? + entity = @ide.fileTreeManager.findEntityByPath(selected_pathname) + if entity? + @ide.fileTreeManager.selectEntity(entity) + + _updateContainsUserId: (update, user_id) -> + for user in update.meta.users + return true if user?.id == user_id + return false diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 17afd8bbcb..7bce498ba8 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -90,7 +90,9 @@ define [ # to block auto compiles. It also causes problems where server-provided # linting errors aren't cleared after typing if (ide.$scope.settings.syntaxValidation and !ide.$scope.hasLintingError) - $scope.recompile(isAutoCompileOnChange: true) + $scope.recompile(isAutoCompileOnChange: true) # compile if no linting errors + else if !ide.$scope.settings.syntaxValidation + $scope.recompile(isAutoCompileOnChange: true) # always recompile else # Extend remainder of timeout autoCompileTimeout = setTimeout () -> @@ -533,14 +535,6 @@ define [ else $scope.switchToSideBySideLayout() - $scope.startFreeTrial = (source) -> - ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source) - - event_tracking.sendMB "subscription-start-trial", { source } - - window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}") - $scope.startedFreeTrial = true - App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) -> # enable per-user containers by default perUserCompile = true diff --git a/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee index ae8c049f69..3fcc5e416f 100644 --- a/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee +++ b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee @@ -4,8 +4,3 @@ define [ App.controller "TrackChangesUpgradeModalController", ($scope, $modalInstance) -> $scope.cancel = () -> $modalInstance.dismiss() - - $scope.startFreeTrial = (source) -> - ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source) - window.open("/user/subscription/new?planCode=student_free_trial_7_days") - $scope.startedFreeTrial = true \ No newline at end of file diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libraries.coffee similarity index 95% rename from services/web/public/coffee/libs.coffee rename to services/web/public/coffee/libraries.coffee index 2b0b034fb7..e4532d7187 100644 --- a/services/web/public/coffee/libs.coffee +++ b/services/web/public/coffee/libraries.coffee @@ -9,7 +9,6 @@ define [ "libs/angular-cookie" "libs/passfield" "libs/sixpack" - "libs/groove" "libs/angular-sixpack" "libs/ng-tags-input-3.0.0" ], () -> diff --git a/services/web/public/coffee/main/account-upgrade.coffee b/services/web/public/coffee/main/account-upgrade.coffee index 6144bea9ef..0cabc224a0 100644 --- a/services/web/public/coffee/main/account-upgrade.coffee +++ b/services/web/public/coffee/main/account-upgrade.coffee @@ -11,9 +11,12 @@ define [ w = window.open() go = () -> ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source) - url = "/user/subscription/new?planCode=#{plan}&ssp=true" - if couponCode? - url = "#{url}&cc=#{couponCode}" + if window.redirectToOLFreeTrialUrl? + url = window.redirectToOLFreeTrialUrl + else + url = "/user/subscription/new?planCode=#{plan}&ssp=true" + if couponCode? + url = "#{url}&cc=#{couponCode}" $scope.startedFreeTrial = true switch source @@ -27,7 +30,7 @@ define [ else event_tracking.sendMB "subscription-start-trial", { source, plan } - + w.location = url if $scope.shouldABTestPlans diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee index 7bb86a6b93..138f459890 100644 --- a/services/web/public/coffee/main/contact-us.coffee +++ b/services/web/public/coffee/main/contact-us.coffee @@ -74,27 +74,34 @@ define [ $modalInstance.close() - App.controller 'UniverstiesContactController', ($scope, $modal) -> + App.controller 'UniverstiesContactController', ($scope, $modal, $http) -> $scope.form = {} $scope.sent = false $scope.sending = false + $scope.error = false $scope.contactUs = -> if !$scope.form.email? console.log "email not set" return $scope.sending = true ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) - params = + data = + _csrf : window.csrfToken name: $scope.form.name || $scope.form.email email: $scope.form.email labels: "#{$scope.form.source} accounts" message: "Please contact me with more details" - subject: $scope.form.subject + " - [#{ticketNumber}]" - about : "#{$scope.form.position || ''} #{$scope.form.university || ''}" + subject: "#{$scope.form.name} - General Enquiry - #{$scope.form.position} - #{$scope.form.university}" + inbox: "accounts" - Groove.createTicket params, (err, json)-> - $scope.sent = true + request = $http.post "/support", data + + request.catch ()-> + $scope.error = true $scope.$apply() - + request.then (response)-> + $scope.sent = true + $scope.error = (response.status != 200) + $scope.$apply() diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index f4a6951e4a..75d4767ec0 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -462,6 +462,9 @@ define [ App.controller "ProjectListItemController", ($scope) -> + $scope.shouldDisableCheckbox = (project) -> + $scope.filter == 'archived' && project.accessLevel != 'owner' + $scope.projectLink = (project) -> if project.accessLevel == 'readAndWrite' and project.source == 'token' "/#{project.tokens.readAndWrite}" diff --git a/services/web/public/coffee/utils/underscore.coffee b/services/web/public/coffee/utils/underscore.coffee index 7a1551f90b..944abccb5d 100644 --- a/services/web/public/coffee/utils/underscore.coffee +++ b/services/web/public/coffee/utils/underscore.coffee @@ -1,5 +1,5 @@ define [ - "libs" + "libraries" ], () -> angular.module('underscore', []).factory '_', -> return window._ diff --git a/services/web/public/js/libs/groove.js b/services/web/public/js/libs/groove.js deleted file mode 100644 index 983b2a73dc..0000000000 --- a/services/web/public/js/libs/groove.js +++ /dev/null @@ -1,84 +0,0 @@ -!function(window) { - - window.Groove = { - - init: function(options) { - this._options = options; - if (typeof grooveOnReady != 'undefined') {grooveOnReady();} - }, - - createTicket: function(params, callback) { - var postData = serialize({ - "ticket[enduser_name]": params["name"], - "ticket[enduser_email]": params["email"], - "ticket[title]": params["subject"], - "ticket[enduser_about]": params["about"], - "ticket[label_string]": params["labels"], - "ticket[comments_attributes][0][body]": params["message"] - }); - - sendRequest(this._options.widget_ticket_url, function(req) { - if (callback) {callback(req);} - }, postData); - } - }; - - // http://www.quirksmode.org/js/xmlhttp.html - function sendRequest(url, callback, postData) { - var req = createXMLHTTPObject(); - if (!req) return; - var method = (postData) ? "POST" : "GET"; - req.open(method, url, true); - if (postData){ - try { - req.setRequestHeader('Content-type','application/x-www-form-urlencoded'); - } - catch(e) { - req.contentType = 'application/x-www-form-urlencoded'; - }; - }; - req.onreadystatechange = function () { - if (req.readyState != 4) return; - callback(req); - } - if (req.readyState == 4) return; - req.send(postData); - } - - var XMLHttpFactories = [ - function () {return new XDomainRequest()}, - function () {return new XMLHttpRequest()}, - function () {return new ActiveXObject("Msxml2.XMLHTTP")}, - function () {return new ActiveXObject("Msxml3.XMLHTTP")}, - function () {return new ActiveXObject("Microsoft.XMLHTTP")} - ]; - - function createXMLHTTPObject() { - var xmlhttp = false; - for (var i = 0; i < XMLHttpFactories.length; i++) { - try { - xmlhttp = XMLHttpFactories[i](); - } - catch (e) { - continue; - } - break; - } - return xmlhttp; - } - - function serialize(obj) { - var str = []; - for(var p in obj) { - if (obj[p]) { - str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); - } - } - return str.join("&"); -} - -if (typeof grooveOnLoad != 'undefined') {grooveOnLoad();} -}(window); - -Groove.init({"widget_ticket_url":"https://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket.json"}); - diff --git a/services/web/public/stylesheets/_ol_style_includes.less b/services/web/public/stylesheets/_ol_style_includes.less new file mode 100644 index 0000000000..f2832bd219 --- /dev/null +++ b/services/web/public/stylesheets/_ol_style_includes.less @@ -0,0 +1 @@ +@import "app/sidebar-v2-dash-pane.less"; diff --git a/services/web/public/stylesheets/app/editor/chat.less b/services/web/public/stylesheets/app/editor/chat.less index c782384cb7..6a08cfc304 100644 --- a/services/web/public/stylesheets/app/editor/chat.less +++ b/services/web/public/stylesheets/app/editor/chat.less @@ -31,13 +31,18 @@ right: 0; bottom: @new-message-height; overflow-x: hidden; + background-color: @chat-bg; + li.message { margin: @line-height-computed / 2; .date { font-size: 12px; - color: @gray-light; - border-bottom: 1px solid @gray-lightest; + color: @chat-message-date-color; margin-bottom: @line-height-computed / 2; + text-align: right; + } + .date when (@is-overleaf = false) { + border-bottom: 1px solid @gray-lightest; text-align: center; } .avatar { @@ -56,20 +61,22 @@ .name { font-size: 12px; - color: @gray-light; + color: @chat-message-name-color; margin-bottom: 4px; min-height: 16px; } .message { border-left: 3px solid transparent; font-size: 14px; - box-shadow: -1px 2px 3px #ddd; - border-raduis: @border-radius-base; + box-shadow: @chat-message-box-shadow; + border-radius: @chat-message-border-radius; position: relative; .message-content { - padding: @line-height-computed / 2; + padding: @chat-message-padding; overflow-x: auto; + color: @chat-message-color; + font-weight: @chat-message-weight; } .arrow { @@ -124,7 +131,7 @@ .full-size; top: auto; height: @new-message-height; - background-color: @gray-lightest; + background-color: @chat-new-message-bg; padding: @line-height-computed / 4; border-top: 1px solid @editor-border-color; textarea { @@ -134,9 +141,10 @@ border: 1px solid @editor-border-color; height: 100%; width: 100%; - color: @gray-dark; + color: @chat-new-message-textarea-color; font-size: 14px; padding: @line-height-computed / 4; + background-color: @chat-new-message-textarea-bg; } } } @@ -145,7 +153,7 @@ word-break: break-all; } -.editor-dark { +.editor-dark when (@is-overleaf = false) { .chat { .new-message { background-color: lighten(@editor-dark-background-color, 10%); diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index b6dca4b7cc..2824fd2e32 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -169,9 +169,19 @@ font-size: 0.8rem; line-height: @line-height-computed; } - .docs { - font-weight: bold; + .doc { font-size: 0.9rem; + font-weight: bold; + } + .action { + color: @gray; + text-transform: uppercase; + font-size: 0.7em; + margin-bottom: -2px; + margin-top: 2px; + &-edited { + margin-top: 0; + } } } li.loading-changes, li.empty-message { diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less index e602d2d99b..d59ca93069 100644 --- a/services/web/public/stylesheets/app/editor/pdf.less +++ b/services/web/public/stylesheets/app/editor/pdf.less @@ -10,9 +10,13 @@ padding: 0 (@line-height-computed / 2); } +.pdf { + background-color: @pdf-bg; +} + .pdf-viewer, .pdf-logs, .pdf-errors, .pdf-uncompiled { .full-size; - top: 58px; + top: @pdf-top-offset; } .pdf-logs, .pdf-errors, .pdf-uncompiled, .pdf-validation-problems{ @@ -69,11 +73,11 @@ } .pdfjs-viewer { .full-size; - background-color: @gray-lighter; + background-color: @pdfjs-bg; overflow: scroll; canvas, div.pdf-canvas { background: white; - box-shadow: black 0px 0px 10px; + box-shadow: @pdf-page-shadow-color 0px 0px 10px; } div.pdf-canvas.pdfng-empty { background-color: white; @@ -179,7 +183,7 @@ cursor: pointer; .line-no { float: right; - color: @gray; + color: @log-line-no-color; font-weight: 700; .fa { @@ -203,16 +207,25 @@ } } - &.alert-danger:hover { - background-color: darken(@alert-danger-bg, 5%); + &.alert-danger { + background-color: tint(@alert-danger-bg, 15%); + &:hover { + background-color: @alert-danger-bg; + } } - &.alert-warning:hover { - background-color: darken(@alert-warning-bg, 5%); + &.alert-warning { + background-color: tint(@alert-warning-bg, 15%); + &:hover { + background-color: @alert-warning-bg; + } } - &.alert-info:hover { - background-color: darken(@alert-info-bg, 5%); + &.alert-info { + background-color: tint(@alert-info-bg, 15%); + &:hover { + background-color: @alert-info-bg; + } } } @@ -354,22 +367,22 @@ } .alert-danger & { - color: @alert-danger-border; + color: @state-danger-border; } .alert-warning & { - color: @alert-warning-border; + color: @state-warning-border; } .alert-info & { - color: @alert-info-border; + color: @state-info-border; } } &-text, &-feedback-label { - color: @gray-dark; + color: @log-hints-color; font-size: 0.9rem; margin-bottom: 20px; } @@ -394,25 +407,25 @@ &-actions a, &-text a { .alert-danger & { - color: @alert-danger-text; + color: @state-danger-text; } .alert-warning & { - color: @alert-warning-text; + color: @state-warning-text; } .alert-info & { - color: @alert-info-text; + color: @state-info-text; } } &-feedback { - color: @gray-dark; + color: @log-hints-color; float: right; } &-extra-feedback { - color: @gray-dark; + color: @log-hints-color; font-size: 0.8rem; margin-top: 10px; padding-bottom: 5px; diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index 5c00f00567..8370e40959 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -129,11 +129,11 @@ } .toolbar-small-mixin() { - height: 32px; + height: @toolbar-small-height; } .toolbar-tall-mixin() { - height: 58px; + height: @toolbar-tall-height; padding-top: 10px; } .toolbar-alt-mixin() { @@ -143,7 +143,7 @@ .toolbar-label { display: none; margin: 0 4px; - font-size: 12px; + font-size: @toolbar-font-size; font-weight: 600; margin-bottom: 2px; vertical-align: middle; diff --git a/services/web/public/stylesheets/app/list/v1-import-modal.less b/services/web/public/stylesheets/app/list/v1-import-modal.less index 64bb9fda37..eac07d8772 100644 --- a/services/web/public/stylesheets/app/list/v1-import-modal.less +++ b/services/web/public/stylesheets/app/list/v1-import-modal.less @@ -2,8 +2,34 @@ text-align: center; } +.v1-import-row { + display: flex; + align-items: center; +} + +.v1-import-col { + flex-basis: 50%; + flex-grow: 0; + flex-shrink: 0; + padding-left: 15px; + padding-right: 15px; +} + + .v1-import-col ul { + margin-bottom: 0; + } + .v1-import-img { width: 100%; + margin-top: 30px; +} + +.v1-import-cta { + margin-top: 20px; + margin-left: auto; + margin-right: auto; + width: 90%; + text-align: center; } .v1-import-warning { diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 732b2e7903..1c3c4490ae 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -57,7 +57,6 @@ } .project-list-sidebar when (@is-overleaf) { - height: 100%; overflow-x: hidden; overflow-y: auto; -ms-overflow-style: -ms-autohiding-scrollbar; diff --git a/services/web/public/stylesheets/app/sidebar-v2-dash-pane.less b/services/web/public/stylesheets/app/sidebar-v2-dash-pane.less new file mode 100644 index 0000000000..d1586b4a5c --- /dev/null +++ b/services/web/public/stylesheets/app/sidebar-v2-dash-pane.less @@ -0,0 +1,27 @@ +@v2-dash-pane-link-height: 130px; + +.project-list-sidebar { + height: calc(~"100% -" @v2-dash-pane-link-height); +} + +.project-list-sidebar-v2-pane { + position: absolute; + bottom: 0; + height: @v2-dash-pane-link-height; + background-color: @v2-dash-pane-bg; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + color: white; + font-size: 13px; +} + + .project-list-sidebar-v2-pane a { + color: @v2-dash-pane-link-color; + text-decoration: underline; + } + + .project-list-sidebar-v2-pane a:hover { + text-decoration: none; + } diff --git a/services/web/public/stylesheets/components/alerts.less b/services/web/public/stylesheets/components/alerts.less index 527c97b151..6ed93d29d4 100755 --- a/services/web/public/stylesheets/components/alerts.less +++ b/services/web/public/stylesheets/components/alerts.less @@ -10,7 +10,7 @@ padding: @alert-padding; margin-bottom: @line-height-computed; border-left: 3px solid transparent; - // border-radius: @alert-border-radius; + border-radius: @alert-border-radius; // Headings for larger alerts h4 { diff --git a/services/web/public/stylesheets/components/card.less b/services/web/public/stylesheets/components/card.less index 1e06fbe3b4..ac43c038b7 100644 --- a/services/web/public/stylesheets/components/card.less +++ b/services/web/public/stylesheets/components/card.less @@ -1,7 +1,6 @@ .card { background-color: white; border-radius: @border-radius-base; - -webkit-box-shadow: @card-box-shadow; box-shadow: @card-box-shadow; padding: @line-height-computed; .page-header { diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index ab3ef2ccc9..f354893661 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -20,14 +20,9 @@ //== Typography // //## Font, line-height, and color for body text, headings, and more. - -@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700); -//@import url(https://fonts.googleapis.com/css?family=PT+Serif:400,600,700); -//@import url(https://fonts.googleapis.com/css?family=PT+Serif:400,400i,700,700i); -@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i); - @font-family-sans-serif: "Open Sans", sans-serif; @font-family-serif: "Merriweather", serif; + //** Default monospace fonts for ``, ``, and `
`.
 @font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
 @font-family-base:        @font-family-sans-serif;
@@ -550,7 +545,7 @@
 //## Define alert colors, border radius, and padding.
 
 @alert-padding:               15px;
-@alert-border-radius:         @border-radius-base;
+@alert-border-radius:         0;
 @alert-link-font-weight:      bold;
 
 @alert-success-bg:            @state-success-bg;
@@ -898,25 +893,28 @@
 @toolbar-btn-active-color       : white;
 @toolbar-btn-active-bg-color    : @link-color;
 @toolbar-btn-active-shadow      : inset 0 3px 5px rgba(0, 0, 0, 0.225);
+@toolbar-font-size              : 12px;
 @toolbar-alt-bg-color           : #fafafa;
 @toolbar-icon-btn-color         : @gray-light;
 @toolbar-icon-btn-hover-color   : @gray-dark;
 @toolbar-icon-btn-hover-shadow  : 0 1px 0 rgba(0, 0, 0, 0.25);
 @toolbar-icon-btn-hover-boxshadow : inset 0 3px 5px rgba(0, 0, 0, 0.225);
-@toolbar-border-bottom          : 1px solid @toolbar-border-color;
+@toolbar-border-bottom            : 1px solid @toolbar-border-color;
+@toolbar-small-height             : 32px;
+@toolbar-tall-height              : 58px;
 
 // Editor file-tree
-@file-tree-bg                   : transparent;
-@file-tree-line-height          : 2.6;
-@file-tree-item-color           : @gray-darker;
-@file-tree-item-toggle-color    : @gray;
-@file-tree-item-icon-color      : @gray-light;
-@file-tree-item-input-color     : inherit;
-@file-tree-item-folder-color    : lighten(desaturate(@link-color, 10%), 5%);
-@file-tree-item-hover-bg        : @gray-lightest;
-@file-tree-item-selected-bg     : transparent;
-@file-tree-multiselect-bg       : lighten(@brand-info, 40%);
-@file-tree-multiselect-hover-bg : lighten(@brand-info, 30%);
+@file-tree-bg                    : transparent;
+@file-tree-line-height           : 2.6;
+@file-tree-item-color            : @gray-darker;
+@file-tree-item-toggle-color     : @gray;
+@file-tree-item-icon-color       : @gray-light;
+@file-tree-item-input-color      : inherit;
+@file-tree-item-folder-color     : lighten(desaturate(@link-color, 10%), 5%);
+@file-tree-item-hover-bg         : @gray-lightest;
+@file-tree-item-selected-bg      : transparent;
+@file-tree-multiselect-bg        : lighten(@brand-info, 40%);
+@file-tree-multiselect-hover-bg  : lighten(@brand-info, 30%);
 
 // Editor resizers
 @editor-resizer-bg-color          : #F4F4F4;
@@ -925,6 +923,28 @@
 @editor-toggler-hover-bg-color    : #DDD;
 @synctex-controls-z-index         : 3;
 @synctex-controls-padding         : 0 2px;
+
+// Chat
+@chat-bg                          : transparent;
+@chat-message-color               : @text-color;
+@chat-message-date-color          : @gray-light;
+@chat-message-name-color          : @gray-light;
+@chat-message-box-shadow          : -1px 2px 3px #ddd;
+@chat-message-border-radius       : 0;
+@chat-message-padding             : @line-height-computed / 2;
+@chat-message-weight              : normal;
+@chat-new-message-bg              : @gray-lightest;
+@chat-new-message-textarea-bg     : #FFF;
+@chat-new-message-textarea-color  : @gray-dark;
+
+// PDF
+@pdf-top-offset                  : @toolbar-tall-height;
+@pdf-bg                          : transparent;
+@pdfjs-bg                        : @gray-lighter;
+@pdf-page-shadow-color           : #000;
+@log-line-no-color               : @gray; 
+@log-hints-color                 : @gray-dark;
+
 // Tags
 @tag-border-radius  : 0.25em;
 @tag-bg-color       : @label-default-bg;
diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less
index 004bc343d8..2485dd3853 100644
--- a/services/web/public/stylesheets/core/ol-variables.less
+++ b/services/web/public/stylesheets/core/ol-variables.less
@@ -1,6 +1,9 @@
 @import "./_common-variables.less";
 
 @is-overleaf: true;
+
+@font-family-sans-serif: "Lato", sans-serif;
+
 @header-height: 68px;
 @footer-height: 50px;
 
@@ -64,6 +67,28 @@
 @btn-info-bg             : @ol-blue;
 @btn-info-border         : transparent;
 
+// Alerts
+@alert-padding : 15px;
+@alert-border-radius : @border-radius-base;
+@alert-link-font-weight : bold;
+
+@alert-success-bg    : @brand-success;
+@alert-success-text  : #FFF;
+@alert-success-border: transparent;
+
+@alert-info-bg       : @brand-info;
+@alert-info-text     : #FFF;
+@alert-info-border   : transparent;
+
+@alert-warning-bg    : @brand-warning;
+@alert-warning-text  : #FFF;
+@alert-warning-border: transparent;
+
+@alert-danger-bg     : @brand-danger;
+@alert-danger-text   : #FFF;
+@alert-danger-border : transparent;
+
+
 // Tags
 @tag-border-radius       : 9999px;
 @tag-bg-color            : @ol-green;
@@ -110,6 +135,10 @@
 @sidebar-active-bg              : @ol-blue-gray-6;
 @sidebar-hover-bg               : @ol-blue-gray-4;
 @sidebar-hover-text-decoration  : none;
+@v2-dash-pane-bg                : @ol-blue-gray-4;
+@v2-dash-pane-link-color        : #FFF;
+@v2-dash-pane-btn-bg            : @ol-blue-gray-5;
+@v2-dash-pane-btn-hover-bg      : @ol-blue-gray-6;
 
 @folders-menu-margin            : 0 -(@grid-gutter-width / 2);
 @folders-menu-line-height       : @structured-list-line-height;
@@ -173,19 +202,22 @@
 @toolbar-icon-btn-hover-shadow    : none;
 @toolbar-border-bottom            : 1px solid @toolbar-border-color;
 @toolbar-icon-btn-hover-boxshadow : none;
+@toolbar-font-size                : 13px;
+
 // Editor file-tree
-@file-tree-bg                     : @ol-blue-gray-4;
-@file-tree-line-height            : 2.05;
-@file-tree-item-color             : #FFF;
-@file-tree-item-input-color       : @ol-blue-gray-5;
-@file-tree-item-toggle-color      : @ol-blue-gray-2;
-@file-tree-item-icon-color        : @ol-blue-gray-2;
-@file-tree-item-folder-color      : @ol-blue-gray-2;
-@file-tree-item-hover-bg          : @ol-blue-gray-5;
-@file-tree-item-selected-bg       : @ol-green;
-@file-tree-multiselect-bg         : @ol-blue;
-@file-tree-multiselect-hover-bg   : @ol-dark-blue;
-@file-tree-droppable-bg-color     : tint(@ol-green, 5%);
+@file-tree-bg                   : @ol-blue-gray-4;
+@file-tree-line-height          : 2.05;
+@file-tree-item-color           : #FFF;
+@file-tree-item-input-color     : @ol-blue-gray-5;
+@file-tree-item-toggle-color    : @ol-blue-gray-2;
+@file-tree-item-icon-color      : @ol-blue-gray-2;
+@file-tree-item-folder-color    : @ol-blue-gray-2;
+@file-tree-item-hover-bg        : @ol-blue-gray-5;
+@file-tree-item-selected-bg     : @ol-green;
+@file-tree-multiselect-bg       : @ol-blue;
+@file-tree-multiselect-hover-bg : @ol-dark-blue;
+@file-tree-droppable-bg-color   : tint(@ol-green, 5%);
+
 // Editor resizers
 @editor-resizer-bg-color          : @ol-blue-gray-6;
 @editor-resizer-bg-color-dragging : transparent;
@@ -193,6 +225,31 @@
 @editor-toggler-hover-bg-color    : @ol-green;
 @synctex-controls-z-index         : 6;
 @synctex-controls-padding         : 0;
+@editor-border-color              : @ol-blue-gray-5;
+
+// Chat
+@chat-bg                          : @ol-blue-gray-5;
+@chat-message-color               : #FFF;
+@chat-message-name-color          : #FFF;
+@chat-message-date-color          : @ol-blue-gray-2;
+@chat-message-box-shadow          : none;
+@chat-message-padding             : 5px 10px;
+@chat-message-border-radius       : @border-radius-large;
+@chat-message-weight              : bold;
+@chat-new-message-bg              : @ol-blue-gray-4;
+@chat-new-message-textarea-bg     : @ol-blue-gray-1;
+@chat-new-message-textarea-color  : @ol-blue-gray-6;
+
+// PDF
+@pdf-top-offset                 : @toolbar-small-height;
+@pdf-bg                         : @ol-blue-gray-1;
+@pdfjs-bg                       : transparent;
+@pdf-page-shadow-color          : rgba(0, 0, 0, 0.5);
+@log-line-no-color              : #FFF;
+@log-hints-color                : @ol-blue-gray-4;
+
+//== Colors
+//
 //## Gray and brand colors for use across Bootstrap.
 @gray-darker:           #252525;
 @gray-dark:             #505050;
@@ -212,9 +269,9 @@
 
 @brand-primary:         @ol-green;
 @brand-success:         @green;
-@brand-info:            @ol-dark-green;
+@brand-info:            @ol-blue;
 @brand-warning:         @orange;
-@brand-danger:          #E03A06;
+@brand-danger:          @ol-red;
 
 @editor-loading-logo-padding-top: 115.44%;
 @editor-loading-logo-background-url: url(/img/ol-brand/overleaf-o-grey.svg);
diff --git a/services/web/public/stylesheets/ol-style.less b/services/web/public/stylesheets/ol-style.less
index 1e48b7284d..540d106ab3 100644
--- a/services/web/public/stylesheets/ol-style.less
+++ b/services/web/public/stylesheets/ol-style.less
@@ -1,4 +1,8 @@
+@import url(https://fonts.googleapis.com/css?family=Lato:300,400,700&subset=latin-ext);
+@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i);
+
 // Core variables and mixins
 @import "core/ol-variables.less";
 @import "app/ol-style-guide.less";
-@import "_style_includes.less";
\ No newline at end of file
+@import "_style_includes.less";
+@import "_ol_style_includes.less";
\ No newline at end of file
diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less
index 760f378719..ae5820453f 100755
--- a/services/web/public/stylesheets/style.less
+++ b/services/web/public/stylesheets/style.less
@@ -1,3 +1,6 @@
+@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);
+@import url(https://fonts.googleapis.com/css?family=Merriweather:400,400i,700,700i);
+
 // Core variables and mixins
 @import "core/variables.less";
 @import "_style_includes.less";
\ No newline at end of file
diff --git a/services/web/test/acceptance/coffee/ProjectStructureTests.coffee b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee
index e54d4fac9d..e037deb5a3 100644
--- a/services/web/test/acceptance/coffee/ProjectStructureTests.coffee
+++ b/services/web/test/acceptance/coffee/ProjectStructureTests.coffee
@@ -59,7 +59,7 @@ describe "ProjectStructureChanges", ->
 				@dup_project_id = body.project_id
 				done()
 
-		it "should version the dosc created", ->
+		it "should version the docs created", ->
 			updates = MockDocUpdaterApi.getProjectStructureUpdates(@dup_project_id).docUpdates
 			expect(updates.length).to.equal(2)
 			_.each updates, (update) =>
@@ -91,6 +91,7 @@ describe "ProjectStructureChanges", ->
 					throw error if error?
 					if res.statusCode < 200 || res.statusCode >= 300
 						throw new Error("failed to add doc #{res.statusCode}")
+					@example_doc_id = body._id
 					done()
 
 		it "should version the doc added", ->
@@ -162,6 +163,8 @@ describe "ProjectStructureChanges", ->
 				if res.statusCode < 200 || res.statusCode >= 300
 					throw new Error("failed to upload file #{res.statusCode}")
 
+				@example_file_id = JSON.parse(body).entity_id
+
 				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
 				expect(updates.length).to.equal(1)
 				update = updates[0]
@@ -199,6 +202,120 @@ describe "ProjectStructureChanges", ->
 
 				done()
 
+	describe "moving entities", ->
+		before (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'foo'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_1 = JSON.parse(body)._id
+				done()
+
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version moving a doc", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/Doc/#{@example_doc_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/new.tex")
+				expect(update.newPathname).to.equal("/foo/new.tex")
+
+				done()
+
+		it "should version moving a file", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/File/#{@example_file_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move file #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/1pixel.png")
+				expect(update.newPathname).to.equal("/foo/1pixel.png")
+
+				done()
+
+		it "should version moving a folder", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'bar'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_2 = JSON.parse(body)._id
+
+				@owner.request.post {
+					uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_1}/move",
+					json:
+						folder_id: @example_folder_id_2
+				}, (error, res, body) =>
+					throw error if error?
+					if res.statusCode < 200 || res.statusCode >= 300
+						throw new Error("failed to move folder #{res.statusCode}")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/new.tex")
+					expect(update.newPathname).to.equal("/bar/foo/new.tex")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/1pixel.png")
+					expect(update.newPathname).to.equal("/bar/foo/1pixel.png")
+
+					done()
+
+	describe "deleting entities", ->
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version deleting a folder", (done) ->
+			@owner.request.delete {
+				uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_2}",
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete folder #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/new.tex")
+				expect(update.newPathname).to.equal("")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/1pixel.png")
+				expect(update.newPathname).to.equal("")
+
+				done()
+
 	describe "tpds", ->
 		before (done) ->
 			@tpds_project_name = "tpds-project-#{new ObjectId().toString()}"
@@ -305,3 +422,25 @@ describe "ProjectStructureChanges", ->
 				done()
 
 			image_file.pipe(req)
+
+		it "should version deleting a doc", (done) ->
+			req = @owner.request.delete {
+				uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/test.tex",
+				auth:
+					user: _.keys(Settings.httpAuthUsers)[0]
+					pass: _.values(Settings.httpAuthUsers)[0]
+					sendImmediately: true
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/test.tex")
+				expect(update.newPathname).to.equal("")
+
+				done()
+
diff --git a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
index b00cd6b173..b21fb1adab 100644
--- a/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
+++ b/services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
@@ -33,6 +33,9 @@ module.exports = MockDocUpdaterApi =
 			@addProjectStructureUpdates(project_id, userId, docUpdates, fileUpdates)
 			res.sendStatus 200
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			res.send 204
+
 		app.listen 3003, (error) ->
 			throw error if error?
 		.on "error", (error) ->
diff --git a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee
index c5b003ac75..631538fd89 100644
--- a/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee
+++ b/services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee
@@ -23,6 +23,16 @@ module.exports = MockDocStoreApi =
 			docs = (doc for doc_id, doc of @docs[req.params.project_id])
 			res.send JSON.stringify docs
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			{project_id, doc_id} = req.params
+			if !@docs[project_id]?
+				res.send 404
+			else if !@docs[project_id][doc_id]?
+				res.send 404
+			else
+				@docs[project_id][doc_id] = undefined
+				res.send 204
+
 		app.listen 3016, (error) ->
 			throw error if error?
 		.on "error", (error) ->
diff --git a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
index 14ccaa3a33..a4e0a4dc53 100644
--- a/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
+++ b/services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
@@ -393,7 +393,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history disabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
+				@settings.apis.project_history.sendProjectStructureOps = false
 				@request.post = sinon.stub()
 
 				@handler.updateProjectStructure @project_id, @user_id, {}, @callback
@@ -406,7 +406,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history enabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
+				@settings.apis.project_history.sendProjectStructureOps = true
 				@url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}"
 				@request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "")
 
@@ -478,14 +478,22 @@ describe 'DocumentUpdaterHandler', ->
 							.should.equal true
 						done()
 
-			describe "when a doc has been deleted", ->
-				it 'should do nothing', (done) ->
+			describe "when an entity has been deleted", ->
+				it 'should end the structure update to the document updater', (done) ->
 					@docId = new ObjectId()
 					@changes = oldDocs: [
 						{ path: '/foo', docLines: 'a\nb', doc: _id: @docId }
 					]
 
+					docUpdates = [
+						id: @docId.toString(),
+						pathname: '/foo',
+						newPathname: ''
+					]
+
 					@handler.updateProjectStructure @project_id, @user_id, @changes, () =>
-						@request.post.called.should.equal false
+						@request.post
+							.calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id})
+							.should.equal true
 						done()
 
diff --git a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
index 4e1af79b46..85760f510d 100644
--- a/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
+++ b/services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
@@ -376,58 +376,53 @@ describe "EditorController", ->
 				err.should.equal "timed out"
 				done()			
 
-
 	describe "deleteEntity", ->
-
 		beforeEach ->
 			@LockManager.getLock.callsArgWith(1)
 			@LockManager.releaseLock.callsArgWith(1)
-			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(4)
+			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(5)
 
 		it "should call deleteEntityWithoutLock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
-				@EditorController.deleteEntityWithoutLock.calledWith(@project_id, @entity_id, @type, @source).should.equal true
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
+				@EditorController.deleteEntityWithoutLock
+					.calledWith(@project_id, @entity_id, @type, @source, @user_id)
+					.should.equal true
 				done()
 
 		it "should take the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
 				@LockManager.getLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should release the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (error)=>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
 				@LockManager.releaseLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should error if it can't cat the lock", (done)->
 			@LockManager.getLock = sinon.stub().callsArgWith(1, "timed out")
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (err)=>
-				expect(err).to.exist
-				err.should.equal "timed out"
-				done()			
-
-
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
+				expect(error).to.exist
+				error.should.equal "timed out"
+				done()
 
 	describe 'deleteEntityWithoutLock', ->
-		beforeEach ->
-			@ProjectEntityHandler.deleteEntity = (project_id, entity_id, type, callback)-> callback()
+		beforeEach (done) ->
 			@entity_id = "entity_id_here"
 			@type = "doc"
 			@EditorRealTimeController.emitToRoom = sinon.stub()
+			@ProjectEntityHandler.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, @user_id, done
 
-		it 'should delete the folder using the project entity handler', (done)->
-			mock = sinon.mock(@ProjectEntityHandler).expects("deleteEntity").withArgs(@project_id, @entity_id, @type).callsArg(3)
+		it 'should delete the folder using the project entity handler', ->
+			@ProjectEntityHandler.deleteEntity
+				.calledWith(@project_id, @entity_id, @type, @user_id)
+				.should.equal.true
 
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, ->
-				mock.verify()
-				done()
-
-		it 'notify users an entity has been deleted', (done)->
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, =>
-				@EditorRealTimeController.emitToRoom
-					.calledWith(@project_id, "removeEntity", @entity_id, @source)
-					.should.equal true
-				done()
+		it 'notify users an entity has been deleted', ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "removeEntity", @entity_id, @source)
+				.should.equal true
 
 	describe "getting a list of project paths", ->
 
diff --git a/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee b/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee
index e05846c5d5..38419e6b46 100644
--- a/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee
+++ b/services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee
@@ -331,12 +331,12 @@ describe "EditorHttpController", ->
 				Project_id: @project_id
 				entity_id: @entity_id = "entity-id-123"
 				entity_type: @entity_type = "entity-type"
-			@EditorController.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntity = sinon.stub().callsArg(5)
 			@EditorHttpController.deleteEntity @req, @res
 
 		it "should call EditorController.deleteEntity", ->
 			@EditorController.deleteEntity
-				.calledWith(@project_id, @entity_id, @entity_type, "editor")
+				.calledWith(@project_id, @entity_id, @entity_type, "editor", @userId)
 				.should.equal true
 
 		it "should send back a success response", ->
diff --git a/services/web/test/unit/coffee/History/HistoryControllerTests.coffee b/services/web/test/unit/coffee/History/HistoryControllerTests.coffee
index e03189526a..f0669a5902 100644
--- a/services/web/test/unit/coffee/History/HistoryControllerTests.coffee
+++ b/services/web/test/unit/coffee/History/HistoryControllerTests.coffee
@@ -31,7 +31,7 @@ describe "HistoryController", ->
 
 		describe "for a project with project history", ->
 			beforeEach ->
-				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{display:true}}})
+				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{id: 42, display:true}}})
 				@HistoryController.selectHistoryApi @req, @res, @next
 
 			it "should set the flag for project history to true", ->
@@ -57,93 +57,55 @@ describe "HistoryController", ->
 				on: (event, handler) -> @events[event] = handler
 			@request.returns @proxy
 
-		describe "with project history enabled", ->
+		describe "for a project with the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
+				@req.useProjectHistory = true
+				@HistoryController.proxyToHistoryApi @req, @res, @next
 
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
 
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
+			it "should call the project history api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.project_history.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
 
-				it "should call the project history api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.project_history.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
 
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-		describe "with project history disabled", ->
+		describe "for a project without the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
+				@req.useProjectHistory = false
+				@HistoryController.proxyToHistoryApi @req, @res, @next
 
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
 
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
+			it "should call the track changes api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.trackchanges.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
 
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
 
 		describe "with an error", ->
 			beforeEach ->
@@ -152,68 +114,3 @@ describe "HistoryController", ->
 
 			it "should pass the error up the call chain", ->
 				@next.calledWith(@error).should.equal true
-
-	describe "initializeProject", ->
-		describe "with project history enabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = true
-
-			describe "project history returns a successful response", ->
-				beforeEach ->
-					@overleaf_id = 1234
-					@res = statusCode: 200
-					@body = JSON.stringify(project: id: @overleaf_id)
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should call the project history api", ->
-					@request.post.calledWith(
-						url: "#{@settings.apis.project_history.url}/project"
-					).should.equal true
-
-				it "should return the callback with the overleaf id", ->
-					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
-
-			describe "project history returns a response without the project id", ->
-				beforeEach ->
-					@res = statusCode: 200
-					@body = JSON.stringify(project: {})
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
-						.should.equal true
-
-			describe "project history returns a unsuccessful response", ->
-				beforeEach ->
-					@res = statusCode: 404
-					@request.post = sinon.stub().callsArgWith(1, null, @res)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
-						.should.equal true
-
-			describe "project history errors", ->
-				beforeEach ->
-					@error = sinon.stub()
-					@request.post = sinon.stub().callsArgWith(1, @error)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with the error", ->
-					@callback.calledWithExactly(@error).should.equal true
-
-		describe "with project history disabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = false
-				@HistoryController.initializeProject @callback
-
-			it "should return the callback", ->
-				@callback.calledWithExactly().should.equal true
diff --git a/services/web/test/unit/coffee/History/HistoryManagerTests.coffee b/services/web/test/unit/coffee/History/HistoryManagerTests.coffee
new file mode 100644
index 0000000000..9b5f80df04
--- /dev/null
+++ b/services/web/test/unit/coffee/History/HistoryManagerTests.coffee
@@ -0,0 +1,86 @@
+chai = require('chai')
+chai.should()
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/History/HistoryManager"
+SandboxedModule = require('sandboxed-module')
+
+describe "HistoryManager", ->
+	beforeEach ->
+		@callback = sinon.stub()
+		@user_id = "user-id-123"
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@user_id)
+		@HistoryManager = SandboxedModule.require modulePath, requires:
+			"request" : @request = sinon.stub()
+			"settings-sharelatex": @settings = {}
+		@settings.apis =
+			trackchanges:
+				enabled: false
+				url: "http://trackchanges.example.com"
+			project_history:
+				url: "http://project_history.example.com"
+
+	describe "initializeProject", ->
+		describe "with project history enabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = true
+
+			describe "project history returns a successful response", ->
+				beforeEach ->
+					@overleaf_id = 1234
+					@res = statusCode: 200
+					@body = JSON.stringify(project: id: @overleaf_id)
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should call the project history api", ->
+					@request.post.calledWith(
+						url: "#{@settings.apis.project_history.url}/project"
+					).should.equal true
+
+				it "should return the callback with the overleaf id", ->
+					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
+
+			describe "project history returns a response without the project id", ->
+				beforeEach ->
+					@res = statusCode: 200
+					@body = JSON.stringify(project: {})
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
+						.should.equal true
+
+			describe "project history returns a unsuccessful response", ->
+				beforeEach ->
+					@res = statusCode: 404
+					@request.post = sinon.stub().callsArgWith(1, null, @res)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
+						.should.equal true
+
+			describe "project history errors", ->
+				beforeEach ->
+					@error = sinon.stub()
+					@request.post = sinon.stub().callsArgWith(1, @error)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with the error", ->
+					@callback.calledWithExactly(@error).should.equal true
+
+		describe "with project history disabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = false
+				@HistoryManager.initializeProject @callback
+
+			it "should return the callback", ->
+				@callback.calledWithExactly().should.equal true
diff --git a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
index 470b538bd9..378e909984 100644
--- a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
@@ -38,7 +38,7 @@ describe 'ProjectCreationHandler', ->
 			setRootDoc: sinon.stub().callsArg(2)
 		@ProjectDetailsHandler =
 			validateProjectName: sinon.stub().yields()
-		@HistoryController =
+		@HistoryManager =
 			initializeProject: sinon.stub().callsArg(0)
 
 		@user =
@@ -53,7 +53,7 @@ describe 'ProjectCreationHandler', ->
 			'../../models/User': User:@User
 			'../../models/Project':{Project:@ProjectModel}
 			'../../models/Folder':{Folder:@FolderModel}
-			'../History/HistoryController': @HistoryController
+			'../History/HistoryManager': @HistoryManager
 			'./ProjectEntityHandler':@ProjectEntityHandler
 			"./ProjectDetailsHandler":@ProjectDetailsHandler
 			"settings-sharelatex": @Settings = {}
@@ -66,7 +66,7 @@ describe 'ProjectCreationHandler', ->
 	describe 'Creating a Blank project', ->
 		beforeEach ->
 			@overleaf_id = 1234
-			@HistoryController.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
+			@HistoryManager.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
 			@ProjectModel::save = sinon.stub().callsArg(0)
 
 		describe "successfully", ->
@@ -83,7 +83,7 @@ describe 'ProjectCreationHandler', ->
 
 			it "should initialize the project overleaf if history id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, done
-				@HistoryController.initializeProject.calledWith().should.equal true
+				@HistoryManager.initializeProject.calledWith().should.equal true
 
 			it "should set the overleaf id if overleaf id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, (err, project)=>
diff --git a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee
index b489014e7e..b7afb79f83 100644
--- a/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee
@@ -64,7 +64,7 @@ describe 'ProjectDuplicator', ->
 		@projectOptionsHandler =
 			setCompiler : sinon.stub()								
 		@entityHandler =
-			addDocWithProject: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
+			addDoc: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
 			copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(5)
 			setRootDoc: sinon.stub()
 			addFolderWithProject: sinon.stub().callsArgWith(3, null, @newFolder)
@@ -112,13 +112,13 @@ describe 'ProjectDuplicator', ->
 			done()
 
 	it 'should use the same compiler', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true
 			done()
 	
 	it 'should use the same root doc', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true
 			done()
@@ -139,13 +139,13 @@ describe 'ProjectDuplicator', ->
 	it 'should copy all the docs', (done)->
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@DocstoreManager.getAllDocs.calledWith(@old_project_id).should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @doc0.name, @doc0_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc1.name, @doc1_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc2.name, @doc2_lines, @owner._id)
 				.should.equal true
 			done()
diff --git a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee
index f62690e226..a4ac6a7f02 100644
--- a/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee
@@ -157,13 +157,13 @@ describe 'ProjectEntityHandler', ->
 			@ProjectGetter.getProject.callsArgWith(2, null, @project)
 			@tpdsUpdateSender.deleteEntity = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._removeElementFromMongoArray = sinon.stub().callsArg(3)
-			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(3)
+			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(5)
 			@path = mongo: "mongo.path", fileSystem: "/file/system/path"
 			@projectLocator.findElement = sinon.stub().callsArgWith(1, null, @entity = { _id: entity_id }, @path)
 
 		describe "deleting from Mongo", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', done
+				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', userId, done
 
 			it "should retreive the path", ->
 				@projectLocator.findElement.called.should.equal true
@@ -182,7 +182,7 @@ describe 'ProjectEntityHandler', ->
 
 			it "should clean up the entity from the rest of the system", ->
 				@ProjectEntityHandler._cleanUpEntity
-					.calledWith(@project, @entity, @type)
+					.calledWith(@project, @entity, @type, @path.fileSystem, userId)
 					.should.equal true
 
 	describe "_cleanUpEntity", ->
@@ -193,7 +193,9 @@ describe 'ProjectEntityHandler', ->
 
 		describe "a file", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpEntity @project, _id: @entity_id, 'file', done
+				@path = "/file/system/path.png"
+				@entity = _id: @entity_id
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'file', @path, userId, done
 
 			it "should delete the file from FileStoreHandler", ->
 				@FileStoreHandler.deleteFile.calledWith(project_id, @entity_id).should.equal true
@@ -201,38 +203,56 @@ describe 'ProjectEntityHandler', ->
 			it "should not attempt to delete from the document updater", ->
 				@documentUpdaterHandler.deleteDoc.called.should.equal false
 
+			it "should should send the update to the doc updater", ->
+				oldFiles = [ file: @entity, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldFiles})
+					.should.equal true
+
 		describe "a doc", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @entity = {_id: @entity_id}, 'doc', done
+				@path = "/file/system/path.tex"
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@entity = {_id: @entity_id}
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'doc', @path, userId, done
 
 			it "should clean up the doc", ->
 				@ProjectEntityHandler._cleanUpDoc
-					.calledWith(@project, @entity)
+					.calledWith(@project, @entity, @path, userId)
 					.should.equal true
 
 		describe "a folder", ->
 			beforeEach (done) ->
 				@folder =
 					folders: [
-						fileRefs: [ @file1 = {_id: "file-id-1" } ]
-						docs:     [ @doc1  = { _id: "doc-id-1" } ]
+						name: "subfolder"
+						fileRefs: [ @file1 = { _id: "file-id-1", name: "file-name-1"} ]
+						docs:     [ @doc1  = { _id: "doc-id-1", name: "doc-name-1" } ]
 						folders:  []
 					]
-					fileRefs: [ @file2 = { _id: "file-id-2" } ]
-					docs:     [ @doc2  = { _id: "doc-id-2" } ]
+					fileRefs: [ @file2 = { _id: "file-id-2", name: "file-name-2" } ]
+					docs:     [ @doc2  = { _id: "doc-id-2", name: "doc-name-2" } ]
 
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", done
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(4)
+				path = "/folder"
+				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", path, userId, done
 
 			it "should clean up all sub files", ->
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file1).should.equal true
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file2).should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file1, "/folder/subfolder/file-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file2, "/folder/file-name-2", userId)
+					.should.equal true
 
 			it "should clean up all sub docs", ->
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc1).should.equal true
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc2).should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc1, "/folder/subfolder/doc-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
+					.should.equal true
 
 	describe 'moveEntity', ->
 		beforeEach ->
@@ -496,6 +516,51 @@ describe 'ProjectEntityHandler', ->
 				.calledWith(project_id, userId, {newDocs})
 				.should.equal true
 
+	describe 'addDocWithoutUpdatingHistory', ->
+		beforeEach ->
+			@name = "some new doc"
+			@lines = ['1234','abc']
+			@path = "/path/to/doc"
+
+			@ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:@path}})
+			@callback = sinon.stub()
+			@tpdsUpdateSender.addDoc = sinon.stub().callsArg(1)
+			@DocstoreManager.updateDoc = sinon.stub().yields(null, true, 0)
+
+			@ProjectEntityHandler.addDocWithoutUpdatingHistory project_id, folder_id, @name, @lines, userId, @callback
+
+			# Created doc
+			@doc = @ProjectEntityHandler._putElement.args[0][2]
+			@doc.name.should.equal @name
+			expect(@doc.lines).to.be.undefined
+
+		it 'should call put element', ->
+			@ProjectEntityHandler._putElement.calledWith(@project, folder_id, @doc).should.equal true
+
+		it 'should return doc and parent folder', ->
+			@callback.calledWith(null, @doc, folder_id).should.equal true
+
+		it 'should call third party data store', ->
+			@tpdsUpdateSender.addDoc
+				.calledWith({
+					project_id: project_id
+					doc_id: doc_id
+					path: @path
+					project_name: @project.name
+					rev: 0
+				})
+				.should.equal true
+
+		it "should send the doc lines to the doc store", ->
+			@DocstoreManager.updateDoc
+				.calledWith(project_id, @doc._id.toString(), @lines)
+				.should.equal true
+
+		it "should not should send the change in project structure to the doc updater", () ->
+			@documentUpdaterHandler.updateProjectStructure
+				.called
+				.should.equal false
+
 	describe "restoreDoc", ->
 		beforeEach ->
 			@name = "doc-name"
@@ -584,6 +649,12 @@ describe 'ProjectEntityHandler', ->
 
 			@ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () ->
 
+		it "should not send the change in project structure to the doc updater when called as addFileWithoutUpdatingHistory", (done) ->
+			@documentUpdaterHandler.updateProjectStructure = sinon.stub().yields()
+			@ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, {}, userId, () =>
+				@documentUpdaterHandler.updateProjectStructure.called.should.equal false
+				done()
+
 	describe 'replaceFile', ->
 		beforeEach ->
 			@projectLocator
@@ -1116,6 +1187,7 @@ describe 'ProjectEntityHandler', ->
 			@doc =
 				_id: ObjectId()
 				name: "test.tex"
+			@path = "/path/to/doc"
 			@ProjectEntityHandler.unsetRootDoc = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._insertDeletedDocReference = sinon.stub().callsArg(2)
 			@documentUpdaterHandler.deleteDoc = sinon.stub().callsArg(2)
@@ -1125,7 +1197,7 @@ describe 'ProjectEntityHandler', ->
 		describe "when the doc is the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = @doc._id
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc
@@ -1146,13 +1218,19 @@ describe 'ProjectEntityHandler', ->
 					.calledWith(project_id, @doc._id.toString())
 					.should.equal true
 
+			it "should should send the update to the doc updater", ->
+				oldDocs = [ doc: @doc, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldDocs})
+					.should.equal true
+
 			it "should call the callback", ->
 				@callback.called.should.equal true
 
 		describe "when the doc is not the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = ObjectId()
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should not unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc.called.should.equal false
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
index 87ff474a0a..93479a03a1 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
+++ b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
@@ -57,6 +57,7 @@ describe "SubscriptionUpdater", ->
 
 		@ReferalAllocator = assignBonus:sinon.stub().callsArgWith(1)
 		@ReferalAllocator.cock = true
+		@Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}}
 		@SubscriptionUpdater = SandboxedModule.require modulePath, requires:
 			'../../models/Subscription': Subscription:@SubscriptionModel
 			'./UserFeaturesUpdater': @UserFeaturesUpdater
@@ -65,6 +66,7 @@ describe "SubscriptionUpdater", ->
 			"logger-sharelatex": log:->
 			'settings-sharelatex': @Settings
 			"../Referal/ReferalAllocator" : @ReferalAllocator
+			'../../infrastructure/Modules': @Modules
 
 
 	describe "syncSubscription", ->
@@ -204,10 +206,22 @@ describe "SubscriptionUpdater", ->
 				assert.equal args[1], @groupSubscription.planCode
 				done()
 
+		it "should call updateFeatures with the overleaf subscription if set", (done)->
+			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
+			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, null)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, ['ol_pro'])
+
+			@SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=>
+				args = @UserFeaturesUpdater.updateFeatures.args[0]
+				assert.equal args[0], @adminUser._id
+				assert.equal args[1], 'ol_pro'
+				done()
+
 		it "should call not call updateFeatures  with users subscription if the subscription plan code is the default one (downgraded)", (done)->
 			@subscription.planCode = @Settings.defaultPlanCode
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
 			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
 			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
 				args = @UserFeaturesUpdater.updateFeatures.args[0]
 				assert.equal args[0], @adminUser._id
@@ -218,6 +232,7 @@ describe "SubscriptionUpdater", ->
 		it "should call updateFeatures with default if there are no subscriptions for user", (done)->
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null)
 			@SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null)
+			@Modules.hooks.fire = sinon.stub().callsArgWith(2, null, null)
 			@SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=>
 				args = @UserFeaturesUpdater.updateFeatures.args[0]
 				assert.equal args[0], @adminUser._id
@@ -263,3 +278,13 @@ describe "SubscriptionUpdater", ->
 				@SubscriptionUpdater._setUsersMinimumFeatures
 					.calledWith(user_id)
 					.should.equal true
+
+	describe 'refreshSubscription', ->
+		beforeEach ->
+			@SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub()
+				.callsArgWith(1, null)
+
+		it 'should call to _setUsersMinimumFeatures', ->
+			@SubscriptionUpdater.refreshSubscription(@adminUser._id, ()->)
+			@SubscriptionUpdater._setUsersMinimumFeatures.callCount.should.equal 1
+			@SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true
diff --git a/services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee b/services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
index dedd3ea7c8..3984d6341f 100644
--- a/services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
+++ b/services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
@@ -8,7 +8,7 @@ describe 'TpdsUpdateHandler', ->
 	beforeEach ->
 		@requestQueuer = {}
 		@updateMerger = 
-			deleteUpdate: (user_id, path, source, cb)->cb()
+			deleteUpdate: (user_id, project_id, path, source, cb)->cb()
 			mergeUpdate:(user_id, project_id, path, update, source, cb)->cb()
 		@editorController = {}
 		@project_id = "dsjajilknaksdn"
@@ -107,11 +107,13 @@ describe 'TpdsUpdateHandler', ->
 		it 'should call deleteEntity in the collaberation manager', (done)->
 			path = "/delete/this"
 			update = {}
-			@updateMerger.deleteUpdate = sinon.stub().callsArg(3)
+			@updateMerger.deleteUpdate = sinon.stub().callsArg(4)
 
 			@handler.deleteUpdate @user_id, @project.name, path, @source, =>
 				@projectDeleter.markAsDeletedByExternalSource.calledWith(@project._id).should.equal false
-				@updateMerger.deleteUpdate.calledWith(@project_id, path, @source).should.equal true
+				@updateMerger.deleteUpdate
+					.calledWith(@user_id, @project_id, path, @source)
+					.should.equal true
 				done()
 
 		it 'should mark the project as deleted by external source if path is a single slash', (done)->
diff --git a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee
index e20419765f..cb2aa059ea 100644
--- a/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee
+++ b/services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee
@@ -145,13 +145,13 @@ describe 'UpdateMerger :', ->
 
 		it 'should get the element id', ->
 			@projectLocator.findElementByPath = sinon.spy()
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 			@projectLocator.findElementByPath.calledWith(@project_id, @path).should.equal true
 
 		it 'should delete the entity in the editor controller with the correct type', (done)->
 			@entity.lines = []
-			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source).callsArg(4)
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source, @user_id).callsArg(5)
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 				mock.verify()
 				done()
 
diff --git a/services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee b/services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee
new file mode 100644
index 0000000000..9ad7ba9a2e
--- /dev/null
+++ b/services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee
@@ -0,0 +1,134 @@
+Path = require 'path'
+SandboxedModule = require "sandboxed-module"
+modulePath = Path.join __dirname, '../../../public/js/ide/history/HistoryV2Manager'
+sinon = require("sinon")
+expect = require("chai").expect
+
+describe "HistoryV2Manager", ->
+	beforeEach ->
+		@moment = {}
+		@ColorManager = {}
+		SandboxedModule.require modulePath, globals:
+			"define": (dependencies, builder) =>
+				@HistoryV2Manager = builder(@moment, @ColorManager)
+
+		@scope =
+			$watch: sinon.stub()
+			$on: sinon.stub()
+		@ide = {}
+
+		@historyManager = new @HistoryV2Manager(@ide, @scope)
+
+	it "should setup the history scope on intialization", ->
+		expect(@scope.history).to.deep.equal({
+			isV2: true
+			updates: []
+			nextBeforeTimestamp: null
+			atEnd: false
+			selection: {
+				updates: []
+				pathname: null
+				range: {
+					fromV: null
+					toV: null
+				}
+			}
+			diff: null
+		})
+
+	describe "_perDocSummaryOfUpdates", ->
+		it "should return the range of updates for the docs", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main.tex"]
+				fromV: 7, toV: 9
+			},{
+				pathnames: ["main.tex", "foo.tex"]
+				fromV: 4, toV: 6
+			},{
+				pathnames: ["main.tex"]
+				fromV: 3, toV: 3
+			},{
+				pathnames: ["foo.tex"]
+				fromV: 0, toV: 2
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 3, toV: 9 },
+				"foo.tex": { fromV: 0, toV: 6 }
+			})
+
+		it "should track renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main2.tex"]
+				fromV: 5, toV: 9
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 4
+			},{
+				pathnames: ["main1.tex"]
+				fromV: 3, toV: 3
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main0.tex",
+						newPathname: "main1.tex"
+					}
+				}],
+				fromV: 2, toV: 2
+			},{
+				pathnames: ["main0.tex"]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main0.tex": { fromV: 0, toV: 9 }
+			})
+
+		it "should track single renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 5
+			}])
+
+			expect(result).to.deep.equal({
+				"main1.tex": { fromV: 4, toV: 5 }
+			})
+
+		it "should track additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}, {
+				pathnames: ["main.tex"]
+				fromV: 1, toV: 4
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 4 }
+			})
+
+		it "should track single additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 1 }
+			})