diff --git a/services/web/.gitignore b/services/web/.gitignore index a48481690a..5ab3a0e3f6 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -39,6 +39,7 @@ data/* app.js app/js/* test/unit/js/* +test/unit_frontend/js/* test/smoke/js/* test/acceptance/js/* cookies.txt @@ -67,6 +68,11 @@ public/minjs/ Gemfile.lock +public/stylesheets/ol-style.*.css +public/stylesheets/style.*.css +public/js/libs/require*.js + + *.swp .DS_Store diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index c49add4a91..a0000360b3 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -205,12 +205,12 @@ module.exports = (grunt) -> modules: [ { name: "main", - exclude: ["libs"] + exclude: ["libraries"] }, { name: "ide", - exclude: ["libs", "pdfjs-dist/build/pdf"] - }, { - name: "libs" + exclude: ["pdfjs-dist/build/pdf", "libraries"] + },{ + name: "libraries" },{ name: "ace/mode-latex" },{ diff --git a/services/web/Jenkinsfile b/services/web/Jenkinsfile index 6421296330..152d980787 100644 --- a/services/web/Jenkinsfile +++ b/services/web/Jenkinsfile @@ -60,7 +60,7 @@ pipeline { sh 'git config --global core.logallrefupdates false' sh 'mv app/views/external/robots.txt public/robots.txt' sh 'mv app/views/external/googlebdb0f8f7f4a17241.html public/googlebdb0f8f7f4a17241.html' - sh 'npm install' + sh 'npm --quiet install' sh 'npm rebuild' // It's too easy to end up shrinkwrapping to an outdated version of translations. // Ensure translations are always latest, regardless of shrinkwrap @@ -71,16 +71,9 @@ pipeline { } } - stage('Unit Tests') { + stage('Test') { steps { - sh 'make clean install' // Removes js files, so do before compile - sh 'make test_unit MOCHA_ARGS="--reporter=tap"' - } - } - - stage('Acceptance Tests') { - steps { - sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"' + sh 'make ci' } } @@ -155,6 +148,10 @@ pipeline { } post { + always { + sh 'make ci_clean' + } + failure { mail(from: "${EMAIL_ALERT_FROM}", to: "${EMAIL_ALERT_TO}", diff --git a/services/web/Makefile b/services/web/Makefile index 98695a8c1f..5db188e2b7 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -16,9 +16,10 @@ add_dev: docker-shared.yml $(NPM) install --save-dev ${P} install: docker-shared.yml + bin/generate_volumes_file $(NPM) install -clean: +clean: ci_clean rm -f app.js rm -rf app/js rm -rf test/unit/js @@ -30,9 +31,8 @@ clean: rm -rf $$dir/test/unit/js; \ rm -rf $$dir/test/acceptance/js; \ done - # Regenerate docker-shared.yml - not stictly a 'clean', - # but lets `make clean install` work nicely - bin/generate_volumes_file + +ci_clean: # Deletes node_modules volume docker-compose down --volumes @@ -40,11 +40,14 @@ clean: docker-shared.yml: bin/generate_volumes_file -test: test_unit test_acceptance +test: test_unit test_frontend test_acceptance test_unit: docker-shared.yml docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:unit -- ${MOCHA_ARGS} +test_frontend: docker-shared.yml + docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:frontend -- ${MOCHA_ARGS} + test_acceptance: test_acceptance_app test_acceptance_modules test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service @@ -71,7 +74,11 @@ test_acceptance_modules: docker-shared.yml test_acceptance_module: docker-shared.yml cd $(MODULE) && make test_acceptance +ci: + MOCHA_ARGS="--reporter tap" \ + $(MAKE) install test + .PHONY: - all add install update test test_unit test_acceptance \ + all add install update test test_unit test_frontend test_acceptance \ test_acceptance_start_service test_acceptance_stop_service \ - test_acceptance_run + test_acceptance_run ci ci_clean diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index f7dd9f3e17..e18b8c8123 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee @@ -205,10 +205,10 @@ module.exports = DocumentUpdaterHandler = callback new Error("doc updater returned a non-success status code: #{res.statusCode}") updateProjectStructure : (project_id, userId, changes, callback = (error) ->)-> - return callback() if !settings.apis.project_history?.enabled + return callback() if !settings.apis.project_history?.sendProjectStructureOps - docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', changes.oldDocs, changes.newDocs) - fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', changes.oldFiles, changes.newFiles) + docUpdates = DocumentUpdaterHandler._getUpdates('doc', changes.oldDocs, changes.newDocs) + fileUpdates = DocumentUpdaterHandler._getUpdates('file', changes.oldFiles, changes.newFiles) timer = new metrics.Timer("set-document") url = "#{settings.apis.documentupdater.url}/project/#{project_id}" @@ -230,7 +230,7 @@ module.exports = DocumentUpdaterHandler = logger.error {project_id, url}, "doc updater returned a non-success status code: #{res.statusCode}" callback new Error("doc updater returned a non-success status code: #{res.statusCode}") - _getRenameUpdates: (entityType, oldEntities, newEntities) -> + _getUpdates: (entityType, oldEntities, newEntities) -> oldEntities ||= [] newEntities ||= [] updates = [] @@ -255,6 +255,16 @@ module.exports = DocumentUpdaterHandler = pathname: oldEntity.path newPathname: newEntity.path + for id, oldEntity of oldEntitiesHash + newEntity = newEntitiesHash[id] + + if !newEntity? + # entity deleted + updates.push + id: id + pathname: oldEntity.path + newPathname: '' + updates PENDINGUPDATESKEY = "PendingUpdates" diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index f66094f9e8..532f515a10 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -105,19 +105,19 @@ module.exports = EditorController = async.series jobs, (err)-> callback err, newFolders, lastFolder - deleteEntity : (project_id, entity_id, entityType, source, callback)-> + deleteEntity : (project_id, entity_id, entityType, source, userId, callback)-> LockManager.getLock project_id, (err)-> if err? logger.err err:err, project_id:project_id, "could not get lock to deleteEntity" return callback(err) - EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, (err)-> + EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, userId, (err)-> LockManager.releaseLock project_id, ()-> callback(err) - deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)-> + deleteEntityWithoutLock: (project_id, entity_id, entityType, source, userId, callback)-> logger.log {project_id, entity_id, entityType, source}, "start delete process of entity" Metrics.inc "editor.delete-entity" - ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)-> + ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, userId, (err)-> if err? logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity" return callback(err) diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index cbc296b69f..16b1a79e31 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -147,6 +147,7 @@ module.exports = EditorHttpController = project_id = req.params.Project_id entity_id = req.params.entity_id entity_type = req.params.entity_type - EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) -> + user_id = AuthenticationController.getLoggedInUserId(req) + EditorController.deleteEntity project_id, entity_id, entity_type, "editor", user_id, (error) -> return next(error) if error? res.sendStatus 204 diff --git a/services/web/app/coffee/Features/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/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 54e04f51e5..c99cc2935e 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -12,7 +12,7 @@ Modules = require "./Modules" Url = require "url" PackageVersions = require "./PackageVersions" htmlEncoder = new require("node-html-encoder").Encoder("numerical") -fingerprints = {} +hashedFiles = {} Path = require 'path' Features = require "./Features" @@ -30,43 +30,42 @@ getFileContent = (filePath)-> filePath = Path.join __dirname, "../../../", "public#{filePath}" exists = fs.existsSync filePath if exists - content = fs.readFileSync filePath + content = fs.readFileSync filePath, "UTF-8" return content else - logger.log filePath:filePath, "file does not exist for fingerprints" + logger.log filePath:filePath, "file does not exist for hashing" return "" -logger.log "Generating file fingerprints..." pathList = [ - ["#{jsPath}libs/#{fineuploader}.js"] - ["#{jsPath}libs/require.js"] - ["#{jsPath}ide.js"] - ["#{jsPath}main.js"] - ["#{jsPath}libs.js"] - ["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"] - ["#{jsPath}libs/#{pdfjs}/pdf.js"] - ["#{jsPath}libs/#{pdfjs}/pdf.worker.js"] - ["#{jsPath}libs/#{pdfjs}/compatibility.js"] - ["/stylesheets/style.css"] - ["/stylesheets/ol-style.css"] + "#{jsPath}libs/require.js" + "#{jsPath}ide.js" + "#{jsPath}main.js" + "#{jsPath}libraries.js" + "/stylesheets/style.css" + "/stylesheets/ol-style.css" ] -for paths in pathList - contentList = _.map(paths, getFileContent) - content = contentList.join("") - hash = crypto.createHash("md5").update(content).digest("hex") - _.each paths, (filePath)-> - logger.log "#{filePath}: #{hash}" - fingerprints[filePath] = hash +if !Settings.useMinifiedJs + logger.log "not using minified JS, not hashing static files" +else + logger.log "Generating file hashes..." + for path in pathList + content = getFileContent(path) + hash = crypto.createHash("md5").update(content).digest("hex") + + splitPath = path.split("/") + filenameSplit = splitPath.pop().split(".") + filenameSplit.splice(filenameSplit.length-1, 0, hash) + splitPath.push(filenameSplit.join(".")) -getFingerprint = (path) -> - if fingerprints[path]? - return fingerprints[path] - else - logger.err "No fingerprint for file: #{path}" - return "" + hashPath = splitPath.join("/") + hashedFiles[path] = hashPath -logger.log "Finished generating file fingerprints" + fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}" + fs.writeFileSync(fsHashPath, content) + + + logger.log "Finished hashing static content" cdnAvailable = Settings.cdn?.web?.host? darkCdnAvailable = Settings.cdn?.web?.darkHost? @@ -120,29 +119,35 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath) res.locals.lib = PackageVersions.lib + + res.locals.buildJsPath = (jsFile, opts = {})-> path = Path.join(jsPath, jsFile) - doFingerPrint = opts.fingerprint != false + if opts.hashedPath && hashedFiles[path]? + path = hashedFiles[path] if !opts.qs? opts.qs = {} - if !opts.qs?.fingerprint? and doFingerPrint - opts.qs.fingerprint = getFingerprint(path) - if opts.cdn != false path = Url.resolve(staticFilesBase, path) qs = querystring.stringify(opts.qs) + if opts.removeExtension == true + path = path.slice(0,-3) + if qs? and qs.length > 0 path = path + "?" + qs return path - res.locals.buildCssPath = (cssFile)-> + res.locals.buildCssPath = (cssFile, opts)-> path = Path.join("/stylesheets/", cssFile) - return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path) + if opts?.hashedPath && hashedFiles[path]? + hashedPath = hashedFiles[path] + return Url.resolve(staticFilesBase, hashedPath) + return Url.resolve(staticFilesBase, path) res.locals.buildImgPath = (imgFile)-> path = Path.join("/img/", imgFile) @@ -227,10 +232,6 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> return req.query?[field] next() - webRouter.use (req, res, next)-> - res.locals.fingerprint = getFingerprint - next() - webRouter.use (req, res, next)-> res.locals.formatPrice = SubscriptionFormatters.formatPrice next() diff --git a/services/web/app/coffee/models/Project.coffee b/services/web/app/coffee/models/Project.coffee index e0013f8c5f..b434d35ab9 100644 --- a/services/web/app/coffee/models/Project.coffee +++ b/services/web/app/coffee/models/Project.coffee @@ -56,6 +56,7 @@ ProjectSchema = new Schema read_token : { type: String } history : id : { type: Number } + display : { type: Boolean } ProjectSchema.statics.getProject = (project_or_id, fields, callback)-> if project_or_id._id? diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index f8ec65ac53..2199bdd08e 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -197,6 +197,7 @@ module.exports = class Router webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject @@ -324,6 +325,10 @@ module.exports = class Router headers: req.headers }) + webRouter.get "/no-cache", (req, res, next)-> + res.header("Cache-Control", "max-age=0") + res.sendStatus(404) + webRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error")) webRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error") webRouter.get '/oops-mongo', (req, res, next) -> diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index e12d7d6f81..151df0b0ac 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -23,7 +23,7 @@ html(itemscope, itemtype='http://schema.org/Product') link(rel="apple-touch-icon-precomposed", href="/" + settings.brandPrefix + "apple-touch-icon-precomposed.png") link(rel="mask-icon", href="/" + settings.brandPrefix + "mask-favicon.svg", color="#a93529") - link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css")) + link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css", {hashedPath:true})) block _headLinks @@ -57,7 +57,7 @@ html(itemscope, itemtype='http://schema.org/Product') script(type="text/javascript"). window.csrfToken = "#{csrfToken}"; - script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false})) + script(src=buildJsPath("libs/jquery-1.11.1.min.js")) script(type="text/javascript"). var noCdnKey = "nocdn=true" var cdnBlocked = typeof jQuery === 'undefined' @@ -68,7 +68,7 @@ html(itemscope, itemtype='http://schema.org/Product') block scripts - script(src=buildJsPath("libs/angular-1.6.4.min.js", {fingerprint:false})) + script(src=buildJsPath("libs/angular-1.6.4.min.js")) script. window.sharelatex = { @@ -95,7 +95,11 @@ html(itemscope, itemtype='http://schema.org/Product') cdnDomain : '!{settings.templates.cdnDomain}', indexName : '!{settings.templates.indexName}' } - + + - if (settings.overleaf && settings.overleaf.useOLFreeTrial) + script. + window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial' + body if(settings.recaptcha) script(src="https://www.google.com/recaptcha/api.js?render=explicit") @@ -142,9 +146,10 @@ html(itemscope, itemtype='http://schema.org/Product') window.requirejs = { "paths" : { "moment": "libs/#{lib('moment')}", - "fineuploader": "libs/#{lib('fineuploader')}" + "fineuploader": "libs/#{lib('fineuploader')}", + "main": "#{buildJsPath('main.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", + "libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", }, - "urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}", "config":{ "moment":{ "noGlobal": true @@ -152,9 +157,9 @@ html(itemscope, itemtype='http://schema.org/Product') } }; script( - data-main=buildJsPath('main.js', {fingerprint:false}), + data-main=buildJsPath('main.js', {hashedPath:false}), baseurl=fullJsPath, - src=buildJsPath('libs/require.js') + src=buildJsPath('libs/require.js', {hashedPath:true}) ) include contact-us-modal diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index ec1d129714..4a35b930ff 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -98,14 +98,14 @@ block requirejs script(type="text/javascript" src='/socket.io/socket.io.js') //- don't use cdn for workers - - var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) - - var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false}) - - var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false,fingerprint:false}) + - var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false}) + - var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false}) + - var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false}) //- We need to do .replace(/\//g, '\\/') do that '' -> '<\/script>' //- and doesn't prematurely end the script tag. script#data(type="application/json"). - !{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState}).replace(/\//g, '\\/')} + !{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState, useV2History: useV2History}).replace(/\//g, '\\/')} script(type="text/javascript"). window.data = JSON.parse($("#data").text()); @@ -126,14 +126,15 @@ block requirejs window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)}; window.requirejs = { "paths" : { - "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}", + "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}", "moment": "libs/#{lib('moment')}", "pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf", "pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}", "ace": "#{lib('ace')}", - "fineuploader": "libs/#{lib('fineuploader')}" + "fineuploader": "libs/#{lib('fineuploader')}", + "ide": "#{buildJsPath('ide.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", + "libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}", }, - "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}", "waitSeconds": 0, "shim": { "pdfjs-dist/build/pdf": { @@ -155,14 +156,13 @@ block requirejs } } }; - window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}" window.aceWorkerPath = "#{aceWorkerPath}"; window.pdfCMapsPath = "#{pdfCMapsPath}" window.uiConfig = JSON.parse('!{JSON.stringify(uiConfig).replace(/\//g, "\\/")}'); script( - data-main=buildJsPath("ide.js", {fingerprint:false}), + data-main=buildJsPath("ide.js", {hashedPath:false}), baseurl=fullJsPath, - data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}), - src=buildJsPath('libs/require.js') + data-ace-base=buildJsPath(lib('ace')), + src=buildJsPath('libs/require.js', {hashedPath:true}) ) diff --git a/services/web/app/views/project/editor/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/bin/compile_app b/services/web/bin/compile_backend similarity index 100% rename from services/web/bin/compile_app rename to services/web/bin/compile_backend diff --git a/services/web/bin/compile_frontend b/services/web/bin/compile_frontend new file mode 100755 index 0000000000..bb1dde7dbb --- /dev/null +++ b/services/web/bin/compile_frontend @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +COFFEE=node_modules/.bin/coffee +echo Compiling public/coffee; +$COFFEE -o public/js -c public/coffee; diff --git a/services/web/bin/compile_frontend_tests b/services/web/bin/compile_frontend_tests new file mode 100755 index 0000000000..0351ad70cd --- /dev/null +++ b/services/web/bin/compile_frontend_tests @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +COFFEE=node_modules/.bin/coffee +echo Compiling test/unit_frontend/coffee; +$COFFEE -o test/unit_frontend/js -c test/unit_frontend/coffee; diff --git a/services/web/bin/frontend_test b/services/web/bin/frontend_test new file mode 100755 index 0000000000..599055803a --- /dev/null +++ b/services/web/bin/frontend_test @@ -0,0 +1,5 @@ +#!/bin/bash +set -e; +MOCHA="node_modules/.bin/mocha --recursive --reporter spec" +$MOCHA "$@" test/unit_frontend/js + diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 65a8bf1e91..844bffae3c 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -111,7 +111,8 @@ module.exports = settings = trackchanges: url : "http://localhost:3015" project_history: - enabled: process.env.PROJECT_HISTORY_ENABLED == 'true' or false + sendProjectStructureOps: process.env.PROJECT_HISTORY_ENABLED == 'true' or false + initializeHistoryForNewProjects: process.env.PROJECT_HISTORY_ENABLED == 'true' or false url : "http://localhost:3054" docstore: url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016" diff --git a/services/web/docker-shared.template.yml b/services/web/docker-shared.template.yml index d697e59e96..8acc63ef81 100644 --- a/services/web/docker-shared.template.yml +++ b/services/web/docker-shared.template.yml @@ -13,17 +13,15 @@ services: - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json - node_modules:/app/node_modules - ./bin:/app/bin - # Copying the whole public dir is fine for now, and needed for - # some unit tests to pass, but we will want to isolate the coffee - # and vendor js files, so that the compiled js files are not written - # back to the local filesystem. - - ./public:/app/public + - ./public/coffee:/app/public/coffee:ro + - ./public/js/ace-1.2.5:/app/public/js/ace-1.2.5 - ./app.coffee:/app/app.coffee:ro - ./app/coffee:/app/app/coffee:ro - ./app/templates:/app/app/templates:ro - ./app/views:/app/app/views:ro - ./config:/app/config - ./test/unit/coffee:/app/test/unit/coffee:ro + - ./test/unit_frontend/coffee:/app/test/unit_frontend/coffee:ro - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro - ./test/acceptance/files:/app/test/acceptance/files:ro - ./test/smoke/coffee:/app/test/smoke/coffee:ro diff --git a/services/web/package.json b/services/web/package.json index a1cf0fd6e0..398ce3dc78 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -14,11 +14,15 @@ "test:acceptance:run": "bin/acceptance_test $@", "test:acceptance:dir": "npm -q run compile:acceptance_tests && npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@", "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js", - "test:unit": "npm -q run compile:app && npm -q run compile:unit_tests && bin/unit_test $@", + "test:unit": "npm -q run compile:backend && npm -q run compile:unit_tests && bin/unit_test $@", + "test:frontend": "npm -q run compile:frontend && npm -q run compile:frontend_tests && bin/frontend_test $@", "compile:unit_tests": "bin/compile_unit_tests", + "compile:frontend_tests": "bin/compile_frontend_tests", "compile:acceptance_tests": "bin/compile_acceptance_tests", - "compile:app": "bin/compile_app", - "start": "npm -q run compile:app && node app.js" + "compile:frontend": "bin/compile_frontend", + "compile:backend": "bin/compile_backend", + "compile": "npm -q run compile:backend && npm -q run compile:frontend", + "start": "npm -q run compile && node app.js" }, "dependencies": { "archiver": "0.9.0", diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index 03d0ada365..c3847c9342 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -1,5 +1,5 @@ define [ - "libs" + "libraries" "modules/recursionHelper" "modules/errorCatcher" "modules/localStorage" diff --git a/services/web/public/coffee/directives/selectAll.coffee b/services/web/public/coffee/directives/selectAll.coffee index 4194d050e8..eb0bd05e86 100644 --- a/services/web/public/coffee/directives/selectAll.coffee +++ b/services/web/public/coffee/directives/selectAll.coffee @@ -49,18 +49,21 @@ define [ selectAllListController.clearSelectAllState() scope.$on "select-all:select", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = true ignoreChanges = false scope.$on "select-all:deselect", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = false ignoreChanges = false scope.$on "select-all:row-clicked", () -> + return if element.prop('disabled') ignoreChanges = true scope.$apply () -> scope.ngModel = !scope.ngModel @@ -75,4 +78,4 @@ define [ link: (scope, element, attrs) -> element.on "click", (e) -> scope.$broadcast "select-all:row-clicked" - } \ No newline at end of file + } diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index dbf6a2724e..3d514d093c 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -5,6 +5,7 @@ define [ "ide/editor/EditorManager" "ide/online-users/OnlineUsersManager" "ide/history/HistoryManager" + "ide/history/HistoryV2Manager" "ide/permissions/PermissionsManager" "ide/pdf/PdfManager" "ide/binary-files/BinaryFilesManager" @@ -44,6 +45,7 @@ define [ EditorManager OnlineUsersManager HistoryManager + HistoryV2Manager PermissionsManager PdfManager BinaryFilesManager @@ -137,7 +139,10 @@ define [ ide.fileTreeManager = new FileTreeManager(ide, $scope) ide.editorManager = new EditorManager(ide, $scope) ide.onlineUsersManager = new OnlineUsersManager(ide, $scope) - ide.historyManager = new HistoryManager(ide, $scope) + if window.data.useV2History + ide.historyManager = new HistoryV2Manager(ide, $scope) + else + ide.historyManager = new HistoryManager(ide, $scope) ide.pdfManager = new PdfManager(ide, $scope) ide.permissionsManager = new PermissionsManager(ide, $scope) ide.binaryFilesManager = new BinaryFilesManager(ide, $scope) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index 6908b25bce..ff5d1554e2 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -33,7 +33,7 @@ define [ if !ace.config._moduleUrl? ace.config._moduleUrl = ace.config.moduleUrl ace.config.moduleUrl = (args...) -> - url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}" + url = ace.config._moduleUrl(args...) return url App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, metadata, graphics, preamble, files, $http, $q) -> diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index 6b42714e79..f896fbb8b4 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -100,6 +100,7 @@ define [ end_ts: end_ts doc: doc error: false + pathname: doc.name } if !doc.deleted @@ -190,8 +191,10 @@ define [ previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1] for update in updates + update.pathnames = [] # Used for display for doc_id, doc of update.docs or {} doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true) + update.pathnames.push doc.entity.name for user in update.meta.users or [] if user? diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee new file mode 100644 index 0000000000..8198738e16 --- /dev/null +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -0,0 +1,280 @@ +define [ + "moment" + "ide/colors/ColorManager" + "ide/history/controllers/HistoryListController" + "ide/history/controllers/HistoryDiffController" + "ide/history/directives/infiniteScroll" +], (moment, ColorManager) -> + class HistoryManager + constructor: (@ide, @$scope) -> + @reset() + + @$scope.toggleHistory = () => + if @$scope.ui.view == "history" + @hide() + else + @show() + + @$scope.$watch "history.selection.updates", (updates) => + if updates? and updates.length > 0 + @_selectDocFromUpdates() + @reloadDiff() + + @$scope.$on "entity:selected", (event, entity) => + if (@$scope.ui.view == "history") and (entity.type == "doc") + @$scope.history.selection.pathname = _ide.fileTreeManager.getEntityPath(entity) + @reloadDiff() + + show: () -> + @$scope.ui.view = "history" + @reset() + + hide: () -> + @$scope.ui.view = "editor" + # Make sure we run the 'open' logic for whatever is currently selected + @$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity() + + reset: () -> + @$scope.history = { + isV2: true + updates: [] + nextBeforeTimestamp: null + atEnd: false + selection: { + updates: [] + pathname: null + range: { + fromV: null + toV: null + } + } + diff: null + } + + MAX_RECENT_UPDATES_TO_SELECT: 2 + autoSelectRecentUpdates: () -> + return if @$scope.history.updates.length == 0 + + @$scope.history.updates[0].selectedTo = true + + indexOfLastUpdateNotByMe = 0 + for update, i in @$scope.history.updates + if @_updateContainsUserId(update, @$scope.user.id) or i > @MAX_RECENT_UPDATES_TO_SELECT + break + indexOfLastUpdateNotByMe = i + + @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true + + BATCH_SIZE: 10 + fetchNextBatchOfUpdates: () -> + url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" + if @$scope.history.nextBeforeTimestamp? + url += "&before=#{@$scope.history.nextBeforeTimestamp}" + @$scope.history.loading = true + @ide.$http + .get(url) + .then (response) => + { data } = response + @_loadUpdates(data.updates) + @$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp + if !data.nextBeforeTimestamp? + @$scope.history.atEnd = true + @$scope.history.loading = false + + reloadDiff: () -> + diff = @$scope.history.diff + {updates} = @$scope.history.selection + {fromV, toV, pathname} = @_calculateDiffDataFromSelection() + + if !pathname? + @$scope.history.diff = null + return + + return if diff? and + diff.pathname == pathname and + diff.fromV == fromV and + diff.toV == toV + + @$scope.history.diff = diff = { + fromV: fromV + toV: toV + pathname: pathname + error: false + } + + diff.loading = true + url = "/project/#{@$scope.project_id}/diff" + query = ["pathname=#{encodeURIComponent(pathname)}"] + if diff.fromV? and diff.toV? + query.push "from=#{diff.fromV}", "to=#{diff.toV}" + url += "?" + query.join("&") + + @ide.$http + .get(url) + .then (response) => + { data } = response + diff.loading = false + {text, highlights} = @_parseDiff(data) + diff.text = text + diff.highlights = highlights + .catch () -> + diff.loading = false + diff.error = true + + _parseDiff: (diff) -> + row = 0 + column = 0 + highlights = [] + text = "" + for entry, i in diff.diff or [] + content = entry.u or entry.i or entry.d + content ||= "" + text += content + lines = content.split("\n") + startRow = row + startColumn = column + if lines.length > 1 + endRow = startRow + lines.length - 1 + endColumn = lines[lines.length - 1].length + else + endRow = startRow + endColumn = startColumn + lines[0].length + row = endRow + column = endColumn + + range = { + start: + row: startRow + column: startColumn + end: + row: endRow + column: endColumn + } + + if entry.i? or entry.d? + if entry.meta.user? + name = "#{entry.meta.user.first_name} #{entry.meta.user.last_name}" + else + name = "Anonymous" + if entry.meta.user?.id == @$scope.user.id + name = "you" + date = moment(entry.meta.end_ts).format("Do MMM YYYY, h:mm a") + if entry.i? + highlights.push { + label: "Added by #{name} on #{date}" + highlight: range + hue: ColorManager.getHueForUserId(entry.meta.user?.id) + } + else if entry.d? + highlights.push { + label: "Deleted by #{name} on #{date}" + strikeThrough: range + hue: ColorManager.getHueForUserId(entry.meta.user?.id) + } + + return {text, highlights} + + _loadUpdates: (updates = []) -> + previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1] + + for update in updates or [] + for user in update.meta.users or [] + if user? + user.hue = ColorManager.getHueForUserId(user.id) + + if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day") + update.meta.first_in_day = true + + update.selectedFrom = false + update.selectedTo = false + update.inSelection = false + + previousUpdate = update + + firstLoad = @$scope.history.updates.length == 0 + + @$scope.history.updates = + @$scope.history.updates.concat(updates) + + @autoSelectRecentUpdates() if firstLoad + + _perDocSummaryOfUpdates: (updates) -> + # Track current_pathname -> original_pathname + original_pathnames = {} + + # Map of original pathname -> doc summary + docs_summary = {} + + updatePathnameWithUpdateVersions = (pathname, update) -> + # docs_summary is indexed by the original pathname the doc + # had at the start, so we have to look this up from the current + # pathname via original_pathname first + if !original_pathnames[pathname]? + original_pathnames[pathname] = pathname + original_pathname = original_pathnames[pathname] + doc_summary = docs_summary[original_pathname] ?= { + fromV: update.fromV, toV: update.toV, + } + doc_summary.fromV = Math.min( + doc_summary.fromV, + update.fromV + ) + doc_summary.toV = Math.max( + doc_summary.toV, + update.toV + ) + + # Put updates in ascending chronological order + updates = updates.slice().reverse() + for update in updates + for pathname in update.pathnames or [] + updatePathnameWithUpdateVersions(pathname, update) + for project_op in update.project_ops or [] + if project_op.rename? + rename = project_op.rename + updatePathnameWithUpdateVersions(rename.pathname, update) + original_pathnames[rename.newPathname] = original_pathnames[rename.pathname] + delete original_pathnames[rename.pathname] + if project_op.add? + add = project_op.add + updatePathnameWithUpdateVersions(add.pathname, update) + + return docs_summary + + _calculateDiffDataFromSelection: () -> + fromV = toV = pathname = null + + selected_pathname = @$scope.history.selection.pathname + + for pathname, doc of @_perDocSummaryOfUpdates(@$scope.history.selection.updates) + if pathname == selected_pathname + {fromV, toV} = doc + return {fromV, toV, pathname} + + return {} + + # Set the track changes selected doc to one of the docs in the range + # of currently selected updates. If we already have a selected doc + # then prefer this one if present. + _selectDocFromUpdates: () -> + affected_docs = @_perDocSummaryOfUpdates(@$scope.history.selection.updates) + + selected_pathname = @$scope.history.selection.pathname + if selected_pathname? and affected_docs[selected_pathname] + # Selected doc is already open + else + # Set to first possible candidate + for pathname, doc of affected_docs + selected_pathname = pathname + break + + @$scope.history.selection.pathname = selected_pathname + if selected_pathname? + entity = @ide.fileTreeManager.findEntityByPath(selected_pathname) + if entity? + @ide.fileTreeManager.selectEntity(entity) + + _updateContainsUserId: (update, user_id) -> + for user in update.meta.users + return true if user?.id == user_id + return false diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 17afd8bbcb..7bce498ba8 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -90,7 +90,9 @@ define [ # to block auto compiles. It also causes problems where server-provided # linting errors aren't cleared after typing if (ide.$scope.settings.syntaxValidation and !ide.$scope.hasLintingError) - $scope.recompile(isAutoCompileOnChange: true) + $scope.recompile(isAutoCompileOnChange: true) # compile if no linting errors + else if !ide.$scope.settings.syntaxValidation + $scope.recompile(isAutoCompileOnChange: true) # always recompile else # Extend remainder of timeout autoCompileTimeout = setTimeout () -> @@ -533,14 +535,6 @@ define [ else $scope.switchToSideBySideLayout() - $scope.startFreeTrial = (source) -> - ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source) - - event_tracking.sendMB "subscription-start-trial", { source } - - window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}") - $scope.startedFreeTrial = true - App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) -> # enable per-user containers by default perUserCompile = true diff --git a/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee index ae8c049f69..3fcc5e416f 100644 --- a/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee +++ b/services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee @@ -4,8 +4,3 @@ define [ App.controller "TrackChangesUpgradeModalController", ($scope, $modalInstance) -> $scope.cancel = () -> $modalInstance.dismiss() - - $scope.startFreeTrial = (source) -> - ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source) - window.open("/user/subscription/new?planCode=student_free_trial_7_days") - $scope.startedFreeTrial = true \ No newline at end of file diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libraries.coffee similarity index 100% rename from services/web/public/coffee/libs.coffee rename to services/web/public/coffee/libraries.coffee 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/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index f4a6951e4a..75d4767ec0 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -462,6 +462,9 @@ define [ App.controller "ProjectListItemController", ($scope) -> + $scope.shouldDisableCheckbox = (project) -> + $scope.filter == 'archived' && project.accessLevel != 'owner' + $scope.projectLink = (project) -> if project.accessLevel == 'readAndWrite' and project.source == 'token' "/#{project.tokens.readAndWrite}" diff --git a/services/web/public/coffee/utils/underscore.coffee b/services/web/public/coffee/utils/underscore.coffee index 7a1551f90b..944abccb5d 100644 --- a/services/web/public/coffee/utils/underscore.coffee +++ b/services/web/public/coffee/utils/underscore.coffee @@ -1,5 +1,5 @@ define [ - "libs" + "libraries" ], () -> angular.module('underscore', []).factory '_', -> return window._ diff --git a/services/web/public/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 e164f4fc02..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() { 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 5885235ddc..f354893661 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -545,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; @@ -899,20 +899,22 @@ @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; @@ -935,6 +937,14 @@ @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 def0e408f9..8433c374b8 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -67,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; @@ -179,21 +201,25 @@ @toolbar-icon-btn-hover-shadow : none; @toolbar-border-bottom : 1px solid @toolbar-border-color; @toolbar-icon-btn-hover-boxshadow : none; +<<<<<<< HEAD @toolbar-font-size : 13px; +======= +>>>>>>> master // 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; @@ -216,6 +242,16 @@ @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; @@ -235,9 +271,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/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/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 } + })