diff --git a/services/web/.gitignore b/services/web/.gitignore index 3830036f24..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 @@ -69,6 +70,7 @@ Gemfile.lock public/stylesheets/ol-style.*.css public/stylesheets/style.*.css +public/js/libs/require*.js *.swp 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/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 da22373870..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)-> @@ -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 002a10ca89..c99cc2935e 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -36,7 +36,6 @@ getFileContent = (filePath)-> logger.log filePath:filePath, "file does not exist for hashing" return "" -logger.log "Generating file hashes..." pathList = [ "#{jsPath}libs/require.js" "#{jsPath}ide.js" @@ -46,23 +45,27 @@ pathList = [ "/stylesheets/ol-style.css" ] -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(".")) +if !Settings.useMinifiedJs + logger.log "not using minified JS, not hashing static files" +else + logger.log "Generating file hashes..." + for path in pathList + content = getFileContent(path) + hash = crypto.createHash("md5").update(content).digest("hex") + + splitPath = path.split("/") + filenameSplit = splitPath.pop().split(".") + filenameSplit.splice(filenameSplit.length-1, 0, hash) + splitPath.push(filenameSplit.join(".")) - hashPath = splitPath.join("/") - hashedFiles[path] = hashPath + hashPath = splitPath.join("/") + hashedFiles[path] = hashPath - fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}" - fs.writeFileSync(fsHashPath, content) + fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}" + fs.writeFileSync(fsHashPath, content) -logger.log "Finished hashing static content" + logger.log "Finished hashing static content" cdnAvailable = Settings.cdn?.web?.host? darkCdnAvailable = Settings.cdn?.web?.darkHost? @@ -121,7 +124,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> res.locals.buildJsPath = (jsFile, opts = {})-> path = Path.join(jsPath, jsFile) - if opts.hashedPath + if opts.hashedPath && hashedFiles[path]? path = hashedFiles[path] if !opts.qs? @@ -141,7 +144,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> res.locals.buildCssPath = (cssFile, opts)-> path = Path.join("/stylesheets/", cssFile) - if opts?.hashedPath + if opts?.hashedPath && hashedFiles[path]? hashedPath = hashedFiles[path] return Url.resolve(staticFilesBase, hashedPath) return Url.resolve(staticFilesBase, path) @@ -294,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 f203d29357..151df0b0ac 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -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") diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index d1d0d94cf3..4a35b930ff 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -105,7 +105,7 @@ block requirejs //- 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()); 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/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/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/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/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/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/libraries.coffee b/services/web/public/coffee/libraries.coffee index 2b0b034fb7..e4532d7187 100644 --- a/services/web/public/coffee/libraries.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/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/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/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 329f1005f1..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;
@@ -177,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;
@@ -197,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;
@@ -216,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 2a9a140611..540d106ab3 100644
--- a/services/web/public/stylesheets/ol-style.less
+++ b/services/web/public/stylesheets/ol-style.less
@@ -1,3 +1,6 @@
+@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";
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 }
+			})