diff --git a/services/web/Makefile b/services/web/Makefile index 469ab8e919..bfdfc0f52f 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -180,7 +180,7 @@ clean_css: rm -f public/stylesheets/*.css* clean_ci: - docker-compose down -v + docker-compose down -v -t 0 test: test_unit test_frontend test_acceptance @@ -204,7 +204,7 @@ test_acceptance_app_start_service: test_clean # stop service and clear dbs docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance test_acceptance_app_stop_service: - docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo + docker-compose ${DOCKER_COMPOSE_FLAGS} stop -t 0 test_acceptance redis mongo test_acceptance_app_run: docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS} @@ -224,7 +224,7 @@ test_acceptance_module: $(MODULE_MAKEFILES) fi test_clean: - docker-compose ${DOCKER_COMPOSE_FLAGS} down -v + docker-compose ${DOCKER_COMPOSE_FLAGS} down -v -t 0 ci: MOCHA_ARGS="--reporter tap" \ diff --git a/services/web/Makefile.module b/services/web/Makefile.module index 00bea5f8da..fa1ba41679 100644 --- a/services/web/Makefile.module +++ b/services/web/Makefile.module @@ -62,7 +62,7 @@ test_acceptance_start_service: test_acceptance_stop_service $(DOCKER_COMPOSE) up -d test_acceptance test_acceptance_stop_service: - $(DOCKER_COMPOSE) stop test_acceptance redis mongo + $(DOCKER_COMPOSE) stop -t 0 test_acceptance redis mongo test_acceptance_run: $(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js diff --git a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee index 4e96ce113c..1e0577cfc1 100644 --- a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee +++ b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee @@ -1,5 +1,5 @@ BetaProgramHandler = require './BetaProgramHandler' -UserLocator = require "../User/UserLocator" +UserGetter = require "../User/UserGetter" Settings = require "settings-sharelatex" logger = require 'logger-sharelatex' AuthenticationController = require '../Authentication/AuthenticationController' @@ -30,7 +30,7 @@ module.exports = BetaProgramController = optInPage: (req, res, next)-> user_id = AuthenticationController.getLoggedInUserId(req) logger.log {user_id}, "showing beta participation page for user" - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> if err logger.err {err, user_id}, "error fetching user" return next(err) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee index 898abe52bb..f74a144bac 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController = _checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) -> if Settings.restrictInvitesToExistingAccounts == true logger.log {email}, "checking if user exists with this email" - UserGetter.getUser {email: email}, {_id: 1}, (err, user) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) -> return callback(err) if err? userExists = user? and user?._id? callback(null, userExists) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee index ecca8ab86f..b511f56e53 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler = _trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) -> email = invite.email - UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) -> + UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) -> if err? logger.err {projectId, email}, "error checking if user exists" return callback(err) diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index b60f58ba20..40cb7bb507 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -7,7 +7,17 @@ module.exports = exportProject: (req, res) -> {project_id, brand_variation_id} = req.params user_id = AuthenticationController.getLoggedInUserId(req) - ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) -> + export_params = { + project_id: project_id, + brand_variation_id: brand_variation_id, + user_id: user_id + } + + if req.body && req.body.firstName && req.body.lastName + export_params.first_name = req.body.firstName.trim() + export_params.last_name = req.body.lastName.trim() + + ExportsHandler.exportProject export_params, (err, export_data) -> return next(err) if err? logger.log user_id:user_id diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee index 727b01a575..38357c129d 100644 --- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee @@ -10,8 +10,8 @@ settings = require 'settings-sharelatex' module.exports = ExportsHandler = self = - exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) -> - self._buildExport project_id, user_id, brand_variation_id, (err, export_data) -> + exportProject: (export_params, callback=(error, export_data) ->) -> + self._buildExport export_params, (err, export_data) -> return callback(err) if err? self._requestExport export_data, (err, export_v1_id) -> return callback(err) if err? @@ -19,7 +19,10 @@ module.exports = ExportsHandler = self = # TODO: possibly store the export data in Mongo callback null, export_data - _buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) -> + _buildExport: (export_params, callback=(err, export_data) ->) -> + project_id = export_params.project_id + user_id = export_params.user_id + brand_variation_id = export_params.brand_variation_id jobs = project: (cb) -> ProjectGetter.getProject project_id, cb @@ -43,6 +46,10 @@ module.exports = ExportsHandler = self = logger.err err:err, project_id: project_id return callback(err) + if export_params.first_name && export_params.last_name + user.first_name = export_params.first_name + user.last_name = export_params.last_name + export_data = project: id: project_id diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee index aaf4172cf4..1a5e13e86e 100644 --- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee @@ -5,7 +5,8 @@ logger = require 'logger-sharelatex' module.exports = LinkedFilesController = { Agents: { - url: require('./UrlAgent') + url: require('./UrlAgent'), + project_file: require('./ProjectFileAgent') } createLinkedFile: (req, res, next) -> @@ -22,11 +23,17 @@ module.exports = LinkedFilesController = { linkedFileData = Agent.sanitizeData(data) linkedFileData.provider = provider - Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> - if error? - logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' - return Agent.handleError(error, req, res, next) - EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) -> - return next(error) if error? - res.send(204) # created -} \ No newline at end of file + Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) -> + return Agent.handleError(err, req, res, next) if err? + return res.sendStatus(403) if !allowed + Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) -> + return Agent.handleError(err) if err? + linkedFileData = newLinkedFileData + Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) -> + if error? + logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk' + return Agent.handleError(error, req, res, next) + EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) -> + return next(error) if error? + res.json(new_file_id: file._id) # created + } diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee new file mode 100644 index 0000000000..5ea4554426 --- /dev/null +++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee @@ -0,0 +1,127 @@ +FileWriter = require('../../infrastructure/FileWriter') +AuthorizationManager = require('../Authorization/AuthorizationManager') +ProjectLocator = require('../Project/ProjectLocator') +ProjectGetter = require('../Project/ProjectGetter') +DocstoreManager = require('../Docstore/DocstoreManager') +FileStoreHandler = require('../FileStore/FileStoreHandler') +FileWriter = require('../../infrastructure/FileWriter') +_ = require "underscore" +Settings = require 'settings-sharelatex' + + +AccessDeniedError = (message) -> + error = new Error(message) + error.name = 'AccessDenied' + error.__proto__ = AccessDeniedError.prototype + return error +AccessDeniedError.prototype.__proto__ = Error.prototype + + +BadEntityTypeError = (message) -> + error = new Error(message) + error.name = 'BadEntityType' + error.__proto__ = BadEntityTypeError.prototype + return error +BadEntityTypeError.prototype.__proto__ = Error.prototype + + +BadDataError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = BadDataError.prototype + return error +BadDataError.prototype.__proto__ = Error.prototype + + +ProjectNotFoundError = (message) -> + error = new Error(message) + error.name = 'ProjectNotFound' + error.__proto__ = ProjectNotFoundError.prototype + return error +ProjectNotFoundError.prototype.__proto__ = Error.prototype + + +SourceFileNotFoundError = (message) -> + error = new Error(message) + error.name = 'BadData' + error.__proto__ = SourceFileNotFoundError.prototype + return error +SourceFileNotFoundError.prototype.__proto__ = Error.prototype + + +module.exports = ProjectFileAgent = + + sanitizeData: (data) -> + return _.pick( + data, + 'source_project_id', + 'source_entity_path' + ) + + _validate: (data) -> + return ( + data.source_project_id? && + data.source_entity_path? + ) + + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + callback = _.once(callback) + { source_project_id } = data + return callback(new BadDataError()) if !source_project_id? + ProjectGetter.getProject source_project_id, (err, project) -> + return callback(err) if err? + return callback(new ProjectNotFoundError()) if !project? + callback(err, _.extend(data, {source_project_display_name: project.name})) + + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) + {source_project_id, source_entity_path} = data + AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) -> + return callback(err) if err? + callback(null, canRead) + + writeIncomingFileToDisk: + (project_id, data, current_user_id, callback = (error, fsPath) ->) -> + callback = _.once(callback) + if !ProjectFileAgent._validate(data) + return callback(new BadDataError()) + {source_project_id, source_entity_path} = data + ProjectLocator.findElementByPath { + project_id: source_project_id, + path: source_entity_path + }, (err, entity, type) -> + if err? + if err.toString().match(/^not found.*/) + err = new SourceFileNotFoundError() + return callback(err) + ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback + + _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) -> + callback = _.once(callback) + if type == 'doc' + DocstoreManager.getDoc project_id, entity_id, (err, lines) -> + return callback(err) if err? + FileWriter.writeLinesToDisk entity_id, lines, callback + else if type == 'file' + FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) -> + return callback(err) if err? + FileWriter.writeStreamToDisk entity_id, fileStream, callback + else + callback(new BadEntityTypeError()) + + handleError: (error, req, res, next) -> + if error instanceof AccessDeniedError + res.status(403).send("You do not have access to this project") + else if error instanceof BadDataError + res.status(400).send("The submitted data is not valid") + else if error instanceof BadEntityTypeError + res.status(400).send("The file is the wrong type") + else if error instanceof SourceFileNotFoundError + res.status(404).send("Source file not found") + else if error instanceof ProjectNotFoundError + res.status(404).send("Project not found") + else + next(error) + next() diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee index ad96aa628f..7a15fe52d3 100644 --- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee +++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee @@ -27,6 +27,12 @@ module.exports = UrlAgent = { url: @._prependHttpIfNeeded(data.url) } + decorateLinkedFileData: (data, callback = (err, newData) ->) -> + return callback(null, data) + + checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) -> + callback(null, true) + writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) -> callback = _.once(callback) url = data.url @@ -65,4 +71,4 @@ module.exports = UrlAgent = { if !Settings.apis?.linkedUrlProxy?.url? throw new Error('no linked url proxy configured') return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}" -} \ No newline at end of file +} diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee index 4e67e9f1f4..3947b63004 100644 --- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee +++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee @@ -9,7 +9,7 @@ logger = require("logger-sharelatex") module.exports = generateAndEmailResetToken:(email, callback = (error, exists) ->)-> - UserGetter.getUser email:email, (err, user)-> + UserGetter.getUserByMainEmail email, (err, user)-> if err then return callback(err) if !user? or user.holdingAccount logger.err email:email, "user could not be found for password reset" diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 4ca886fed0..8ea2dc3189 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources" TokenAccessHandler = require '../TokenAccess/TokenAccessHandler' CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler' Modules = require '../../infrastructure/Modules' +ProjectEntityHandler = require './ProjectEntityHandler' crypto = require 'crypto' module.exports = ProjectController = @@ -138,6 +139,33 @@ module.exports = ProjectController = return next(err) if err? res.sendStatus 200 + userProjectsJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + ProjectGetter.findAllUsersProjects user_id, + 'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) -> + return next(err) if err? + projects = ProjectController._buildProjectList(projects) + .filter((p) -> !p.archived) + .filter((p) -> !p.isV1Project) + .map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel}) + + res.json({projects: projects}) + + projectEntitiesJson: (req, res, next) -> + user_id = AuthenticationController.getLoggedInUserId(req) + project_id = req.params.Project_id + ProjectGetter.getProject project_id, (err, project) -> + return next(err) if err? + ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) -> + return next(err) if err? + entities = docs.concat(files) + .sort (a, b) -> a.path > b.path # Sort by path ascending + .map (e) -> { + path: e.path, + type: if e.doc? then 'doc' else 'file' + } + res.json({project_id: project_id, entities: entities}) + projectListPage: (req, res, next)-> timer = new metrics.Timer("project-list") user_id = AuthenticationController.getLoggedInUserId(req) @@ -313,6 +341,7 @@ module.exports = ProjectController = maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display showRichText: req.query?.rt == 'true' + showTestControls: req.query?.tc == 'true' || user.isAdmin showPublishModal: req.query?.pm == 'true' timer.done() diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee index 27ead91841..22034200f5 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee @@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) -> methodWithLock module.exports = ProjectEntityUpdateHandler = self = - # this doesn't need any locking because it's only called by ProjectDuplicator - copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> - project_id = project._id - projectHistoryId = project.overleaf?.history?.id - logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" - return callback(err) if err? - ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=> - if !origonalFileRef? - logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" - return callback() - # convert any invalid characters in original file to '_' - fileRef = new File name : SafePath.clean(origonalFileRef.name) - FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> - if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" - return callback(err) - ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=> - if err? - logger.err { err, project_id, folder_id }, "error putting element as part of copy" - return callback(err) - TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + copyFileFromExistingProjectWithProject: wrapWithLock + beforeLock: (next) -> + (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project" + ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) -> + if !origonalFileRef? + logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null" + return callback() + # convert any invalid characters in original file to '_' + fileRef = new File name : SafePath.clean(origonalFileRef.name) + FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)-> if err? - logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" - newFiles = [ - file: fileRef - path: result?.path?.fileSystem - url: fileStoreUrl - ] - DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) -> - return callback(error) if error? - callback null, fileRef, folder_id + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3" + return callback(err) + next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback) + withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)-> + project_id = project._id + projectHistoryId = project.overleaf?.history?.id + ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) -> + if err? + logger.err { err, project_id, folder_id }, "error putting element as part of copy" + return callback(err) + TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) -> + if err? + logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker" + newFiles = [ + file: fileRef + path: result?.path?.fileSystem + url: fileStoreUrl + ] + DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) -> + return callback(error) if error? + callback null, fileRef, folder_id updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)-> ProjectGetter.getProjectWithoutDocLines project_id, (err, project)-> diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee index 8728896631..959833351f 100644 --- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee +++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee @@ -10,6 +10,8 @@ Async = require('async') oneMinInMs = 60 * 1000 fiveMinsInMs = oneMinInMs * 5 +if !settings.apis?.references?.url? + logger.log "references search not enabled" module.exports = ReferencesHandler = diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee index 5c176c611f..5627072c93 100644 --- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee @@ -11,7 +11,16 @@ V1SubscriptionManager = require("./V1SubscriptionManager") oneMonthInSeconds = 60 * 60 * 24 * 30 module.exports = FeaturesUpdater = - refreshFeatures: (user_id, callback)-> + refreshFeatures: (user_id, notifyV1 = true, callback = () ->)-> + if typeof notifyV1 == 'function' + callback = notifyV1 + notifyV1 = true + + if notifyV1 + V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) -> + if error? + logger.err {err: error, user_id}, "error notifying v1 about updated features" + jobs = individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb @@ -80,4 +89,4 @@ module.exports = FeaturesUpdater = if !plan? return {} else - return plan.features \ No newline at end of file + return plan.features diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index 32d2abf594..03e87125ac 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -10,6 +10,7 @@ GeoIpLookup = require("../../infrastructure/GeoIpLookup") SubscriptionDomainHandler = require("./SubscriptionDomainHandler") UserGetter = require "../User/UserGetter" FeaturesUpdater = require './FeaturesUpdater' +planFeatures = require './planFeatures' module.exports = SubscriptionController = @@ -20,6 +21,7 @@ module.exports = SubscriptionController = viewName = "#{viewName}_#{req.query.v}" logger.log viewName:viewName, "showing plans page" currentUser = null + GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)-> return next(err) if err? render = () -> @@ -29,6 +31,7 @@ module.exports = SubscriptionController = gaExperiments: Settings.gaExperiments.plansPage recomendedCurrency:recomendedCurrency shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27'))) + planFeatures: planFeatures user_id = AuthenticationController.getLoggedInUserId(req) if user_id? UserGetter.getUser user_id, {signUpDate: 1}, (err, user) -> diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index fe1e78e5e3..7e88a6c56a 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -2,8 +2,8 @@ async = require("async") _ = require("underscore") SubscriptionUpdater = require("./SubscriptionUpdater") SubscriptionLocator = require("./SubscriptionLocator") +UserGetter = require("../User/UserGetter") Subscription = require("../../models/Subscription").Subscription -UserLocator = require("../User/UserLocator") LimitationsManager = require("./LimitationsManager") logger = require("logger-sharelatex") OneTimeTokenHandler = require("../Security/OneTimeTokenHandler") @@ -22,7 +22,7 @@ module.exports = SubscriptionGroupHandler = if limitReached logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group" return callback(limitReached:limitReached) - UserLocator.findByEmail newEmail, (err, user)-> + UserGetter.getUserByMainEmail newEmail, (err, user)-> return callback(err) if err? if user? SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)-> @@ -66,7 +66,7 @@ module.exports = SubscriptionGroupHandler = users.push buildEmailInviteViewModel(email) jobs = _.map subscription.member_ids, (user_id)-> return (cb)-> - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> if err? or !user? users.push _id:user_id return cb() diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee index 05dc140be2..e920b94f6c 100644 --- a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee +++ b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee @@ -12,39 +12,49 @@ module.exports = V1SubscriptionManager = # - 'v1_free' getPlanCodeFromV1: (userId, callback=(err, planCode)->) -> logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user" + V1SubscriptionManager._v1Request userId, { + method: 'GET', + url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/plan_code" + }, (error, body) -> + return callback(error) if error? + planName = body?.plan_name + logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user" + if planName in ['pro', 'pro_plus', 'student', 'free'] + planName = "v1_#{planName}" + else + # Throw away 'anonymous', etc as being equivalent to null + planName = null + return callback(null, planName) + + notifyV1OfFeaturesChange: (userId, callback = (error) ->) -> + V1SubscriptionManager._v1Request userId, { + method: 'POST', + url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/sync" + }, callback + + _v1Request: (userId, options, callback=(err, body)->) -> + if !settings?.apis?.v1 + return callback null, null UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) -> return callback(err) if err? v1Id = user?.overleaf?.id if !v1Id? logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user" return callback(null, null) - V1SubscriptionManager._v1PlanRequest v1Id, (err, body) -> - return callback(err) if err? - planName = body?.plan_name - logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user" - if planName in ['pro', 'pro_plus', 'student', 'free'] - planName = "v1_#{planName}" + request { + baseUrl: settings.apis.v1.url + url: options.url(v1Id) + method: options.method + auth: + user: settings.apis.v1.user + pass: settings.apis.v1.pass + sendImmediately: true + json: true, + timeout: 5 * 1000 + }, (error, response, body) -> + return callback(error) if error? + if 200 <= response.statusCode < 300 + return callback null, body else - # Throw away 'anonymous', etc as being equivalent to null - planName = null - return callback(null, planName) + return callback new Error("non-success code from v1: #{response.statusCode}") - _v1PlanRequest: (v1Id, callback=(err, body)->) -> - if !settings?.apis?.v1 - return callback null, null - request { - method: 'GET', - url: settings.apis.v1.url + - "/api/v1/sharelatex/users/#{v1Id}/plan_code" - auth: - user: settings.apis.v1.user - pass: settings.apis.v1.pass - sendImmediately: true - json: true, - timeout: 5 * 1000 - }, (error, response, body) -> - return callback(error) if error? - if 200 <= response.statusCode < 300 - return callback null, body - else - return callback new Error("non-success code from v1: #{response.statusCode}") \ No newline at end of file diff --git a/services/web/app/coffee/Features/Subscription/planFeatures.coffee b/services/web/app/coffee/Features/Subscription/planFeatures.coffee new file mode 100644 index 0000000000..8c9c276955 --- /dev/null +++ b/services/web/app/coffee/Features/Subscription/planFeatures.coffee @@ -0,0 +1,133 @@ +module.exports = + [ + { + feature: 'number_collab' + value: 'str' + plans: { + free: '1' + coll: '10' + prof: 'unlimited' + } + student: '6' + } + { + feature: 'unlimited_private' + value: 'bool' + info: 'unlimited_private_info' + plans: { + free: true + coll: true + prof: true + }, + student: true + } + { + feature: 'realtime_collab' + value: 'bool' + info: 'realtime_collab_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'hundreds_templates' + value: 'bool' + info: 'hundreds_templates_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'powerful_latex_editor' + value: 'bool' + info: 'latex_editor_info' + plans: { + free: true + coll: true + prof: true + } + student: true + } + { + feature: 'realtime_track_changes' + value: 'bool' + info: 'realtime_track_changes_info' + plans: { + free: false + coll: true + prof: true + }, + student: true + } + { + feature: 'reference_search' + value: 'bool' + info: 'reference_search_info' + plans: { + free: false + coll: true + prof: true + }, + student: true + }, + { + feature: 'reference_sync' + info: 'reference_sync_info' + value: 'bool' + plans: { + free: false + coll: true + prof: true + }, + student: true + } + { + feature: 'full_doc_history' + value: 'bool' + info: 'full_doc_history_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + } + { + feature: 'dropbox_integration_lowercase' + value: 'bool' + info: 'dropbox_integration_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + { + feature: 'github_integration_lowercase' + value: 'bool' + info: 'github_integration_info' + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + { + feature: 'priority_support', + value: 'bool', + plans: { + free: false, + coll: true, + prof: true + }, + student: true + }, + ] \ No newline at end of file diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee new file mode 100644 index 0000000000..fce6c9502c --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -0,0 +1,80 @@ +path = require('path') +Project = require('../../../js/models/Project').Project +ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') +ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler') +AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') +settings = require('settings-sharelatex') +fs = require('fs') +request = require('request') +uuid = require('uuid') +logger = require('logger-sharelatex') +async = require("async") + + +module.exports = TemplatesController = + + getV1Template: (req, res)-> + templateVersionId = req.params.Template_version_id + templateId = req.query.id + if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId) + logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version" + return res.sendStatus 400 + data = {} + data.templateVersionId = templateVersionId + data.templateId = templateId + data.name = req.query.templateName + data.compiler = req.query.latexEngine + res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data + + createProjectFromV1Template: (req, res)-> + currentUserId = AuthenticationController.getLoggedInUserId(req) + zipUrl = "#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}" + zipReq = request(zipUrl, { + 'auth': { + 'user': settings.apis.v1.user, + 'pass': settings.apis.v1.pass + } + }) + + TemplatesController.createFromZip( + zipReq, + { + templateName: req.body.templateName, + currentUserId: currentUserId, + compiler: req.body.compiler + docId: req.body.docId + templateId: req.body.templateId + templateVersionId: req.body.templateVersionId + }, + req, + res + ) + + createFromZip: (zipReq, options, req, res)-> + dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}" + writeStream = fs.createWriteStream(dumpPath) + + zipReq.on "error", (error) -> + logger.error err: error, "error getting zip from template API" + zipReq.pipe(writeStream) + writeStream.on 'close', -> + ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)-> + if err? + logger.err err:err, zipReq:zipReq, "problem building project from zip" + return res.sendStatus 500 + setCompiler project._id, options.compiler, -> + fs.unlink dumpPath, -> + delete req.session.templateData + conditions = {_id:project._id} + update = { + fromV1TemplateId:options.templateId, + fromV1TemplateVersionId:options.templateVersionId + } + Project.update conditions, update, {}, (err)-> + res.redirect "/project/#{project._id}" + +setCompiler = (project_id, compiler, callback)-> + if compiler? + ProjectOptionsHandler.setCompiler project_id, compiler, callback + else + callback() diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear new file mode 100644 index 0000000000..300721c889 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear @@ -0,0 +1,8 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee new file mode 100644 index 0000000000..8baa0ca605 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee @@ -0,0 +1,9 @@ +settings = require("settings-sharelatex") +logger = require("logger-sharelatex") + + +module.exports = + saveTemplateDataInSession: (req, res, next)-> + if req.query.templateName + req.session.templateData = req.query + next() diff --git a/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee new file mode 100644 index 0000000000..3061789591 --- /dev/null +++ b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee @@ -0,0 +1,10 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +TemplatesController = require("./TemplatesController") +TemplatesMiddlewear = require('./TemplatesMiddlewear') + +module.exports = + apply: (app)-> + + app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template + + app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee index ccbd0a86f1..cf7e6be33f 100644 --- a/services/web/app/coffee/Features/User/UserController.coffee +++ b/services/web/app/coffee/Features/User/UserController.coffee @@ -1,6 +1,6 @@ UserHandler = require("./UserHandler") UserDeleter = require("./UserDeleter") -UserLocator = require("./UserLocator") +UserGetter = require("./UserGetter") User = require("../../models/User").User newsLetterManager = require('../Newsletter/NewsletterManager') UserRegistrationHandler = require("./UserRegistrationHandler") @@ -45,7 +45,7 @@ module.exports = UserController = unsubscribe: (req, res)-> user_id = AuthenticationController.getLoggedInUserId(req) - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> newsLetterManager.unsubscribe user, -> res.send() diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee index d08b953559..0a0cc8641e 100644 --- a/services/web/app/coffee/Features/User/UserCreator.coffee +++ b/services/web/app/coffee/Features/User/UserCreator.coffee @@ -1,19 +1,10 @@ User = require("../../models/User").User -UserLocator = require("./UserLocator") logger = require("logger-sharelatex") metrics = require('metrics-sharelatex') module.exports = UserCreator = - getUserOrCreateHoldingAccount: (email, callback = (err, user)->)-> - self = @ - UserLocator.findByEmail email, (err, user)-> - if user? - callback(err, user) - else - self.createNewUser email:email, holdingAccount:true, callback - createNewUser: (opts, callback)-> logger.log opts:opts, "creating new user" user = new User() diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 306fbc7a10..2e6d4eada6 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId module.exports = UserGetter = getUser: (query, projection, callback = (error, user) ->) -> + if query?.email? + return callback(new Error("Don't use getUser to find user by email"), null) if arguments.length == 2 callback = projection projection = {} @@ -19,6 +21,31 @@ module.exports = UserGetter = db.users.findOne query, projection, callback + getUserEmail: (userId, callback = (error, email) ->) -> + @getUser userId, { email: 1 }, (error, user) -> + callback(error, user?.email) + + getUserByMainEmail: (email, projection, callback = (error, user) ->) -> + email = email.trim() + if arguments.length == 2 + callback = projection + projection = {} + db.users.findOne email: email, projection, callback + + getUserByAnyEmail: (email, projection, callback = (error, user) ->) -> + email = email.trim() + if arguments.length == 2 + callback = projection + projection = {} + # $exists: true MUST be set to use the partial index + query = emails: { $exists: true }, 'emails.email': email + db.users.findOne query, projection, (error, user) => + return callback(error, user) if error? or user? + + # While multiple emails are being rolled out, check for the main email as + # well + @getUserByMainEmail email, projection, callback + getUsers: (user_ids, projection, callback = (error, users) ->) -> try user_ids = user_ids.map (u) -> ObjectId(u.toString()) @@ -39,6 +66,9 @@ module.exports = UserGetter = [ 'getUser', + 'getUserEmail', + 'getUserByMainEmail', + 'getUserByAnyEmail', 'getUsers', 'getUserOrUserStubById' ].map (method) -> diff --git a/services/web/app/coffee/Features/User/UserLocator.coffee b/services/web/app/coffee/Features/User/UserLocator.coffee deleted file mode 100644 index 9be32c76b0..0000000000 --- a/services/web/app/coffee/Features/User/UserLocator.coffee +++ /dev/null @@ -1,21 +0,0 @@ -mongojs = require("../../infrastructure/mongojs") -metrics = require("metrics-sharelatex") -db = mongojs.db -ObjectId = mongojs.ObjectId -logger = require('logger-sharelatex') - -module.exports = UserLocator = - - findByEmail: (email, callback)-> - email = email.trim() - db.users.findOne email:email, (err, user)-> - callback(err, user) - - findById: (_id, callback)-> - db.users.findOne _id:ObjectId(_id+""), callback - -[ - 'findById', - 'findByEmail' -].map (method) -> - metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index 25825c35e6..5e6ea7d62b 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -1,4 +1,3 @@ -UserLocator = require("./UserLocator") UserGetter = require("./UserGetter") UserSessionsManager = require("./UserSessionsManager") ErrorController = require("../Errors/ErrorController") @@ -61,7 +60,7 @@ module.exports = user_id = AuthenticationController.getLoggedInUserId(req) logger.log user: user_id, "loading settings page" shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin) - UserLocator.findById user_id, (err, user)-> + UserGetter.getUser user_id, (err, user)-> return next(err) if err? res.render 'user/settings', title:'account_settings' diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index f5db2e54a1..fab438ffa6 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -1,6 +1,7 @@ sanitize = require('sanitizer') User = require("../../models/User").User UserCreator = require("./UserCreator") +UserGetter = require("./UserGetter") AuthenticationManager = require("../Authentication/AuthenticationManager") NewsLetterManager = require("../Newsletter/NewsletterManager") async = require("async") @@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler = if !requestIsValid return callback(new Error("request is not valid")) userDetails.email = userDetails.email?.trim()?.toLowerCase() - User.findOne email:userDetails.email, (err, user)-> + UserGetter.getUserByMainEmail userDetails.email, (err, user) => if err? return callback err if user?.holdingAccount == false diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee index 530d81063d..fa9ee24450 100644 --- a/services/web/app/coffee/Features/User/UserUpdater.coffee +++ b/services/web/app/coffee/Features/User/UserUpdater.coffee @@ -2,8 +2,9 @@ logger = require("logger-sharelatex") mongojs = require("../../infrastructure/mongojs") metrics = require("metrics-sharelatex") db = mongojs.db +async = require("async") ObjectId = mongojs.ObjectId -UserLocator = require("./UserLocator") +UserGetter = require("./UserGetter") module.exports = UserUpdater = updateUser: (query, update, callback = (error) ->) -> @@ -11,23 +12,89 @@ module.exports = UserUpdater = query = _id: ObjectId(query) else if query instanceof ObjectId query = _id: query + else if typeof query._id == "string" + query._id = ObjectId(query._id) db.users.update query, update, callback - changeEmailAddress: (user_id, newEmail, callback)-> - self = @ - logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user" - UserLocator.findByEmail newEmail, (error, user) -> - if user? - return callback({message:"alread_exists"}) - self.updateUser user_id.toString(), { - $set: { "email": newEmail}, - }, (err) -> - if err? - logger.err err:err, "problem updating users email" - return callback(err) + # + # DEPRECATED + # + # Change the user's main email address by adding a new email, switching the + # default email and removing the old email. Prefer manipulating multiple + # emails and the default rather than calling this method directly + # + changeEmailAddress: (userId, newEmail, callback)-> + logger.log userId: userId, newEmail: newEmail, "updaing email address of user" + + oldEmail = null + async.series [ + (cb) -> + UserGetter.getUserEmail userId, (error, email) -> + oldEmail = email + cb(error) + (cb) -> UserUpdater.addEmailAddress userId, newEmail, cb + (cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb + (cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb + ], callback + + + # Add a new email address for the user. Email cannot be already used by this + # or any other user + addEmailAddress: (userId, newEmail, callback) -> + @_ensureUniqueEmailAddress newEmail, (error) => + return callback(error) if error? + + update = $push: emails: email: newEmail, createdAt: new Date() + @updateUser userId, update, (error) -> + if error? + logger.err error: error, 'problem updating users emails' + return callback(error) callback() -metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger + # remove one of the user's email addresses. The email cannot be the user's + # default email address + removeEmailAddress: (userId, email, callback) -> + query = _id: userId, email: $ne: email + update = $pull: emails: email: email + @updateUser query, update, (error, res) -> + if error? + logger.err error:error, 'problem removing users email' + return callback(error) + if res.nMatched == 0 + return callback(new Error('Cannot remove default email')) + callback() + + + # set the default email address by setting the `email` attribute. The email + # must be one of the user's multiple emails (`emails` attribute) + setDefaultEmailAddress: (userId, email, callback) -> + query = _id: userId, 'emails.email': email + update = $set: email: email + @updateUser query, update, (error, res) -> + if error? + logger.err error:error, 'problem setting default emails' + return callback(error) + if res.nMatched == 0 + return callback(new Error('Default email does not belong to user')) + callback() + + + # check for duplicate email address. This is also enforced at the DB level + _ensureUniqueEmailAddress: (newEmail, callback) -> + UserGetter.getUserByAnyEmail newEmail, (error, user) -> + return callback(message: 'alread_exists') if user? + callback() + + +[ + 'updateUser' + 'changeEmailAddress' + 'setDefaultEmailAddress' + 'addEmailAddress' + 'removeEmailAddress' + '_ensureUniqueEmailAddress' +].map (method) -> + metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger) diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee index dedeed9bad..27b1f16921 100644 --- a/services/web/app/coffee/infrastructure/FileWriter.coffee +++ b/services/web/app/coffee/infrastructure/FileWriter.coffee @@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex' request = require 'request' module.exports = FileWriter = + + _ensureDumpFolderExists: (callback=(error)->) -> + fs.mkdir Settings.path.dumpFolder, (error) -> + if error? and error.code != 'EEXIST' + # Ignore error about already existing + return callback(error) + callback(null) + + writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) -> + callback = _.once(callback) + fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? + fs.writeFile fsPath, lines.join('\n'), (error) -> + return callback(error) if error? + callback(null, fsPath) + writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) -> callback = _.once(callback) fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}" stream.pause() - fs.mkdir Settings.path.dumpFolder, (error) -> + FileWriter._ensureDumpFolderExists (error) -> + return callback(error) if error? stream.resume() - if error? and error.code != 'EEXIST' - # Ignore error about already existing - return callback(error) writeStream = fs.createWriteStream(fsPath) stream.pipe(writeStream) @@ -39,4 +54,4 @@ module.exports = FileWriter = else err = new Error("bad response from url: #{response.statusCode}") logger.err {err, identifier, url}, err.message - callback(err) \ No newline at end of file + callback(err) diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee index 769182ad94..e1b2e11520 100644 --- a/services/web/app/coffee/infrastructure/Modules.coffee +++ b/services/web/app/coffee/infrastructure/Modules.coffee @@ -30,7 +30,8 @@ module.exports = Modules = for module in @modules for view, partial of module.viewIncludes or {} @viewIncludes[view] ||= [] - @viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html") + filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug") + @viewIncludes[view].push pug.compileFile(filePath, doctype: "html") moduleIncludes: (view, locals) -> compiledPartials = Modules.viewIncludes[view] or [] diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index e6b2692f7c..22c4abe925 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController') TokenAccessController = require('./Features/TokenAccess/TokenAccessController') Features = require('./infrastructure/Features') LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter' +TemplatesRouter = require './Features/Templates/TemplatesRouter' logger = require("logger-sharelatex") _ = require("underscore") @@ -80,10 +81,10 @@ module.exports = class Router ContactRouter.apply(webRouter, privateApiRouter) AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if Settings.enableSubscriptions webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus @@ -119,6 +120,11 @@ module.exports = class Router webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo + webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson + webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanReadProject, + ProjectController.projectEntitiesJson + webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject @@ -201,7 +207,8 @@ module.exports = class Router webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails - webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.get "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi + webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2 privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory diff --git a/services/web/app/views/contact-us-modal.pug b/services/web/app/views/contact-us-modal.pug deleted file mode 100644 index eacfd10533..0000000000 --- a/services/web/app/views/contact-us-modal.pug +++ /dev/null @@ -1,67 +0,0 @@ -script(type='text/ng-template', id='supportModalTemplate') - .modal-header - button.close( - type="button" - data-dismiss="modal" - ng-click="close()" - ) × - h3 #{translate("contact_us")} - .modal-body.contact-us-modal - form(name="contactForm") - span(ng-show="sent == false") - .alert.alert-danger(ng-show="error") Something went wrong sending your request :( - label - | #{translate("subject")} - .form-group - input.field.text.medium.span8.form-control( - name="subject", - required - ng-model="form.subject", - ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }" - maxlength='255', - tabindex='1', - onkeyup='') - .contact-suggestions(ng-show="suggestions.length") - p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "" + translate("knowledge_base") + "" })} - ul.contact-suggestion-list - li(ng-repeat="suggestion in suggestions") - a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank") - span(ng-bind-html="suggestion.name") - i.fa.fa-angle-right - label.desc(ng-show="'"+getUserEmail()+"'.length < 1") - | #{translate("email")} - .form-group(ng-show="'"+getUserEmail()+"'.length < 1") - input.field.text.medium.span8.form-control( - name="email", - required - ng-model="form.email", - ng-init="form.email = '"+getUserEmail()+"'", - type='email', spellcheck='false', - value='', - maxlength='255', - tabindex='2') - label#title12.desc - | #{translate("project_url")} (#{translate("optional")}) - .form-group - input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='') - label.desc - | #{translate("contact_message_label")} - .form-group - textarea.field.text.medium.span8.form-control( - name="body", - required - ng-model="form.message", - type='text', - value='', - tabindex='4', - onkeyup='' - ) - .form-group.text-center - input.btn-success.btn.btn-lg( - type='submit', - ng-disabled="contactForm.$invalid || sending", - ng-click="contactUs()" - value=translate("contact_us") - ) - span(ng-show="sent") - p #{translate("request_sent_thank_you")} diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug index 4dfdbc3ae7..687f5e1880 100644 --- a/services/web/app/views/layout.pug +++ b/services/web/app/views/layout.pug @@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product') src=buildJsPath('libs/require.js', {hashedPath:true}) ) - include contact-us-modal + != moduleIncludes("contactModal", locals) include v1-tooltip include sentry diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 7415c2ed77..602b9af86b 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -57,9 +57,12 @@ block content include ./editor/share != moduleIncludes("publish:body", locals) + include ./editor/history/toolbarV2.pug + main#ide-body( ng-cloak, role="main", + ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }", layout="main", ng-hide="state.loading", resize-on="layout:chat:resize", @@ -70,7 +73,7 @@ block content ) .ui-layout-west include ./editor/file-tree - include ./editor/history-file-tree + include ./editor/history/fileTreeV2 .ui-layout-center include ./editor/editor diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug index e5690c1874..35d75c9fb3 100644 --- a/services/web/app/views/project/editor/binary-file.pug +++ b/services/web/app/views/project/editor/binary-file.pug @@ -47,7 +47,18 @@ div.binary-file.full-size( | | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} - span(ng-if="openFile.linkedFileData.provider == 'url'") + div(ng-if="openFile.linkedFileData.provider == 'project_file'") + p + i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon + | Imported from + | + a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank") + | {{ openFile.linkedFileData.source_project_display_name }} + | /{{ openFile.linkedFileData.source_entity_path.slice(1) }}, + | + | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }} + + span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'") button.btn.btn-success( href, ng-click="refreshFile(openFile)", ng-disabled="refreshing" @@ -63,3 +74,7 @@ div.binary-file.full-size( i.fa.fa-fw.fa-download | | #{translate("download")} + div(ng-if="refreshError") + br + .alert.alert-danger.col-md-6.col-md-offset-3 + | Error: {{ refreshError}} diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index 3cac3a9490..c24d967da7 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -33,11 +33,11 @@ div.full-size( i.fa.fa-arrow-left |   #{translate("open_a_file_on_the_left")} - != moduleIncludes('editor:toolbar', locals) + != moduleIncludes('editor:main', locals) #editor( ace-editor="editor", - ng-if="!editor.richText", + ng-if="!editor.showRichText", ng-show="!!editor.sharejs_doc && !editor.opening", style=showRichText ? "top: 32px" : "", theme="settings.theme", @@ -73,8 +73,6 @@ div.full-size( line-height="settings.lineHeight || ui.defaultLineHeight" ) - != moduleIncludes('editor:body', locals) - include ./review-panel .ui-layout-east diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index 55fc660abc..d16258d1c9 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate') span(ng-show="state.inflight") #{translate("creating")}... +// Project Linked Files Modal +script(type='text/ng-template', id='projectLinkedFileModalTemplate') + .modal-header + h3 New file from Project + + .modal-body + div + div.alert.alert-danger(ng-if="state.error") Error, something went wrong! + div + form + .form-controls + label(for="project-select") Select a Project + span(ng-show="state.inFlight.projects") + |   + i.fa.fa-spinner.fa-spin + select.form-control( + name="project-select" + ng-model="data.selectedProjectId" + ng-disabled="!shouldEnableProjectSelect()" + ) + option(value="" disabled selected) - Please Select a Project + option( + ng-repeat="project in data.projects" + value="{{ project._id }}" + ) {{ project.name }} + + br + .form-controls + label(for="project-entity-select") Select a File + span(ng-show="state.inFlight.entities") + |   + i.fa.fa-spinner.fa-spin + select.form-control( + name="project-entity-select" + ng-model="data.selectedProjectEntity" + ng-disabled="!shouldEnableProjectEntitySelect()" + ) + option(value="" disabled selected) - Please Select a File + option( + ng-repeat="projectEntity in data.projectEntities" + value="{{ projectEntity.path }}" + ) {{ projectEntity.path.slice(1) }} + br + + .form-controls + label(for="name") File Name In This Project + input.form-control( + type="text" + placeholder="example.tex" + required + ng-model="data.name" + name="name" + ) + br + + .modal-footer + span(ng-show="state.inFlight.create") + i.fa.fa-spinner.fa-spin + |   + button.btn.btn-default( + ng-disabled="state.inflight" + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-primary( + ng-disabled="!shouldEnableCreateButton()" + ng-click="create()" + ) + span(ng-hide="state.inflight") #{translate("create")} + span(ng-show="state.inflight") #{translate("creating")}... + + script(type='text/ng-template', id='linkedFileModalTemplate') .modal-header h3 New file from URL diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug deleted file mode 100644 index 3356dc2249..0000000000 --- a/services/web/app/views/project/editor/history-file-tree.pug +++ /dev/null @@ -1,17 +0,0 @@ -aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view == 'history' && history.isV2").full-size - .toolbar.toolbar-filetree - span Modified files - - .file-tree-inner - ul.list-unstyled.file-tree-list - li( - ng-repeat="(pathname, doc) in history.selection.docs" - ng-class="{ 'selected': history.selection.pathname == pathname }" - ) - .entity - .entity-name.entity-name-history( - ng-click="history.selection.pathname = pathname", - ng-class="{ 'deleted': !!doc.deletedAtV }" - ) - i.fa.fa-fw.fa-pencil - span {{ pathname }} diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug index d366d5f41a..a7a52d2927 100644 --- a/services/web/app/views/project/editor/history.pug +++ b/services/web/app/views/project/editor/history.pug @@ -40,90 +40,11 @@ div#history(ng-show="ui.view == 'history'") p a.small(href, ng-click="toggleHistory()") #{translate("cancel")} - aside.change-list( - ng-controller="HistoryListController" - infinite-scroll="loadMore()" - infinite-scroll-disabled="history.loading || history.atEnd" - infinite-scroll-initialize="ui.view == 'history'" - ) - .infinite-scroll-inner - ul.list-unstyled( - ng-class="{\ - 'hover-state': history.hoveringOverListSelectors\ - }" - ) - li.change( - ng-repeat="update in history.updates" - ng-class="{\ - 'first-in-day': update.meta.first_in_day,\ - 'selected': update.inSelection,\ - 'selected-to': update.selectedTo,\ - 'selected-from': update.selectedFrom,\ - 'hover-selected': update.inHoverSelection,\ - 'hover-selected-to': update.hoverSelectedTo,\ - 'hover-selected-from': update.hoverSelectedFrom,\ - }" - ng-controller="HistoryListItemController" - ) - - div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} - - div.selectors - div.range - form - input.selector-from( - type="radio" - name="fromVersion" - ng-model="update.selectedFrom" - ng-value="true" - ng-mouseover="mouseOverSelectedFrom()" - ng-mouseout="mouseOutSelectedFrom()" - ng-show="update.afterSelection || update.inSelection" - ) - form - input.selector-to( - type="radio" - name="toVersion" - ng-model="update.selectedTo" - ng-value="true" - ng-mouseover="mouseOverSelectedTo()" - ng-mouseout="mouseOutSelectedTo()" - ng-show="update.beforeSelection || update.inSelection" - ) - - div.description(ng-click="select()") - div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} - 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(ng-if="project_op.remove") - .action Deleted - .doc {{ project_op.remove.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%)'}") - .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") - .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") - .name(ng-if="update_user && update_user.id == user.id") You - .name(ng-if="update_user == null") #{translate("anonymous")} - div.user(ng-if="update.meta.users.length == 0") - .color-square(style="background-color: hsl(100, 100%, 50%)") - span #{translate("anonymous")} - - .loading(ng-show="history.loading") - i.fa.fa-spin.fa-refresh - |    #{translate("loading")}... + include ./history/entriesListV1 + include ./history/entriesListV2 include ./history/diffPanelV1 - include ./history/diffPanelV2 + include ./history/previewPanelV2 script(type="text/ng-template", id="historyRestoreDiffModalTemplate") .modal-header diff --git a/services/web/app/views/project/editor/history/diffPanelV1.pug b/services/web/app/views/project/editor/history/diffPanelV1.pug index 1720f48b59..369f3703b0 100644 --- a/services/web/app/views/project/editor/history/diffPanelV1.pug +++ b/services/web/app/views/project/editor/history/diffPanelV1.pug @@ -13,8 +13,8 @@ }" ) | in {{history.diff.pathname}} - .toolbar-right - a.btn.btn-danger.btn-sm( + .toolbar-right(ng-if="permissions.write") + a.btn.btn-danger.btn-xs( href, ng-click="openRestoreDiffModal()" ) #{translate("restore_to_before_these_changes")} diff --git a/services/web/app/views/project/editor/history/entriesListV1.pug b/services/web/app/views/project/editor/history/entriesListV1.pug new file mode 100644 index 0000000000..27f9e66fe1 --- /dev/null +++ b/services/web/app/views/project/editor/history/entriesListV1.pug @@ -0,0 +1,82 @@ +aside.change-list( + ng-if="!history.isV2" + ng-controller="HistoryListController" + infinite-scroll="loadMore()" + infinite-scroll-disabled="history.loading || history.atEnd" + infinite-scroll-initialize="ui.view == 'history'" + ) + .infinite-scroll-inner + ul.list-unstyled( + ng-class="{\ + 'hover-state': history.hoveringOverListSelectors\ + }" + ) + li.change( + ng-repeat="update in history.updates" + ng-class="{\ + 'first-in-day': update.meta.first_in_day,\ + 'selected': update.inSelection,\ + 'selected-to': update.selectedTo,\ + 'selected-from': update.selectedFrom,\ + 'hover-selected': update.inHoverSelection,\ + 'hover-selected-to': update.hoverSelectedTo,\ + 'hover-selected-from': update.hoverSelectedFrom,\ + }" + ng-controller="HistoryListItemController" + ) + + div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} + + div.selectors + div.range + form + input.selector-from( + type="radio" + name="fromVersion" + ng-model="update.selectedFrom" + ng-value="true" + ng-mouseover="mouseOverSelectedFrom()" + ng-mouseout="mouseOutSelectedFrom()" + ng-show="update.afterSelection || update.inSelection" + ) + form + input.selector-to( + type="radio" + name="toVersion" + ng-model="update.selectedTo" + ng-value="true" + ng-mouseover="mouseOverSelectedTo()" + ng-mouseout="mouseOutSelectedTo()" + ng-show="update.beforeSelection || update.inSelection" + ) + + div.description(ng-click="select()") + div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} + div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") + | #{translate("file_action_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 #{translate("file_action_renamed")} + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action #{translate("file_action_created")} + .doc {{ project_op.add.pathname }} + div(ng-if="project_op.remove") + .action #{translate("file_action_deleted")} + .doc {{ project_op.remove.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%)'}") + .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") + .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") + .name(ng-if="update_user && update_user.id == user.id") You + .name(ng-if="update_user == null") #{translate("anonymous")} + div.user(ng-if="update.meta.users.length == 0") + .color-square(style="background-color: hsl(100, 100%, 50%)") + span #{translate("anonymous")} + + .loading(ng-show="history.loading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug new file mode 100644 index 0000000000..fa7a90b20e --- /dev/null +++ b/services/web/app/views/project/editor/history/entriesListV2.pug @@ -0,0 +1,174 @@ +aside.change-list( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" + ng-controller="HistoryV2ListController" +) + history-entries-list( + entries="history.updates" + current-user="user" + load-entries="loadMore()" + load-disabled="history.loading || history.atEnd" + load-initialize="ui.view == 'history'" + is-loading="history.loading" + on-entry-select="handleEntrySelect(selectedEntry)" + ) + +aside.change-list( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE" + ng-controller="HistoryListController" + infinite-scroll="loadMore()" + infinite-scroll-disabled="history.loading || history.atEnd" + infinite-scroll-initialize="ui.view == 'history'" +) + .infinite-scroll-inner + ul.list-unstyled( + ng-class="{\ + 'hover-state': history.hoveringOverListSelectors\ + }" + ) + li.change( + ng-repeat="update in history.updates" + ng-class="{\ + 'first-in-day': update.meta.first_in_day,\ + 'selected': update.inSelection,\ + 'selected-to': update.selectedTo,\ + 'selected-from': update.selectedFrom,\ + 'hover-selected': update.inHoverSelection,\ + 'hover-selected-to': update.hoverSelectedTo,\ + 'hover-selected-from': update.hoverSelectedFrom,\ + }" + ng-controller="HistoryListItemController" + ) + + div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }} + + div.selectors + div.range + form + input.selector-from( + type="radio" + name="fromVersion" + ng-model="update.selectedFrom" + ng-value="true" + ng-mouseover="mouseOverSelectedFrom()" + ng-mouseout="mouseOutSelectedFrom()" + ng-show="update.afterSelection || update.inSelection" + ) + form + input.selector-to( + type="radio" + name="toVersion" + ng-model="update.selectedTo" + ng-value="true" + ng-mouseover="mouseOverSelectedTo()" + ng-mouseout="mouseOutSelectedTo()" + ng-show="update.beforeSelection || update.inSelection" + ) + + div.description(ng-click="select()") + div.time {{ update.meta.end_ts | formatDate:'h:mm a' }} + div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0") + | #{translate("file_action_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 #{translate("file_action_renamed")} + .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }} + div(ng-if="project_op.add") + .action #{translate("file_action_created")} + .doc {{ project_op.add.pathname }} + div(ng-if="project_op.remove") + .action #{translate("file_action_deleted")} + .doc {{ project_op.remove.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%)'}") + .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}") + .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)") + .name(ng-if="update_user && update_user.id == user.id") You + .name(ng-if="update_user == null") #{translate("anonymous")} + div.user(ng-if="update.meta.users.length == 0") + .color-square(style="background-color: hsl(100, 100%, 50%)") + span #{translate("anonymous")} + + .loading(ng-show="history.loading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... + +script(type="text/ng-template", id="historyEntriesListTpl") + .history-entries( + infinite-scroll="$ctrl.loadEntries()" + infinite-scroll-disabled="$ctrl.loadDisabled" + infinite-scroll-initialize="$ctrl.loadInitialize" + ) + .infinite-scroll-inner + history-entry( + ng-repeat="entry in $ctrl.entries" + entry="entry" + current-user="$ctrl.currentUser" + on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })" + ng-show="!$ctrl.isLoading" + ) + .loading(ng-show="$ctrl.isLoading") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... + +script(type="text/ng-template", id="historyEntryTpl") + .history-entry( + ng-class="{\ + 'history-entry-first-in-day': $ctrl.entry.meta.first_in_day,\ + 'history-entry-selected': $ctrl.entry.inSelection,\ + 'history-entry-selected-to': $ctrl.entry.selectedTo,\ + 'history-entry-selected-from': $ctrl.entry.selectedFrom,\ + 'history-entry-hover-selected': $ctrl.entry.inHoverSelection,\ + 'history-entry-hover-selected-to': $ctrl.entry.hoverSelectedTo,\ + 'history-entry-hover-selected-from': $ctrl.entry.hoverSelectedFrom,\ + }" + ) + + time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }} + + .history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })") + ol.history-entry-changes + li.history-entry-change( + ng-repeat="pathname in ::$ctrl.entry.pathnames" + ) + span.history-entry-change-action #{translate("file_action_edited")} + span.history-entry-change-doc {{ ::pathname }} + li.history-entry-change( + ng-repeat="project_op in ::$ctrl.entry.project_ops" + ) + span.history-entry-change-action( + ng-if="::project_op.rename" + ) #{translate("file_action_renamed")} + span.history-entry-change-action( + ng-if="::project_op.add" + ) #{translate("file_action_created")} + span.history-entry-change-action( + ng-if="::project_op.remove" + ) #{translate("file_action_deleted")} + span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }} + .history-entry-metadata + time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }} + span + | + | • + | + ol.history-entry-metadata-users + li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users") + span.name( + ng-if="::update_user && update_user.id != $ctrl.currentUser.id" + ng-style="$ctrl.getUserCSSStyle(update_user);" + ) {{ ::$ctrl.displayName(update_user) }} + span.name( + ng-if="::update_user && update_user.id == $ctrl.currentUser.id" + ng-style="$ctrl.getUserCSSStyle(update_user);" + ) You + span.name( + ng-if="::update_user == null" + ng-style="$ctrl.getUserCSSStyle(update_user);" + ) #{translate("anonymous")} + li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0") + span.name( + ng-style="$ctrl.getUserCSSStyle();" + ) #{translate("anonymous")} \ No newline at end of file diff --git a/services/web/app/views/project/editor/history/fileTreeV2.pug b/services/web/app/views/project/editor/history/fileTreeV2.pug new file mode 100644 index 0000000000..0f3a2c1203 --- /dev/null +++ b/services/web/app/views/project/editor/history/fileTreeV2.pug @@ -0,0 +1,70 @@ +aside.file-tree.full-size( + ng-controller="HistoryV2FileTreeController" + ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" +) + .history-file-tree-inner + history-file-tree( + file-tree="currentFileTree" + selected-pathname="history.selection.pathname" + on-selected-file-change="handleFileSelection(file)" + is-loading="history.loadingFileTree" + ) + +aside.file-tree.file-tree-history.full-size( + ng-controller="FileTreeController" + ng-class="{ 'multi-selected': multiSelectedCount > 0 }" + ng-show="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.COMPARE") + .toolbar.toolbar-filetree + span Modified files + + .file-tree-inner + ul.list-unstyled.file-tree-list + li( + ng-repeat="(pathname, doc) in history.selection.docs" + ng-class="{ 'selected': history.selection.pathname == pathname }" + ) + .entity + .entity-name.entity-name-history( + ng-click="history.selection.pathname = pathname", + ng-class="{ 'deleted': !!doc.deletedAtV }" + ) + i.fa.fa-fw.fa-pencil + span {{ pathname }} + + + + +script(type="text/ng-template", id="historyFileTreeTpl") + .history-file-tree + history-file-entity( + ng-repeat="fileEntity in $ctrl.fileTree" + file-entity="fileEntity" + ng-show="!$ctrl.isLoading" + ) + + +script(type="text/ng-template", id="historyFileEntityTpl") + .history-file-entity-wrapper + a.history-file-entity-link( + href + ng-click="$ctrl.handleClick()" + ng-class="{ 'history-file-entity-link-selected': $ctrl.isSelected }" + ) + span.history-file-entity-name + i.history-file-entity-icon.history-file-entity-icon-folder-state.fa.fa-fw( + ng-class="{\ + 'fa-chevron-down': ($ctrl.fileEntity.type === 'folder' && $ctrl.isOpen),\ + 'fa-chevron-right': ($ctrl.fileEntity.type === 'folder' && !$ctrl.isOpen)\ + }" + ) + i.history-file-entity-icon.fa( + ng-class="$ctrl.iconClass" + ) + | {{ ::$ctrl.fileEntity.name }} + div( + ng-show="$ctrl.isOpen" + ) + history-file-entity( + ng-repeat="childEntity in $ctrl.fileEntity.children" + file-entity="childEntity" + ) diff --git a/services/web/app/views/project/editor/history/diffPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug similarity index 58% rename from services/web/app/views/project/editor/history/diffPanelV2.pug rename to services/web/app/views/project/editor/history/previewPanelV2.pug index 415d587155..3d7a1ac3df 100644 --- a/services/web/app/views/project/editor/history/diffPanelV2.pug +++ b/services/web/app/views/project/editor/history/previewPanelV2.pug @@ -1,4 +1,7 @@ -.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController") +.diff-panel.full-size( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE" + ng-controller="HistoryV2DiffController" +) .diff( ng-if="!!history.diff && !history.diff.loading && !history.diff.error", ng-class="{ 'diff-binary': history.diff.binary }" @@ -16,8 +19,13 @@ }" ) | in {{history.diff.pathname}} + .history-toolbar-btn( + ng-click="toggleHistoryViewMode();" + ) + i.fa + | #{translate("view_single_version")} .toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV") - button.btn.btn-danger.btn-sm( + button.btn.btn-danger.btn-xs( ng-click="restoreDeletedFile()" ng-show="!restoreState.error" ng-disabled="restoreState.inflight" @@ -47,4 +55,27 @@ i.fa.fa-spin.fa-refresh |   #{translate("loading")}... .error-panel(ng-show="history.diff.error") - .alert.alert-danger #{translate("generic_something_went_wrong")} \ No newline at end of file + .alert.alert-danger #{translate("generic_something_went_wrong")} + +.point-in-time-panel.full-size( + ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" +) + .point-in-time-editor-container( + ng-if="!!history.selectedFile && !history.selectedFile.loading && !history.selectedFile.error" + ) + .hide-ace-cursor( + ng-if="!history.selectedFile.binary" + ace-editor="history-pointintime", + theme="settings.theme", + font-size="settings.fontSize", + text="history.selectedFile.text", + read-only="true", + resize-on="layout:main:resize", + ) + .alert.alert-info(ng-if="history.selectedFile.binary") + | We're still working on showing image and binary changes, sorry. Stay tuned! + .loading-panel(ng-show="history.selectedFile.loading") + i.fa.fa-spin.fa-refresh + |   #{translate("loading")}... + .error-panel(ng-show="history.selectedFile.error") + .alert.alert-danger #{translate("generic_something_went_wrong")} diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug new file mode 100644 index 0000000000..799a7136f3 --- /dev/null +++ b/services/web/app/views/project/editor/history/toolbarV2.pug @@ -0,0 +1,13 @@ +.history-toolbar( + ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME" +) + span(ng-show="history.loadingFileTree") + i.fa.fa-spin.fa-refresh + |    #{translate("loading")}... + span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}  + time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }} + .history-toolbar-btn( + ng-click="toggleHistoryViewMode();" + ) + i.fa + | #{translate("compare_to_another_version")} \ No newline at end of file diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index ed6ef85b44..d85a45723b 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -62,6 +62,23 @@ aside#left-menu.full-size( != moduleIncludes("editorLeftMenu:editing_services", locals) + if showTestControls + h4 Test Controls + ul.list-unstyled.nav(ng-controller="TestControlsController") + li + a(href="#" ng-click="richText()") + i.fa.fa-exclamation.fa-fw + | Rich Text + li + a(href="#" ng-click="openProjectLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | Project-Linked-File Modal + li + a(href="#" ng-click="openLinkedFileModal()") + i.fa.fa-exclamation.fa-fw + | URL-Linked-File Modal + + h4(ng-show="!anonymous") #{translate("settings")} form.settings(ng-controller="SettingsController", ng-show="!anonymous") .containter-fluid @@ -179,6 +196,7 @@ aside#left-menu.full-size( option(value="pdfjs") #{translate("built_in")} option(value="native") #{translate("native")} + h4 #{translate("hotkeys")} ul.list-unstyled.nav li(ng-controller="HotkeysController") diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug new file mode 100644 index 0000000000..6dc27a4241 --- /dev/null +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -0,0 +1,26 @@ +extends ../../layout + +block content + script. + $(document).ready(function(){ + $('#create_form').submit(); + }); + + .editor.full-size + .loading-screen() + .loading-screen-brand-container + .loading-screen-brand( + style="height: 20%;" + ) + + h3.loading-screen-label() #{translate("Opening template")} + span.loading-screen-ellip . + span.loading-screen-ellip . + span.loading-screen-ellip . + + form(id='create_form' method='POST' action='/project/new/template/') + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden" name="templateId" value=templateId) + input(type="hidden" name="templateVersionId" value=templateVersionId) + input(type="hidden" name="templateName" value=name) + input(type="hidden" name="compiler" value=compiler) diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug index bfc53a8360..5a9cb2138d 100644 --- a/services/web/app/views/project/list/item.pug +++ b/services/web/app/views/project/list/item.pug @@ -1,4 +1,7 @@ -.col-xs-6 +- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6" +- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + +div(class=titleClasses) input.select-item( select-individual, type="checkbox", @@ -37,8 +40,50 @@ tooltip-placement="right" tooltip-append-to-body="true" ) -.col-xs-4 + +div(class=lastUpdatedClasses) if settings.overleaf span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} else span.last-modified {{project.lastUpdated | formatDate}} + +if settings.overleaf + .hidden-xs.col-sm-3.col-md-2.action-btn-row + button.btn.btn-link.action-btn( + tooltip=translate('copy'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="clone($event)" + ) + i.icon.fa.fa-files-o + button.btn.btn-link.action-btn( + tooltip=translate('download'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="download($event)" + ) + i.icon.fa.fa-cloud-download + button.btn.btn-link.action-btn( + ng-if="!project.archived && isOwner()" + tooltip=translate('archive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="archiveOrLeave($event)" + ) + i.icon.fa.fa-inbox + button.btn.btn-link.action-btn( + ng-if="!project.archived && !isOwner()" + tooltip=translate('leave'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="archiveOrLeave($event)" + ) + i.icon.fa.fa-sign-out + button.btn.btn-link.action-btn( + ng-if="project.archived" + tooltip=translate('unarchive'), + tooltip-placement="top", + tooltip-append-to-body="true", + ng-click="restore($event)" + ) + i.icon.fa.fa-reply \ No newline at end of file diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index df3c2bf681..cfea4aa6c6 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -131,7 +131,10 @@ ) li.container-fluid .row - .col-xs-6 + - var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6" + - var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4" + + div(class=titleClasses) input.select-all( select-all, type="checkbox" @@ -142,9 +145,12 @@ .col-xs-2 span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")} i.tablesort.fa(ng-class="getSortIconClass('accessLevel')") - .col-xs-4 + div(class=lastUpdatedClasses) span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")} i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')") + if settings.overleaf + .hidden-xs.col-sm-3.col-md-2.action-btn-row-header + span.header #{translate("actions")} li.project_entry.container-fluid( ng-repeat="project in visibleProjects | orderBy:predicate:reverse", ng-controller="ProjectListItemController" diff --git a/services/web/app/views/project/list/v1-item.pug b/services/web/app/views/project/list/v1-item.pug index b4a3ccb99d..5a8e37bca0 100644 --- a/services/web/app/views/project/list/v1-item.pug +++ b/services/web/app/views/project/list/v1-item.pug @@ -1,4 +1,4 @@ -.col-xs-6 +.col-xs-6.col-sm-4.col-md-6 .select-item span.v1-badge( aria-label=translate("v1_badge") @@ -21,5 +21,5 @@ .col-xs-2 span.owner {{ownerName()}} -.col-xs-4 +.col-xs-4.col-sm-3.col-md-2 span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}} \ No newline at end of file diff --git a/services/web/app/views/subscriptions/_plans_page_details_less.pug b/services/web/app/views/subscriptions/_plans_page_details_less.pug new file mode 100644 index 0000000000..2aa572e4e7 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_details_less.pug @@ -0,0 +1,118 @@ +.row + .col-md-12 + .page-header.centered.plans-header.text-centered + h1 #{translate("start_x_day_trial", {len:'{{trial_len}}'})} +.row + .col-md-8.col-md-offset-2 + p.text-centered #{translate("sl_benefits_plans")} + +.row.top-switch + .col-md-6.col-md-offset-3 + +plan_switch('card') + .col-md-2.text-right + +currency_dropdown + +div(ng-show="showPlans") + .row + .col-md-10.col-md-offset-1 + .row + .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + .circle #{translate("free")} + +features_free + .col-md-4 + .card.card-highlighted + .card-header + h2 #{translate("collaborator")} + .circle + +price_collaborator + +features_collaborator + .col-md-4 + .card.card-last + .card-header + h2 #{translate("professional")} + .circle + +price_professional + +features_professional + + .card-group.text-centered(ng-if="ui.view == 'student'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + .circle #{translate("free")} + +features_free + + .col-md-4 + .card.card-highlighted + +card_student_monthly + + .col-md-4 + .card.card-last + +card_student_annual + +.row.row-spaced + p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})} + +.row + .col-md-8.col-md-offset-2 + .alert.alert-info.text-centered + | #{translate("interested_in_group_licence")} + br + a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")} + + script(type="text/ng-template", id="groupPlanModalTemplate") + .modal-header + h3 #{translate("group_plan_enquiry")} + .modal-body + form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()") + span(ng-show="sent == false && error == false") + .form-group + label#title9(for='Field9') + | Name + input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='') + label#title11.desc(for='Field11') + | Email + .form-group + input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + label#title12.desc(for='Field12') + | University / Company + .form-group + input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') + label#title13.desc(for='Field13') + | Position + .form-group + input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') + .form-group + input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';") + .form-group.text-center + input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') + span(ng-show="sent == true && error == false") + p Request Sent, Thank you. + span(ng-show="error") + p Error sending request. + +.row + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate("enjoy_these_features")} + .col-md-4 + .card.features.text-centered + i.fa.fa-file-text-o.fa-5x + h4 #{translate("unlimited_projects")} + p #{translate("create_unlimited_projects")} + .col-md-4 + .card.features.text-centered + i.fa.fa-clock-o.fa-5x + h4 #{translate("full_doc_history")} + p #{translate("never_loose_work")} + .col-md-4 + .card.features.text-centered + i.fa.fa-dropbox.fa-5x + |     + i.fa.fa-github.fa-5x + h4 #{translate("sync_to_dropbox_and_github")} + p #{translate("access_projects_anywhere")} \ No newline at end of file diff --git a/services/web/app/views/subscriptions/_plans_page_details_more.pug b/services/web/app/views/subscriptions/_plans_page_details_more.pug new file mode 100644 index 0000000000..17c683201e --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_details_more.pug @@ -0,0 +1,160 @@ +.row + .col-md-12 + .page-header.centered.plans-header.text-centered + h1.text-capitalize #{translate('instant_access')} +.row + .col-md-8.col-md-offset-2 + p.text-centered #{translate("sl_benefits_plans")} + +.row.top-switch + .col-md-6.col-md-offset-3 + +plan_switch('card') + .col-md-2.text-right + +currency_dropdown + +div(ng-show="showPlans") + .row + .col-md-10.col-md-offset-1 + .row + .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + h5.tagline #{translate("tagline_personal")} + .circle #{translate("free")} + +features_free + .col-md-4 + .card.card-highlighted + .best-value + strong #{translate('best_value')} + .card-header + h2 #{translate("collaborator")} + h5.tagline #{translate("tagline_collaborator")} + .circle + +price_collaborator + +features_collaborator + .col-md-4 + .card.card-last + .card-header + h2 #{translate("professional")} + h5.tagline #{translate("tagline_professional")} + .circle + +price_professional + +features_professional + + .card-group.text-centered(ng-if="ui.view == 'student'") + .col-md-4 + .card.card-first + .card-header + h2 #{translate("personal")} + h5.tagline #{translate("tagline_personal")} + .circle #{translate("free")} + +features_free + + .col-md-4 + .card.card-highlighted + +card_student_annual + + .col-md-4 + .card.card-last + +card_student_monthly + +.row.row-spaced-large.text-centered + i.fa.fa-cc-mastercard.fa-2x   + i.fa.fa-cc-visa.fa-2x   + i.fa.fa-cc-amex.fa-2x   + i.fa.fa-cc-paypal.fa-2x   + div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'{{trial_len}}'})} + +.row.row-spaced-large + .col-md-8.col-md-offset-2 + .card.text-centered + .card-header + h2 #{translate('looking_multiple_licenses')} + span #{translate('reduce_costs_group_licenses')} + br + br + a.btn.btn-info(href="/i/university/groups") #{translate('find_out_more')} + +div + .row.row-spaced-large + .col-sm-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('compare_plan_features')} + .row + .col-md-6.col-md-offset-3 + +plan_switch('table') + .col-md-3.text-right + +currency_dropdown + .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true") + .col-sm-12(ng-if="ui.view != 'student'") + +table_premium + .col-sm-12(ng-if="ui.view == 'student'") + +table_student + + .row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 #{translate('in_good_company')} + .row + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/erdogmus.jpg') alt="Professor Erdogmus") + .col-md-9 + blockquote + p The ability to track changes and the real-time collaborative nature is what sets ShareLaTeX apart. + footer Professor Erdogmus, Northeastern University + .col-md-6 + div + .row + .col-md-3 + .circle-img + img(src=buildImgPath('advocates/henderson.jpg') alt="Rob Henderson") + .col-md-9 + blockquote + p ShareLaTeX has proven to be a powerful and robust collaboration tool that is widely used in our School. + footer Rob Henderson, School Of Informatics And Computing - Indiana University + + .faq + .row.row-spaced-large + .col-md-12 + .page-header.plans-header.plans-subheader.text-centered + h2 FAQ + .row + .col-md-6 + h3 #{translate("faq_how_free_trial_works_question")} + p #{translate('faq_how_free_trial_works_answer', { len:'{{trial_len}}' })} + .col-md-6 + h3 #{translate('faq_change_plans_question')} + p #{translate('faq_change_plans_answer')} + .row + .col-md-6 + h3 #{translate('faq_do_collab_need_premium_question')} + p #{translate('faq_do_collab_need_premium_answer')} + .col-md-6 + h3 #{translate('faq_need_more_collab_question')} + p !{translate('faq_need_more_collab_answer', { referFriendsLink: '' + translate('referring_your_friends') + ''})} + .row + .col-md-6 + h3 #{translate('faq_purchase_more_licenses_question')} + p !{translate('faq_purchase_more_licenses_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })} + .col-md-6 + h3 #{translate('faq_monthly_or_annual_question')} + p #{translate('faq_monthly_or_annual_answer')} + .row + .col-md-6 + h3 #{translate('faq_how_to_pay_question')} + p #{translate('faq_how_to_pay_answer')} + .col-md-6 + h3 #{translate('faq_pay_by_invoice_question')} + p !{translate('faq_pay_by_invoice_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })} + .row.row-spaced-large.text-centery + .col-md-12 + .plans-header.plans-subheader.text-centered + h2 #{translate('still_have_questions')} + button.btn.btn-info.btn-header.text-capitalize(ng-controller="ContactGeneralModal" ng-click="openModal()") #{translate('get_in_touch')} + != moduleIncludes("contactModalGeneral", locals) diff --git a/services/web/app/views/subscriptions/_plans_page_mixins.pug b/services/web/app/views/subscriptions/_plans_page_mixins.pug new file mode 100644 index 0000000000..c1f19bca00 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_mixins.pug @@ -0,0 +1,162 @@ +//- Buy Buttons +mixin btn_buy_collaborator(location) + a.btn.btn-info( + ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", + ng-click="signUpNowClicked('collaborator','" + location + "')" + ) + span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} + span(ng-show="ui.view == 'annual'") #{translate("buy_now")} +mixin btn_buy_free(location) + a.btn.btn-info( + href="/register" + style=(getLoggedInUserId() === null ? "" : "visibility: hidden") + ng-click="signUpNowClicked('free','" + location + "')" + ) + span(ng-if="plansVariant !== 'more-details'") #{translate('sign_up_now')} + span.text-capitalize(ng-if="plansVariant === 'more-details'") #{translate('get_started_now')} +mixin btn_buy_professional(location) + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}" + ng-click="signUpNowClicked('professional','" + location + "')" + ) + span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} + span(ng-show="ui.view == 'annual'") #{translate("buy_now")} +mixin btn_buy_student(location, plan) + if plan == 'annual' + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=student-annual¤cy={{currencyCode}}", + ng-click="signUpNowClicked('student-annual','" + location + "')" + ) #{translate("buy_now")} + else + //- planQueryString will contain _free_trial_7_days + a.btn.btn-info( + ng-href="/user/subscription/new?planCode=student{{planQueryString}}¤cy={{currencyCode}}", + ng-click="signUpNowClicked('student-monthly','" + location + "')" + ) #{translate("start_free_trial")} + +//- Cards +mixin card_student_annual + .best-value(ng-if="plansVariant == 'more-details'") + strong #{translate('best_value')} + .card-header + h2 #{translate("student")} (#{translate("annual")}) + h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_annual')} + .circle + span + +price_student_annual + +features_student('card', 'annual') +mixin card_student_monthly + .card-header + h2 #{translate("student")} + h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_monthly')} + .circle + span + +price_student_monthly + +features_student('card', 'monthly') + +//- Features Lists +mixin features_collaborator + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:10})} + +features_premium + li + br + +btn_buy_collaborator('card') +mixin features_free + ul.list-unstyled + li #{translate("one_collaborator")} + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")   + li + br + +btn_buy_free('card') +mixin features_premium + li(ng-if="plansVariant != 'more-details'") #{translate("full_doc_history")} + li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_dropbox")} + li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_github")} + li(ng-if="plansVariant === 'more-details'")   + li(ng-if="plansVariant === 'more-details'") + strong #{translate('all_premium_features')} + li(ng-if="plansVariant === 'more-details'") #{translate('sync_dropbox_github')} + li(ng-if="plansVariant === 'more-details'") #{translate('full_doc_history')} + li(ng-if="plansVariant === 'more-details'") #{translate('track_changes')} + li(ng-if="plansVariant === 'more-details'") + #{translate('more').toLowerCase()} +mixin features_professional + ul.list-unstyled + li + strong #{translate("unlimited_collabs")} + +features_premium + li + br + +btn_buy_professional('card') +mixin features_student(location, plan) + ul.list-unstyled + li + strong #{translate("collabs_per_proj", {collabcount:6})} + +features_premium + li + br + +btn_buy_student(location, plan) + +//- Prices +mixin price_collaborator + span(ng-if="ui.view == 'monthly'") + | {{plans[currencyCode]['collaborator']['monthly']}} + span.small /mo + span(ng-if="ui.view == 'annual'") + | {{plans[currencyCode]['collaborator']['annual']}} + span.small /yr +mixin price_professional + span(ng-if="ui.view == 'monthly'") + | {{plans[currencyCode]['professional']['monthly']}} + span.small /mo + span(ng-if="ui.view == 'annual'") + | {{plans[currencyCode]['professional']['annual']}} + span.small /yr +mixin price_student_annual + | {{plans[currencyCode]['student']['annual']}} + span.small /yr +mixin price_student_monthly + | {{plans[currencyCode]['student']['monthly']}} + span.small /mo + +//- UI Control +mixin currency_dropdown + .dropdown.currency-dropdown(dropdown) + a.btn.btn-default.dropdown-toggle( + href="#", + data-toggle="dropdown", + dropdown-toggle + ) + | {{currencyCode}} ({{plans[currencyCode]['symbol']}}) + span.caret + + ul.dropdown-menu.dropdown-menu-right.text-right(role="menu") + li(ng-repeat="(currency, value) in plans") + a( + href="#", + ng-click="changeCurreny($event, currency)" + ) {{currency}} ({{value['symbol']}}) +mixin plan_switch(location) + ul.nav.nav-pills + li(ng-class="{'active': ui.view == 'monthly'}") + a( + href="#" + ng-click="switchToMonthly($event,'" + location + "')" + ) #{translate("monthly")} + li(ng-class="{'active': ui.view == 'annual'}") + a( + href="#" + ng-click="switchToAnnual($event,'" + location + "')" + ) #{translate("annual")} + li(ng-class="{'active': ui.view == 'student'}") + a( + href="#" + ng-click="switchToStudent($event,'" + location + "')" + ) #{translate("half_price_student")} + diff --git a/services/web/app/views/subscriptions/_plans_page_tables.pug b/services/web/app/views/subscriptions/_plans_page_tables.pug new file mode 100644 index 0000000000..63c4747603 --- /dev/null +++ b/services/web/app/views/subscriptions/_plans_page_tables.pug @@ -0,0 +1,107 @@ + +//- Features Tables +mixin table_premium + table.card.plans-table + tr + th + th #{translate("personal")} + th #{translate("collaborator")} + .outer.outer-top + .outer-content + .best-value + strong #{translate('best_value')} + th #{translate("professional")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_collaborator + td + +price_professional + + for feature in planFeatures + tr + td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + for plan in feature.plans + td + if feature.value == 'str' + | #{plan} + else if plan + i.fa.fa-check + else + i.fa.fa-times + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_collaborator('table') + .outer.outer-btm + .outer-content   + td + +btn_buy_professional('table') + +mixin table_cell_student(feature) + if feature.value == 'str' + | #{feature.student} + else if feature.student + i.fa.fa-check + else + i.fa.fa-times + +mixin table_student + table.card.plans-table + tr + th + th #{translate("personal")} + th #{translate("student")} (#{translate("annual")}) + .outer.outer-top + .outer-content + .best-value + strong Best Value + th #{translate("student")} + + tr + td #{translate("price")} + td #{translate("free")} + td + +price_student_annual + td + +price_student_monthly + + for feature in planFeatures + tr + td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`) + if feature.info + span(tooltip=translate(feature.info)) #{translate(feature.feature)} + else + | #{translate(feature.feature)} + td + if feature.value == 'str' + | #{feature.plans.free} + else if feature.plans.free + i.fa.fa-check + else + i.fa.fa-times + td + +table_cell_student(feature) + td + +table_cell_student(feature) + + tr + td + td + +btn_buy_free('table') + td + +btn_buy_student('table', 'annual') + .outer.outer-btm + .outer-content   + td + +btn_buy_student('table', 'monthly') + diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug index d86bdb8166..4eae442f1e 100644 --- a/services/web/app/views/subscriptions/new.pug +++ b/services/web/app/views/subscriptions/new.pug @@ -31,7 +31,10 @@ block content li(ng-repeat="(currency, value) in plans") a( ng-click="changeCurrency(currency)", - ) {{currency}} ({{value['symbol']}}) + ) {{currency}} ({{value['symbol']}}) + .row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'") + .col-xs-12 + p.student-disclaimer #{translate('student_disclaimer')} hr.thin .row .col-md-12.text-center diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 7c40c38a52..56a20f90f5 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -1,253 +1,18 @@ extends ../layout + +include _plans_page_mixins +include _plans_page_tables + block scripts script(type='text/javascript'). window.recomendedCurrency = '#{recomendedCurrency}' window.abCurrencyFlag = '#{abCurrencyFlag}' window.shouldABTestPlans = #{shouldABTestPlans || false} - script(type='text/javascript'). - (function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; - s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js'; - var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})(); - block content .content.content-alt .content.plans(ng-controller="PlansController") - .container - .row - .col-md-12 - .page-header.centered.plans-header.text-centered - h1(ng-cloak) #{translate("start_x_day_trial", {len:'{{trial_len}}'})} - .row - .col-md-8.col-md-offset-2 - p.text-centered #{translate("sl_benefits_plans")} - - .row(ng-cloak) - .col-md-6.col-md-offset-3 - ul.nav.nav-pills - li(ng-class="{'active': ui.view == 'monthly'}") - a( - href, - ng-click="switchToMonthly()" - ) #{translate("monthly")} - li(ng-class="{'active': ui.view == 'annual'}") - a( - href - ng-click="switchToAnnual()" - ) #{translate("annual")} - li(ng-class="{'active': ui.view == 'student'}") - a( - href, - ng-click="switchToStudent()" - ) #{translate("half_price_student")} - .col-md-2.text-right - .dropdown.currency-dropdown(dropdown) - a.btn.btn-default.dropdown-toggle#currenyDropdown( - href="#", - data-toggle="dropdown", - dropdown-toggle - ) - | {{currencyCode}} ({{plans[currencyCode]['symbol']}}) - span.caret - - ul.dropdown-menu.dropdown-menu-right.text-right(role="menu") - li(ng-repeat="(currency, value) in plans") - a( - href, - ng-click="changeCurreny(currency)" - ) {{currency}} ({{value['symbol']}}) - - div(ng-show="showPlans") - .row(ng-cloak) - .col-md-10.col-md-offset-1 - .row - .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'") - .col-md-4 - .card.card-first - .card-header - h2 #{translate("personal")} - .circle #{translate("free")} - ul.list-unstyled - li #{translate("one_collaborator")} - li   - li   - li   - li - br - a.btn.btn-info( - href="/register" - style=(getLoggedInUserId() === null ? "" : "visibility: hidden") - ) #{translate("sign_up_now")} - .col-md-4 - .card.card-highlighted - .card-header - h2 #{translate("collaborator")} - .circle - span(ng-if="ui.view == 'monthly'") - | {{plans[currencyCode]['collaborator']['monthly']}} - span.small /mo - span(ng-if="ui.view == 'annual'") - | {{plans[currencyCode]['collaborator']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:10})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", ng-click="signUpNowClicked('collaborator')" - ) - span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} - span(ng-show="ui.view == 'annual'") #{translate("buy_now")} - .col-md-4 - .card.card-last - .card-header - h2 #{translate("professional")} - .circle - span(ng-if="ui.view == 'monthly'") - | {{plans[currencyCode]['professional']['monthly']}} - span.small /mo - span(ng-if="ui.view == 'annual'") - | {{plans[currencyCode]['professional']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("unlimited_collabs")} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('professional')" - ) - span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")} - span(ng-show="ui.view == 'annual'") #{translate("buy_now")} - - .card-group.text-centered(ng-if="ui.view == 'student'") - .col-md-4 - .card.card-first - .card-header - h2 #{translate("personal")} - .circle #{translate("free")} - ul.list-unstyled - li #{translate("one_collaborator")} - li   - li   - li   - li - br - a.btn.btn-info( - href="/register" - style=(getLoggedInUserId() === null ? "" : "visibility: hidden") - ) #{translate("sign_up_now")} - - .col-md-4 - .card.card-highlighted - .card-header - h2 #{translate("student")} - .circle - span - | {{plans[currencyCode]['student']['monthly']}} - span.small /mo - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:6})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}¤cy={{currencyCode}}", - ng-click="signUpNowClicked('student-monthly')" - ) #{translate("start_free_trial")} - - .col-md-4 - .card.card-last - .card-header - h2 #{translate("student")} (#{translate("annual")}) - .circle - span - | {{plans[currencyCode]['student']['annual']}} - span.small /yr - ul.list-unstyled - li - strong #{translate("collabs_per_proj", {collabcount:6})} - li #{translate("full_doc_history")} - li #{translate("sync_to_dropbox")} - li #{translate("sync_to_github")} - li - br - a.btn.btn-info( - ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}¤cy={{currencyCode}}", - ng-click="signUpNowClicked('student-annual')" - ) #{translate("buy_now")} - - - - .row.row-spaced(ng-cloak) - p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})} - - .row(ng-cloak) - .col-md-8.col-md-offset-2 - .alert.alert-info.text-centered - | #{translate("interested_in_group_licence")} - br - a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")} - - script(type="text/ng-template", id="groupPlanModalTemplate") - .modal-header - h3 #{translate("group_plan_enquiry")} - .modal-body - form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak) - span(ng-show="sent == false && error == false") - .form-group - label#title9(for='Field9') - | Name - input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='') - label#title11.desc(for='Field11') - | Email - .form-group - input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2') - label#title12.desc(for='Field12') - | University / Company - .form-group - input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='') - label#title13.desc(for='Field13') - | Position - .form-group - input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='') - .form-group - input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';") - .form-group.text-center - input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote') - span(ng-show="sent == true && error == false") - p Request Sent, Thank you. - span(ng-show="error") - p Error sending request. - - .row - .col-md-12 - .page-header.plans-header.plans-subheader.text-centered - h2 #{translate("enjoy_these_features")} - .col-md-4 - .card.features.text-centered - i.fa.fa-file-text-o.fa-5x - h4 #{translate("unlimited_projects")} - p #{translate("create_unlimited_projects")} - .col-md-4 - .card.features.text-centered - i.fa.fa-clock-o.fa-5x - h4 #{translate("full_doc_history")} - p #{translate("never_loose_work")} - .col-md-4 - .card.features.text-centered - i.fa.fa-dropbox.fa-5x - |     - i.fa.fa-github.fa-5x - h4 #{translate("sync_to_dropbox_and_github")} - p #{translate("access_projects_anywhere")} + .container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'") + include _plans_page_details_more + .container(ng-cloak ng-if="plansVariant != 'more-details'") + include _plans_page_details_less diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 0892804778..7f4024c368 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -146,8 +146,8 @@ module.exports = settings = url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036" sixpack: url: "" - # references: - # url: "http://localhost:3040" + references: + url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040" notifications: url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042" analytics: diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index a062c0df4e..9596fe5126 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -17,6 +17,7 @@ services: PROJECT_HISTORY_ENABLED: 'true' ENABLED_LINKED_FILE_TYPES: 'url' LINKED_URL_PROXY: 'http://localhost:6543' + ENABLED_LINKED_FILE_TYPES: 'url,project_file' SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee depends_on: - redis diff --git a/services/web/public/coffee/directives/eventTracking.coffee b/services/web/public/coffee/directives/eventTracking.coffee index 9ba2fbb647..2710960fe0 100644 --- a/services/web/public/coffee/directives/eventTracking.coffee +++ b/services/web/public/coffee/directives/eventTracking.coffee @@ -4,11 +4,23 @@ # event not sent to MB. # for MB, add event-tracking-mb='true' # by default, event sent to MB via sendMB -# this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute # event not sent to GA. # for GA, add event-tracking-ga attribute, where the value is the GA category +# Either GA or MB can use the attribute event-tracking-send-once='true' to +# send event just once +# MB will use the key and GA will use the action to determine if the event +# has been sent # event-tracking-trigger attribute is required to send event +isInViewport = (element) -> + elTop = element.offset().top + elBtm = elTop + element.outerHeight() + + viewportTop = $(window).scrollTop() + viewportBtm = viewportTop + $(window).height() + + elBtm > viewportTop && elTop < viewportBtm + define [ 'base' ], (App) -> @@ -22,20 +34,42 @@ define [ sendGA = attrs.eventTrackingGa || false sendMB = attrs.eventTrackingMb || false sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB' + sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send' segmentation = scope.eventSegmentation || {} - segmentation.page = window.location.pathname - sendEvent = () -> + sendEvent = (scrollEvent) -> + ### + @param {boolean} scrollEvent Use to unbind scroll event + ### if sendMB event_tracking[sendMBFunction] scope.eventTracking, segmentation if sendGA - event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || '' + event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || '' + if scrollEvent + $(window).unbind('resize scroll') if attrs.eventTrackingTrigger == 'load' sendEvent() else if attrs.eventTrackingTrigger == 'click' element.on 'click', (e) -> sendEvent() + else if attrs.eventTrackingTrigger == 'hover' + timer = null + timeoutAmt = 500 + if attrs.eventHoverAmt + timeoutAmt = parseInt(attrs.eventHoverAmt, 10) + element.on 'mouseenter', () -> + timer = setTimeout((-> sendEvent()), timeoutAmt) + return + .on 'mouseleave', () -> + clearTimeout(timer) + else if attrs.eventTrackingTrigger == 'scroll' + if !event_tracking.eventInCache(scope.eventTracking) + $(window).on 'resize scroll', () -> + _.throttle( + if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking) + sendEvent(true) + , 500) } - ] \ No newline at end of file + ] diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 7c8602eb76..9f910d857e 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -18,6 +18,7 @@ define [ "ide/chat/index" "ide/clone/index" "ide/hotkeys/index" + "ide/test-controls/index" "ide/wordcount/index" "ide/directives/layout" "ide/directives/validFile" @@ -34,6 +35,7 @@ define [ "directives/videoPlayState" "services/queued-http" "services/validateCaptcha" + "services/wait-for" "filters/formatDate" "main/event" "main/account-upgrade" @@ -54,7 +56,7 @@ define [ SafariScrollPatcher ) -> - App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) -> + App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) -> # Don't freak out if we're already in an apply callback $scope.$originalApply = $scope.$apply $scope.$apply = (fn = () ->) -> @@ -211,11 +213,10 @@ define [ try chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null; browserIsChrome61or62 = ( - chromeVersion? && - (chromeVersion == 61 || chromeVersion == 62) + chromeVersion? ) if browserIsChrome61or62 - document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; }", 1) + document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1) catch err console.error err diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index c14e097842..bba455c447 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -2,7 +2,7 @@ define [ "base" "moment" ], (App, moment) -> - App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) -> + App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) -> TWO_MEGABYTES = 2 * 1024 * 1024 @@ -31,6 +31,7 @@ define [ data: null $scope.refreshing = false + $scope.refreshError = null MAX_URL_LENGTH = 60 FRONT_OF_URL_LENGTH = 35 @@ -48,9 +49,27 @@ define [ $scope.refreshFile = (file) -> $scope.refreshing = true + $scope.refreshError = null ide.fileTreeManager.refreshLinkedFile(file) - .then () -> - loadTextFileFilePreview() + .then (response) -> + { data } = response + { new_file_id } = data + $timeout( + () -> + waitFor( + () -> + ide.fileTreeManager.findEntityById(new_file_id) + 5000 + ) + .then (newFile) -> + ide.binaryFilesManager.openFile(newFile) + .catch (err) -> + console.warn(err) + , 0 + ) + $scope.refreshError = null + .catch (response) -> + $scope.refreshError = response.data .finally () -> $scope.refreshing = false @@ -86,11 +105,9 @@ define [ # show dots when payload is closs to cutoff if data.length >= (TWO_MEGABYTES - 200) $scope.textPreview.shouldShowDots = true - try # remove last partial line - data = data.replace(/\n.*$/, '') - finally - $scope.textPreview.data = data + data = data?.replace?(/\n.*$/, '') + $scope.textPreview.data = data $timeout(setHeight, 0) .catch (error) -> console.error(error) diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index e3cabf8e98..7246e09b83 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -14,7 +14,7 @@ define [ opening: true trackChanges: false wantTrackChanges: false - richText: false + showRichText: false } @$scope.$on "entity:selected", (event, entity) => diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee index d7a428ec80..8d717e09b8 100644 --- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee +++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee @@ -335,6 +335,11 @@ define [ return null + projectContainsFolder: () -> + for entity in @$scope.rootFolder.children + return true if entity.type == 'folder' + return false + existsInThisFolder: (folder, name) -> for entity in folder?.children or [] return true if entity.name is name diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index 3d4077b2dd..010e00476f 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -43,6 +43,19 @@ define [ } ) + $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () -> + unless 'project_file' in window.data.enabledLinkedFileTypes + console.warn("Project linked files are not enabled") + return + $modal.open( + templateUrl: "projectLinkedFileModalTemplate" + controller: "ProjectLinkedFileModalController" + scope: $scope + resolve: { + parent_folder: () -> ide.fileTreeManager.getCurrentFolder() + } + ) + $scope.orderByFoldersFirst = (entity) -> return '0' if entity?.type == "folder" return '1' @@ -201,6 +214,117 @@ define [ $modalInstance.dismiss('cancel') ] + App.controller "ProjectLinkedFileModalController", [ + "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", + ($scope, ide, $modalInstance, $timeout, parent_folder) -> + $scope.data = + projects: null # or [] + selectedProjectId: null + projectEntities: null # or [] + selectedProjectEntity: null + name: null + $scope.state = + inFlight: + projects: false + entities: false + create: false + error: false + + $scope.$watch 'data.selectedProjectId', (newVal, oldVal) -> + return if !newVal + $scope.data.selectedProjectEntity = null + $scope.getProjectEntities($scope.data.selectedProjectId) + + # auto-set filename based on selected file + $scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) -> + return if !newVal + fileName = newVal.split('/').reverse()[0] + if fileName + $scope.data.name = fileName + + _setInFlight = (type) -> + $scope.state.inFlight[type] = true + + _reset = (opts) -> + isError = opts.err == true + inFlight = $scope.state.inFlight + inFlight.projects = inFlight.entities = inFlight.create = false + $scope.state.error = isError + + $scope.shouldEnableProjectSelect = () -> + { state, data } = $scope + return !state.inFlight.projects && data.projects + + $scope.shouldEnableProjectEntitySelect = () -> + { state, data } = $scope + return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId + + $scope.shouldEnableCreateButton = () -> + state = $scope.state + data = $scope.data + return !state.inFlight.projects && + !state.inFlight.entities && + data.projects && + data.selectedProjectId && + data.projectEntities && + data.selectedProjectEntity && + data.name + + $scope.getUserProjects = () -> + _setInFlight('projects') + ide.$http.get("/user/projects", { + _csrf: window.csrfToken + }) + .then (resp) -> + $scope.data.projectEntities = null + $scope.data.projects = resp.data.projects.filter (p) -> + p._id != ide.project_id + _reset(err: false) + .catch (err) -> + _reset(err: true) + + $scope.getProjectEntities = (project_id) => + _setInFlight('entities') + ide.$http.get("/project/#{project_id}/entities", { + _csrf: window.csrfToken + }) + .then (resp) -> + if $scope.data.selectedProjectId == resp.data.project_id + $scope.data.projectEntities = resp.data.entities + _reset(err: false) + .catch (err) -> + _reset(err: true) + + $scope.init = () -> + $scope.getUserProjects() + $timeout($scope.init, 0) + + $scope.create = () -> + projectId = $scope.data.selectedProjectId + path = $scope.data.selectedProjectEntity + name = $scope.data.name + if !name || !path || !projectId + _reset(err: true) + return + _setInFlight('create') + ide.fileTreeManager + .createLinkedFile(name, parent_folder, 'project_file', { + source_project_id: projectId, + source_entity_path: path + }) + .then () -> + _reset(err: false) + $modalInstance.close() + .catch (response)-> + { data } = response + _reset(err: true) + + $scope.cancel = () -> + $modalInstance.dismiss('cancel') + + ] + + # TODO: rename all this to UrlLinkedFilModalController App.controller "LinkedFileModalController", [ "$scope", "ide", "$modalInstance", "$timeout", "parent_folder", ($scope, ide, $modalInstance, $timeout, parent_folder) -> diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee index 735d065cd8..0dcbac31c1 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee @@ -1,6 +1,7 @@ define [ "base" -], (App) -> + "ide/file-tree/util/iconTypeFromName" +], (App, iconTypeFromName) -> App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) -> $scope.select = (e) -> if e.ctrlKey or e.metaKey @@ -70,18 +71,7 @@ define [ $scope.$on "delete:selected", () -> $scope.openDeleteModal() if $scope.entity.selected - $scope.iconTypeFromName = (name) -> - ext = name.split(".").pop()?.toLowerCase() - if ext in ["png", "pdf", "jpg", "jpeg", "gif"] - return "image" - else if ext in ["csv", "xls", "xlsx"] - return "table" - else if ext in ["py", "r"] - return "file-text" - else if ext in ['bib'] - return 'book' - else - return "file" + $scope.iconTypeFromName = iconTypeFromName ] App.controller "DeleteEntityModalController", [ diff --git a/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee new file mode 100644 index 0000000000..01c11f395a --- /dev/null +++ b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee @@ -0,0 +1,13 @@ +define [], () -> + return iconTypeFromName = (name) -> + ext = name.split(".").pop()?.toLowerCase() + if ext in ["png", "pdf", "jpg", "jpeg", "gif"] + return "image" + else if ext in ["csv", "xls", "xlsx"] + return "table" + else if ext in ["py", "r"] + return "file-text" + else if ext in ['bib'] + return 'book' + else + return "file" \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index cdc39ffd05..8c77d97965 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -4,7 +4,6 @@ define [ "ide/history/util/displayNameForUser" "ide/history/controllers/HistoryListController" "ide/history/controllers/HistoryDiffController" - "ide/history/controllers/HistoryV2DiffController" "ide/history/directives/infiniteScroll" ], (moment, ColorManager, displayNameForUser) -> class HistoryManager diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee index 72f4c79bdd..7c8f476e39 100644 --- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee +++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee @@ -2,13 +2,20 @@ define [ "moment" "ide/colors/ColorManager" "ide/history/util/displayNameForUser" - "ide/history/controllers/HistoryListController" - "ide/history/controllers/HistoryDiffController" + "ide/history/util/HistoryViewModes" + "ide/history/controllers/HistoryV2ListController" + "ide/history/controllers/HistoryV2DiffController" + "ide/history/controllers/HistoryV2FileTreeController" "ide/history/directives/infiniteScroll" -], (moment, ColorManager, displayNameForUser) -> + "ide/history/components/historyEntriesList" + "ide/history/components/historyEntry" + "ide/history/components/historyFileTree" + "ide/history/components/historyFileEntity" +], (moment, ColorManager, displayNameForUser, HistoryViewModes) -> class HistoryManager constructor: (@ide, @$scope) -> @reset() + @$scope.HistoryViewModes = HistoryViewModes @$scope.toggleHistory = () => if @$scope.ui.view == "history" @@ -16,17 +23,31 @@ define [ else @show() - @$scope.$watch "history.selection.updates", (updates) => - if updates? and updates.length > 0 - @_selectDocFromUpdates() - @reloadDiff() + @$scope.toggleHistoryViewMode = () => + if @$scope.history.viewMode == HistoryViewModes.COMPARE + @reset() + @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME + else + @reset() + @$scope.history.viewMode = HistoryViewModes.COMPARE - @$scope.$watch "history.selection.pathname", () => - @reloadDiff() + @$scope.$watch "history.selection.updates", (updates) => + if @$scope.history.viewMode == HistoryViewModes.COMPARE + if updates? and updates.length > 0 + @_selectDocFromUpdates() + @reloadDiff() + + @$scope.$watch "history.selection.pathname", (pathname) => + if @$scope.history.viewMode == HistoryViewModes.POINT_IN_TIME + if pathname? + @loadFileAtPointInTime() + else + @reloadDiff() show: () -> @$scope.ui.view = "history" @reset() + @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME hide: () -> @$scope.ui.view = "editor" @@ -35,6 +56,7 @@ define [ @$scope.history = { isV2: true updates: [] + viewMode: null nextBeforeTimestamp: null atEnd: false selection: { @@ -46,16 +68,33 @@ define [ toV: null } } - diff: null + files: [] + diff: null # When history.viewMode == HistoryViewModes.COMPARE + selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME } restoreFile: (version, pathname) -> url = "/project/#{@$scope.project_id}/restore_file" + @ide.$http.post(url, { version, pathname, _csrf: window.csrfToken }) + loadFileTreeForUpdate: (update) -> + {fromV, toV} = update + url = "/project/#{@$scope.project_id}/filetree/diff" + query = [ "from=#{toV}", "to=#{toV}" ] + url += "?" + query.join("&") + @$scope.history.loadingFileTree = true + @$scope.history.selectedFile = null + @$scope.history.selection.pathname = null + @ide.$http + .get(url) + .then (response) => + @$scope.history.files = response.data.diff + @$scope.history.loadingFileTree = false + MAX_RECENT_UPDATES_TO_SELECT: 5 autoSelectRecentUpdates: () -> return if @$scope.history.updates.length == 0 @@ -70,12 +109,28 @@ define [ @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true + autoSelectLastUpdate: () -> + return if @$scope.history.updates.length == 0 + @selectUpdate @$scope.history.updates[0] + + selectUpdate: (update) -> + selectedUpdateIndex = @$scope.history.updates.indexOf update + if selectedUpdateIndex == -1 + selectedUpdateIndex = 0 + for update in @$scope.history.updates + update.selectedTo = false + update.selectedFrom = false + @$scope.history.updates[selectedUpdateIndex].selectedTo = true + @$scope.history.updates[selectedUpdateIndex].selectedFrom = true + @loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex] + 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 + @$scope.history.loadingFileTree = true @ide.$http .get(url) .then (response) => @@ -86,6 +141,23 @@ define [ @$scope.history.atEnd = true @$scope.history.loading = false + loadFileAtPointInTime: () -> + pathname = @$scope.history.selection.pathname + toV = @$scope.history.selection.updates[0].toV + url = "/project/#{@$scope.project_id}/diff" + query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"] + url += "?" + query.join("&") + @$scope.history.selectedFile = + loading: true + @ide.$http + .get(url) + .then (response) => + {text, binary} = @_parseDiff(response.data.diff) + @$scope.history.selectedFile.binary = binary + @$scope.history.selectedFile.text = text + @$scope.history.selectedFile.loading = false + .catch () -> + reloadDiff: () -> diff = @$scope.history.diff {updates} = @$scope.history.selection @@ -200,7 +272,11 @@ define [ @$scope.history.updates = @$scope.history.updates.concat(updates) - @autoSelectRecentUpdates() if firstLoad + if firstLoad + if @$scope.history.viewMode == HistoryViewModes.COMPARE + @autoSelectRecentUpdates() + else + @autoSelectLastUpdate() _perDocSummaryOfUpdates: (updates) -> # Track current_pathname -> original_pathname diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee new file mode 100644 index 0000000000..5022724714 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee @@ -0,0 +1,19 @@ +define [ + "base" +], (App) -> + historyEntriesListController = ($scope, $element, $attrs) -> + ctrl = @ + return + + App.component "historyEntriesList", { + bindings: + entries: "<" + loadEntries: "&" + loadDisabled: "<" + loadInitialize: "<" + isLoading: "<" + currentUser: "<" + onEntrySelect: "&" + controller: historyEntriesListController + templateUrl: "historyEntriesListTpl" + } diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee new file mode 100644 index 0000000000..e2692b7dee --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee @@ -0,0 +1,27 @@ +define [ + "base" + "ide/history/util/displayNameForUser" +], (App, displayNameForUser) -> + historyEntryController = ($scope, $element, $attrs) -> + ctrl = @ + ctrl.displayName = displayNameForUser + ctrl.getProjectOpDoc = (projectOp) -> + if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }" + else if projectOp.add? then "#{ projectOp.add.pathname}" + else if projectOp.remove? then "#{ projectOp.remove.pathname}" + ctrl.getUserCSSStyle = (user) -> + hue = user?.hue or 100 + if ctrl.entry.inSelection + color : "#FFF" + else + color: "hsl(#{ hue }, 70%, 50%)" + return + + App.component "historyEntry", { + bindings: + entry: "<" + currentUser: "<" + onSelect: "&" + controller: historyEntryController + templateUrl: "historyEntryTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyFileEntity.coffee b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee new file mode 100644 index 0000000000..bf99348c62 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee @@ -0,0 +1,34 @@ +define [ + "base" + "ide/file-tree/util/iconTypeFromName" +], (App, iconTypeFromName) -> + # TODO Add arrows in folders + historyFileEntityController = ($scope, $element, $attrs) -> + ctrl = @ + _handleFolderClick = () -> + ctrl.isOpen = !ctrl.isOpen + ctrl.iconClass = _getFolderIcon() + _handleFileClick = () -> + ctrl.historyFileTreeController.handleEntityClick ctrl.fileEntity + _getFolderIcon = () -> + if ctrl.isOpen then "fa-folder-open" else "fa-folder" + ctrl.$onInit = () -> + if ctrl.fileEntity.type == "folder" + ctrl.isOpen = true + ctrl.iconClass = _getFolderIcon() + ctrl.handleClick = _handleFolderClick + else + ctrl.iconClass = "fa-#{ iconTypeFromName(ctrl.fileEntity.name) }" + ctrl.handleClick = _handleFileClick + $scope.$watch (() -> ctrl.historyFileTreeController.selectedPathname), (newPathname) -> + ctrl.isSelected = ctrl.fileEntity.pathname == newPathname + return + + App.component "historyFileEntity", { + require: + historyFileTreeController: "^historyFileTree" + bindings: + fileEntity: "<" + controller: historyFileEntityController + templateUrl: "historyFileEntityTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/components/historyFileTree.coffee b/services/web/public/coffee/ide/history/components/historyFileTree.coffee new file mode 100644 index 0000000000..7e3c636470 --- /dev/null +++ b/services/web/public/coffee/ide/history/components/historyFileTree.coffee @@ -0,0 +1,18 @@ +define [ + "base" +], (App) -> + historyFileTreeController = ($scope, $element, $attrs) -> + ctrl = @ + ctrl.handleEntityClick = (file) -> + ctrl.onSelectedFileChange file: file + return + + App.component "historyFileTree", { + bindings: + fileTree: "<" + selectedPathname: "<" + onSelectedFileChange: "&" + isLoading: "<" + controller: historyFileTreeController + templateUrl: "historyFileTreeTpl" + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee index f16cace816..4b0786d259 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee @@ -5,7 +5,7 @@ define [ App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) -> $scope.hoveringOverListSelectors = false - + $scope.loadMore = () => ide.historyManager.fetchNextBatchOfUpdates() diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee index c2ced4cf59..279c230afb 100644 --- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) -> + App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) -> $scope.restoreState = inflight: false error: false @@ -24,17 +24,16 @@ define [ $scope.restoreState.inflight = false openEntity = (data) -> - iterations = 0 {id, type} = data - do tryOpen = () -> - if iterations > 5 - return - iterations += 1 - entity = ide.fileTreeManager.findEntityById(id) - if entity? and type == 'doc' - ide.editorManager.openDoc(entity) - else if entity? and type == 'file' - ide.binaryFilesManager.openFile(entity) - else - setTimeout(tryOpen, 500) - \ No newline at end of file + waitFor( + () -> + ide.fileTreeManager.findEntityById(id) + 3000 + ) + .then (entity) -> + if type == 'doc' + ide.editorManager.openDoc(entity) + else if type == 'file' + ide.binaryFilesManager.openFile(entity) + .catch (err) -> + console.warn(err) diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee new file mode 100644 index 0000000000..47f5c07c4c --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee @@ -0,0 +1,54 @@ +define [ + "base" +], (App) -> + + App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) -> + _previouslySelectedPathname = null + $scope.currentFileTree = [] + + _pathnameExistsInFiles = (pathname, files) -> + _.any files, (file) -> file.pathname == pathname + + _getSelectedDefaultPathname = (files) -> + selectedPathname = null + if _previouslySelectedPathname? and _pathnameExistsInFiles _previouslySelectedPathname, files + selectedPathname = _previouslySelectedPathname + else + mainFile = _.find files, (file) -> /main\.tex$/.test file.pathname + if mainFile? + selectedPathname = _previouslySelectedPathname = mainFile.pathname + else + selectedPathname = _previouslySelectedPathname = files[0].pathname + return selectedPathname + + $scope.handleFileSelection = (file) -> + $scope.history.selection.pathname = _previouslySelectedPathname = file.pathname + + $scope.$watch 'history.files', (files) -> + if files? and files.length > 0 + $scope.currentFileTree = _.reduce files, _reducePathsToTree, [] + $scope.history.selection.pathname = _getSelectedDefaultPathname(files) + + _reducePathsToTree = (currentFileTree, fileObject) -> + filePathParts = fileObject.pathname.split "/" + currentFileTreeLocation = currentFileTree + for pathPart, index in filePathParts + isFile = index == filePathParts.length - 1 + if isFile + fileTreeEntity = + name: pathPart + pathname: fileObject.pathname + type: "file" + operation: fileObject.operation || "edited" + currentFileTreeLocation.push fileTreeEntity + else + fileTreeEntity = _.find currentFileTreeLocation, (entity) => entity.name == pathPart + if !fileTreeEntity? + fileTreeEntity = + name: pathPart + type: "folder" + children: [] + currentFileTreeLocation.push fileTreeEntity + currentFileTreeLocation = fileTreeEntity.children + return currentFileTree + ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee new file mode 100644 index 0000000000..eaf7fbc884 --- /dev/null +++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee @@ -0,0 +1,76 @@ +define [ + "base", + "ide/history/util/displayNameForUser" +], (App, displayNameForUser) -> + + App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) -> + $scope.hoveringOverListSelectors = false + + $scope.loadMore = () => + ide.historyManager.fetchNextBatchOfUpdates() + + $scope.handleEntrySelect = (entry) -> + # $scope.$applyAsync () -> + ide.historyManager.selectUpdate(entry) + $scope.recalculateSelectedUpdates() + + $scope.recalculateSelectedUpdates = () -> + beforeSelection = true + afterSelection = false + $scope.history.selection.updates = [] + for update in $scope.history.updates + if update.selectedTo + inSelection = true + beforeSelection = false + + update.beforeSelection = beforeSelection + update.inSelection = inSelection + update.afterSelection = afterSelection + + if inSelection + $scope.history.selection.updates.push update + + if update.selectedFrom + inSelection = false + afterSelection = true + + $scope.recalculateHoveredUpdates = () -> + hoverSelectedFrom = false + hoverSelectedTo = false + for update in $scope.history.updates + # Figure out whether the to or from selector is hovered over + if update.hoverSelectedFrom + hoverSelectedFrom = true + if update.hoverSelectedTo + hoverSelectedTo = true + + if hoverSelectedFrom + # We want to 'hover select' everything between hoverSelectedFrom and selectedTo + inHoverSelection = false + for update in $scope.history.updates + if update.selectedTo + update.hoverSelectedTo = true + inHoverSelection = true + update.inHoverSelection = inHoverSelection + if update.hoverSelectedFrom + inHoverSelection = false + if hoverSelectedTo + # We want to 'hover select' everything between hoverSelectedTo and selectedFrom + inHoverSelection = false + for update in $scope.history.updates + if update.hoverSelectedTo + inHoverSelection = true + update.inHoverSelection = inHoverSelection + if update.selectedFrom + update.hoverSelectedFrom = true + inHoverSelection = false + + $scope.resetHoverState = () -> + for update in $scope.history.updates + delete update.hoverSelectedFrom + delete update.hoverSelectedTo + delete update.inHoverSelection + + $scope.$watch "history.updates.length", () -> + $scope.recalculateSelectedUpdates() + ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee new file mode 100644 index 0000000000..125dd87060 --- /dev/null +++ b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee @@ -0,0 +1,4 @@ +define [], () -> + HistoryViewModes = + POINT_IN_TIME : 'point_in_time' + COMPARE : 'compare' diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee new file mode 100644 index 0000000000..ae7db45905 --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee @@ -0,0 +1,16 @@ +define [ + "base" + "ace/ace" +], (App) -> + App.controller "TestControlsController", ($scope) -> + + $scope.openProjectLinkedFileModal = () -> + window.openProjectLinkedFileModal() + + $scope.openLinkedFileModal = () -> + window.openLinkedFileModal() + + $scope.richText = () -> + current = window.location.toString() + target = "#{current}#{if window.location.search then '&' else '?'}rt=true" + window.location.href = target diff --git a/services/web/public/coffee/ide/test-controls/index.coffee b/services/web/public/coffee/ide/test-controls/index.coffee new file mode 100644 index 0000000000..d60d9e1a01 --- /dev/null +++ b/services/web/public/coffee/ide/test-controls/index.coffee @@ -0,0 +1,3 @@ +define [ + "ide/test-controls/controllers/TestControlsController" +], () -> diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee index 2d1dee6d34..6d3e441c49 100644 --- a/services/web/public/coffee/main/contact-us.coffee +++ b/services/web/public/coffee/main/contact-us.coffee @@ -1,79 +1,7 @@ define [ "base" "libs/platform" - "services/algolia-search" ], (App, platform) -> - App.controller 'ContactModal', ($scope, $modal) -> - $scope.contactUsModal = () -> - modalInstance = $modal.open( - templateUrl: "supportModalTemplate" - controller: "SupportModalController" - ) - - App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) -> - $scope.form = {} - $scope.sent = false - $scope.sending = false - $scope.suggestions = []; - - _handleSearchResults = (success, results) -> - suggestions = for hit in results.hits - page_underscored = hit.pageName.replace(/\s/g,'_') - - suggestion = - url :"/learn/kb/#{page_underscored}" - name : hit._highlightResult.pageName.value - - event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length - - $scope.$applyAsync () -> - $scope.suggestions = suggestions - - $scope.contactUs = -> - if !$scope.form.email? or $scope.form.email == "" - console.log "email not set" - return - $scope.sending = true - ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) - message = $scope.form.message - if $scope.form.project_url? - message = "#{message}\n\n project_url = #{$scope.form.project_url}" - params = - email: $scope.form.email - message: message or "" - subject: $scope.form.subject + " - [#{ticketNumber}]" - labels: "support" - about: "
browser: #{platform?.name} #{platform?.version}
-
os: #{platform?.os?.family} #{platform?.os?.version}
" - - Groove.createTicket params, (response)-> - $scope.sending = false - if response.responseText == "" # Blocked request or similar - $scope.error = true - else - data = JSON.parse(response.responseText) - if data.errors? - $scope.error = true - else - $scope.sent = true - $scope.$apply() - - $scope.$watch "form.subject", (newVal, oldVal) -> - if newVal and newVal != oldVal and newVal.length > 3 - algoliaSearch.searchKB newVal, _handleSearchResults, { - hitsPerPage: 3 - typoTolerance: 'strict' - } - else - $scope.suggestions = []; - - $scope.clickSuggestionLink = (url) -> - event_tracking.sendMB "contact-form-suggestions-clicked", { url } - - $scope.close = () -> - $modalInstance.close() - - App.controller 'UniverstiesContactController', ($scope, $modal, $http) -> $scope.form = {} diff --git a/services/web/public/coffee/main/event.coffee b/services/web/public/coffee/main/event.coffee index 3ff8fc0aa4..d4e07c7fc8 100644 --- a/services/web/public/coffee/main/event.coffee +++ b/services/web/public/coffee/main/event.coffee @@ -50,6 +50,10 @@ define [ send: (category, action, label, value)-> ga('send', 'event', category, action, label, value) + sendGAOnce: (category, action, label, value) -> + if ! _eventInCache(action) + _addEventToCache(action) + @send category, action, label, value editingSessionHeartbeat: () -> return unless nextHeartbeat <= new Date() @@ -86,6 +90,9 @@ define [ if ! _eventInCache(key) _addEventToCache(key) @sendMB key, segmentation + + eventInCache: (key) -> + _eventInCache(key) } diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee index 31d8e37f40..7851171524 100644 --- a/services/web/public/coffee/main/new-subscription.coffee +++ b/services/web/public/coffee/main/new-subscription.coffee @@ -9,6 +9,7 @@ define [ $scope.currencyCode = MultiCurrencyPricing.currencyCode $scope.plans = MultiCurrencyPricing.plans + $scope.planCode = window.plan_code $scope.switchToStudent = ()-> currentPlanCode = window.plan_code @@ -234,3 +235,6 @@ define [ {code:'WK',name:'Wake Island'},{code:'WF',name:'Wallis and Futuna'},{code:'EH',name:'Western Sahara'},{code:'YE',name:'Yemen'}, {code:'ZM',name:'Zambia'},{code:'AX',name:'Åland Islandscode:'} ] + + sixpack.participate 'plans', ['default', 'more-details'], (chosenVariation, rawResponse)-> + $scope.plansVariant = chosenVariation \ No newline at end of file diff --git a/services/web/public/coffee/main/plans.coffee b/services/web/public/coffee/main/plans.coffee index 9a62420d66..7eb63607c0 100644 --- a/services/web/public/coffee/main/plans.coffee +++ b/services/web/public/coffee/main/plans.coffee @@ -3,7 +3,6 @@ define [ "libs/recurly-4.8.5" ], (App, recurly) -> - App.factory "MultiCurrencyPricing", () -> currencyCode = window.recomendedCurrency @@ -146,17 +145,16 @@ define [ } - App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) -> + App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack, $filter) -> $scope.showPlans = false - - $scope.plansVariant = 'default' $scope.shouldABTestPlans = window.shouldABTestPlans if $scope.shouldABTestPlans - $scope.showPlans = true - else - $scope.showPlans = true + sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)-> + $scope.plansVariant = chosenVariation + + $scope.showPlans = true $scope.plans = MultiCurrencyPricing.plans @@ -169,44 +167,57 @@ define [ $scope.ui = view: "monthly" - $scope.changeCurreny = (newCurrency)-> + $scope.changeCurreny = (e, newCurrency)-> + e.preventDefault() $scope.currencyCode = newCurrency # because ternary logic in angular bindings is hard $scope.getCollaboratorPlanCode = () -> view = $scope.ui.view - variant = $scope.plansVariant if view == "annual" - if variant == "default" - return "collaborator-annual" - else - return "collaborator-annual_#{variant}" + return "collaborator-annual" else - if variant == "default" - return "collaborator#{$scope.planQueryString}" - else - return "collaborator_#{variant}" + return "collaborator#{$scope.planQueryString}" - $scope.signUpNowClicked = (plan, annual)-> - event_tracking.sendMB 'plans-page-start-trial', {plan} + $scope.signUpNowClicked = (plan, location)-> if $scope.ui.view == "annual" plan = "#{plan}_annual" - event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan + plan = eventLabel(plan, location) + event_tracking.sendMB 'plans-page-start-trial', {plan} + event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan + if $scope.shouldABTestPlans + sixpack.convert 'plans-details' - $scope.switchToMonthly = -> - $scope.ui.view = "monthly" - event_tracking.send 'subscription-funnel', 'plans-page', 'monthly-prices' + $scope.switchToMonthly = (e, location) -> + uiView = 'monthly' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView - $scope.switchToStudent = -> - $scope.ui.view = "student" - event_tracking.send 'subscription-funnel', 'plans-page', 'student-prices' + $scope.switchToStudent = (e, location) -> + uiView = 'student' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView - $scope.switchToAnnual = -> - $scope.ui.view = "annual" - event_tracking.send 'subscription-funnel', 'plans-page', 'annual-prices' + $scope.switchToAnnual = (e, location) -> + uiView = 'annual' + switchEvent(e, uiView + '-prices', location) + $scope.ui.view = uiView $scope.openGroupPlanModal = () -> $modal.open { templateUrl: "groupPlanModalTemplate" } event_tracking.send 'subscription-funnel', 'plans-page', 'group-inquiry-potential' + + eventLabel = (label, location) -> + if location && $scope.plansVariant != 'default' + label = label + '-' + location + if $scope.plansVariant != 'default' + label += '-exp-' + $scope.plansVariant + label + + switchEvent = (e, label, location) -> + e.preventDefault() + gaLabel = eventLabel(label, location) + event_tracking.send 'subscription-funnel', 'plans-page', gaLabel + 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 36520d2cc7..3e7136111c 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -320,6 +320,9 @@ define [ name: cloneName id: data.project_id accessLevel: "owner" + owner: { + _id: user_id + } # TODO: Check access level if correct after adding it in # to the rest of the app } @@ -350,14 +353,15 @@ define [ $scope.archiveOrLeaveSelectedProjects() $scope.archiveOrLeaveSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.archiveOrLeaveProjects($scope.getSelectedProjects()) + $scope.archiveOrLeaveProjects = (projects) -> + projectIds = projects.map (p) -> p.id # Remove project from any tags for tag in $scope.tags - $scope._removeProjectIdsFromTagArray(tag, selected_project_ids) + $scope._removeProjectIdsFromTagArray(tag, projectIds) - for project in selected_projects + for project in projects project.tags = [] if project.accessLevel == "owner" project.archived = true @@ -414,16 +418,17 @@ define [ $scope.updateVisibleProjects() $scope.restoreSelectedProjects = () -> - selected_projects = $scope.getSelectedProjects() - selected_project_ids = $scope.getSelectedProjectIds() + $scope.restoreProjects($scope.getSelectedProjects()) - for project in selected_projects + $scope.restoreProjects = (projects) -> + projectIds = projects.map (p) -> p.id + for project in projects project.archived = false - for project_id in selected_project_ids + for projectId in projectIds queuedHttp { method: "POST" - url: "/project/#{project_id}/restore" + url: "/project/#{projectId}/restore" headers: "X-CSRF-Token": window.csrfToken } @@ -437,13 +442,14 @@ define [ ) $scope.downloadSelectedProjects = () -> - selected_project_ids = $scope.getSelectedProjectIds() - event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' - if selected_project_ids.length > 1 - path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}" - else - path = "/project/#{selected_project_ids[0]}/download/zip" + $scope.downloadProjectsById($scope.getSelectedProjectIds()) + $scope.downloadProjectsById = (projectIds) -> + event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip' + if projectIds.length > 1 + path = "/project/download/zip?project_ids=#{projectIds.join(',')}" + else + path = "/project/#{projectIds[0]}/download/zip" window.location = path $scope.openV1ImportModal = (project) -> @@ -487,6 +493,25 @@ define [ else return "None" + $scope.isOwner = () -> + window.user_id == $scope.project.owner._id + $scope.$watch "project.selected", (value) -> if value? $scope.updateSelectedProjects() + + $scope.clone = (e) -> + e.stopPropagation() + $scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)") + + $scope.download = (e) -> + e.stopPropagation() + $scope.downloadProjectsById([$scope.project.id]) + + $scope.archiveOrLeave = (e) -> + e.stopPropagation() + $scope.archiveOrLeaveProjects([$scope.project]) + + $scope.restore = (e) -> + e.stopPropagation() + $scope.restoreProjects([$scope.project]) diff --git a/services/web/public/coffee/services/algolia-search.coffee b/services/web/public/coffee/services/algolia-search.coffee index d62bc6389d..9ae5eae077 100644 --- a/services/web/public/coffee/services/algolia-search.coffee +++ b/services/web/public/coffee/services/algolia-search.coffee @@ -8,7 +8,7 @@ define [ kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb) service = - searchWiki: wikiIdx.search.bind(wikiIdx) - searchKB: kbIdx.search.bind(kbIdx) + searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null + searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null return service \ No newline at end of file diff --git a/services/web/public/coffee/services/wait-for.coffee b/services/web/public/coffee/services/wait-for.coffee new file mode 100644 index 0000000000..409142354c --- /dev/null +++ b/services/web/public/coffee/services/wait-for.coffee @@ -0,0 +1,20 @@ +define [ + "base" +], (App) -> + App.factory "waitFor", ($q) -> + waitFor = (testFunction, timeout, pollInterval=500) -> + iterationLimit = Math.floor(timeout / pollInterval) + iterations = 0 + $q( + (resolve, reject) -> + do tryIteration = () -> + if iterations > iterationLimit + return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}")) + iterations += 1 + result = testFunction() + if result? + resolve(result) + else + setTimeout(tryIteration, pollInterval) + ) + return waitFor diff --git a/services/web/public/img/advocates/erdogmus.jpg b/services/web/public/img/advocates/erdogmus.jpg new file mode 100644 index 0000000000..e70a641fd0 Binary files /dev/null and b/services/web/public/img/advocates/erdogmus.jpg differ diff --git a/services/web/public/img/advocates/henderson.jpg b/services/web/public/img/advocates/henderson.jpg new file mode 100644 index 0000000000..549570955c Binary files /dev/null and b/services/web/public/img/advocates/henderson.jpg differ diff --git a/services/web/public/stylesheets/_style_includes.less b/services/web/public/stylesheets/_style_includes.less index 52790de9a8..bbe3463cba 100644 --- a/services/web/public/stylesheets/_style_includes.less +++ b/services/web/public/stylesheets/_style_includes.less @@ -80,6 +80,7 @@ @import "app/review-features-page.less"; @import "app/error-pages.less"; @import "app/v1-badge.less"; +@import "app/editor/history-v2.less"; @import "app/metrics.less"; // Vendor CSS diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index fc52bfb156..af3063cf22 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -74,9 +74,13 @@ #ide-body { .full-size; - top: 40px; + top: @ide-body-top-offset; + &.ide-history-open { + top: @ide-body-top-offset + @editor-toolbar-height; + } } + #editor, #editor-rich-text { .full-size; } @@ -88,6 +92,7 @@ .toolbar-editor { height: @editor-toolbar-height; background-color: @editor-toolbar-bg; + overflow: hidden; } .loading-screen { diff --git a/services/web/public/stylesheets/app/editor/history-v2.less b/services/web/public/stylesheets/app/editor/history-v2.less new file mode 100644 index 0000000000..9089ed1ae9 --- /dev/null +++ b/services/web/public/stylesheets/app/editor/history-v2.less @@ -0,0 +1,498 @@ +.history-toolbar { + display: flex; + align-items: center; + position: absolute; + width: 100%; + top: @ide-body-top-offset; + height: @editor-toolbar-height; + line-height: 1; + font-size: @font-size-small; + background-color: @history-toolbar-bg-color; + z-index: 1; + color: @history-toolbar-color; + padding-left: (@line-height-computed / 2); +} +.history-toolbar when (@is-overleaf = false) { + border-bottom: @toolbar-border-bottom; +} + .history-toolbar-time { + font-weight: bold; + } + .history-toolbar-btn { + .btn; + .btn-info; + .btn-xs; + padding-left: @padding-small-horizontal; + padding-right: @padding-small-horizontal; + margin-left: (@line-height-computed / 2); + } + +.history-entries { + font-size: @history-base-font-size; + color: @history-base-color; + height: 100%; + background-color: @history-base-bg; +} + +.history-entry-day { + display: block; + background-color: @history-entry-day-bg; + color: #FFF; + padding: 5px 10px; + line-height: 1; +} + +.history-entry-details { + background-color: #FFF; + margin-bottom: 2px; + padding: 5px 10px; + cursor: pointer; + + .history-entry-selected & { + background-color: @history-entry-selected-bg; + color: #FFF; + } +} + .history-entry-changes { + .list-unstyled; + margin-bottom: 3px; + } + .history-entry-change { + + } + .history-entry-change-action { + margin-right: 0.5em; + } + + .history-entry-change-doc { + color: @history-highlight-color; + font-weight: bold; + word-break: break-all; + .history-entry-selected & { + color: #FFF; + } + } + .history-entry-metadata { + + } + .history-entry-metadata-time { + white-space: nowrap; + } + + .history-entry-metadata-users { + display: inline; + padding: 0; + } + .history-entry-metadata-user { + display: inline; + &::after { + content: ', '; + } + &:last-of-type::after { + content: none; + } + } + +.history-file-tree-inner { + .full-size; + overflow-y: auto; + background-color: @file-tree-bg; + + .loading { + color: #FFF; + font-size: @history-base-font-size; + text-align: center; + font-family: @font-family-serif; + } +} + +.history-file-tree-inner when (@is-overleaf = false) { + font-size: 0.8rem; +} + + .history-file-entity-wrapper { + color: #FFF; + margin-left: (@line-height-computed / 2); + } + .history-file-entity-link { + display: block; + position: relative; + color: @file-tree-item-color; + line-height: @file-tree-line-height; + &:hover { + background-color: @file-tree-item-hover-bg; + color: @file-tree-item-color; + text-decoration: none; + } + &:focus { + color: @file-tree-item-color; + outline: none; + text-decoration: none; + } + &:hover when (@is-overleaf = true) { + .fake-full-width-bg(@file-tree-item-hover-bg); + } + } + .history-file-entity-link-selected { + background-color: @file-tree-item-selected-bg; + font-weight: bold; + padding-right: 32px; + color: #FFF; + .fake-full-width-bg(@file-tree-item-selected-bg); + &:hover { + background-color: @file-tree-item-hover-bg; + } + } + .history-file-entity-icon { + color: @file-tree-item-icon-color; + font-size: 14px; + margin-right: .5em; + .history-file-entity-link-selected & { + color: #FFF; + } + } + .history-file-entity-name { + display: block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .history-file-entity-link-selected when (@is-overleaf = false) { + color: @brand-primary; + &:hover, + &:focus { + color: @brand-primary; + } + .history-file-entity-icon { + color: @brand-primary; + } + } +// @changesListWidth: 250px; +// @changesListPadding: @line-height-computed / 2; + +// @selector-padding-vertical: 10px; +// @selector-padding-horizontal: @line-height-computed / 2; +// @day-header-height: 24px; + +// @range-bar-color: @link-color; +// @range-bar-selected-offset: 14px; + +// #history { +// .upgrade-prompt { +// position: absolute; +// top: 0; +// bottom: 0; +// left: 0; +// right: 0; +// z-index: 100; +// background-color: rgba(128,128,128,0.4); +// .message { +// margin: auto; +// margin-top: 100px; +// padding: (@line-height-computed / 2) @line-height-computed; +// width: 400px; +// background-color: white; +// border-radius: 8px; +// } +// .message-wider { +// width: 650px; +// margin-top: 60px; +// padding: 0; +// } + +// .message-header { +// .modal-header; +// } + +// .message-body { +// .modal-body; +// } +// } + +// .diff-panel { +// .full-size; +// margin-right: @changesListWidth; +// } + +// .diff { +// .full-size; +// .toolbar { +// padding: 3px; +// .name { +// float: left; +// padding: 3px @line-height-computed / 4; +// display: inline-block; +// } +// } +// .diff-editor { +// .full-size; +// top: 40px; +// } +// .hide-ace-cursor { +// .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { +// display: none; +// } +// } +// .diff-deleted { +// padding: @line-height-computed; +// } +// .deleted-warning { +// background-color: @brand-danger; +// color: white; +// padding: @line-height-computed / 2; +// margin-right: @line-height-computed / 4; +// } +// &-binary { +// .alert { +// margin: @line-height-computed / 2; +// } +// } +// } + +// aside.change-list { +// border-left: 1px solid @editor-border-color; +// height: 100%; +// width: @changesListWidth; +// position: absolute; +// right: 0; + +// .loading { +// text-align: center; +// font-family: @font-family-serif; +// } + +// ul { +// li.change { +// position: relative; +// user-select: none; +// -ms-user-select: none; +// -moz-user-select: none; +// -webkit-user-select: none; + +// .day { +// background-color: #fafafa; +// border-bottom: 1px solid @editor-border-color; +// padding: 4px; +// font-weight: bold; +// text-align: center; +// height: @day-header-height; +// font-size: 14px; +// line-height: 1; +// } +// .selectors { +// input { +// margin: 0; +// } +// position: absolute; +// left: @selector-padding-horizontal; +// top: 0; +// bottom: 0; +// width: 24px; +// .selector-from { +// position: absolute; +// bottom: @selector-padding-vertical; +// left: 0; +// opacity: 0.8; +// } +// .selector-to { +// position: absolute; +// top: @selector-padding-vertical; +// left: 0; +// opacity: 0.8; +// } +// .range { +// position: absolute; +// left: 5px; +// width: 4px; +// top: 0; +// bottom: 0; +// } +// } +// .description { +// padding: (@line-height-computed / 4); +// padding-left: 38px; +// min-height: 38px; +// border-bottom: 1px solid @editor-border-color; +// cursor: pointer; +// &:hover { +// background-color: @gray-lightest; +// } +// } +// .users { +// .user { +// font-size: 0.8rem; +// color: @gray; +// text-transform: capitalize; +// position: relative; +// padding-left: 16px; +// .color-square { +// height: 12px; +// width: 12px; +// border-radius: 3px; +// position: absolute; +// left: 0; +// bottom: 3px; +// } +// .name { +// width: 94%; +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// } +// } +// } +// .time { +// float: right; +// color: @gray; +// display: inline-block; +// padding-right: (@line-height-computed / 2); +// font-size: 0.8rem; +// line-height: @line-height-computed; +// } +// .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 { +// padding: 6px; +// cursor: default; +// &:hover { +// background-color: inherit; +// } +// } +// li.selected { +// border-left: 4px solid @range-bar-color; +// .day { +// padding-left: 0; +// } +// .description { +// padding-left: 34px; +// } +// .selectors { +// left: @selector-padding-horizontal - 4px; +// .range { +// background-color: @range-bar-color; +// } +// } +// } +// li.selected-to { +// .selectors { +// .range { +// top: @range-bar-selected-offset; +// } +// .selector-to { +// opacity: 1; +// } +// } +// } +// li.selected-from { +// .selectors { +// .range { +// bottom: @range-bar-selected-offset; +// } +// .selector-from { +// opacity: 1; +// } +// } +// } +// li.first-in-day { +// .selectors { +// .selector-to { +// top: @day-header-height + @selector-padding-vertical; +// } +// } +// } +// li.first-in-day.selected-to { +// .selectors { +// .range { +// top: @day-header-height + @range-bar-selected-offset; +// } +// } +// } +// } +// ul.hover-state { +// li { +// .selectors { +// .range { +// background-color: transparent; +// top: 0; +// bottom: 0; +// } +// } +// } +// li.hover-selected { +// .selectors { +// .range { +// top: 0; +// background-color: @gray-light; +// } +// } +// } +// li.hover-selected-to { +// .selectors { +// .range { +// top: @range-bar-selected-offset; +// } +// .selector-to { +// opacity: 1; +// } +// } +// } +// li.hover-selected-from { +// .selectors { +// .range { +// bottom: @range-bar-selected-offset; +// } +// .selector-from { +// opacity: 1; +// } +// } +// } +// li.first-in-day.hover-selected-to { +// .selectors { +// .range { +// top: @day-header-height + @range-bar-selected-offset; +// } +// } +// } +// } +// } +// } + +// .diff-deleted { +// padding-top: 15px; +// } + +// .editor-dark { +// #history { +// aside.change-list { +// border-color: @editor-dark-toolbar-border-color; + +// ul li.change { +// .day { +// background-color: darken(@editor-dark-background-color, 10%); +// border-bottom: 1px solid @editor-dark-toolbar-border-color; +// } +// .description { +// border-bottom: 1px solid @editor-dark-toolbar-border-color; +// &:hover { +// background-color: black; +// } +// } +// } +// } +// } +// } diff --git a/services/web/public/stylesheets/app/editor/history.less b/services/web/public/stylesheets/app/editor/history.less index 68616f6100..3e558b6c5f 100644 --- a/services/web/public/stylesheets/app/editor/history.less +++ b/services/web/public/stylesheets/app/editor/history.less @@ -1,4 +1,4 @@ -@changesListWidth: 250px; +@changesListWidth: 250px; @changesListPadding: @line-height-computed / 2; @selector-padding-vertical: 10px; @@ -40,7 +40,8 @@ } } - .diff-panel { + .diff-panel, + .point-in-time-panel { .full-size; margin-right: @changesListWidth; } @@ -49,6 +50,7 @@ .full-size; .toolbar { padding: 3px; + height: 32px; .name { float: left; padding: 3px @line-height-computed / 4; @@ -57,13 +59,9 @@ } .diff-editor { .full-size; - top: 40px; - } - .hide-ace-cursor { - .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { - display: none; - } + top: 32px; } + .diff-deleted { padding: @line-height-computed; } @@ -90,6 +88,7 @@ .loading { text-align: center; font-family: @font-family-serif; + margin-top: (@line-height-computed / 2); } ul { @@ -305,6 +304,12 @@ padding-top: 15px; } +.hide-ace-cursor { + .ace_active-line, .ace_cursor-layer, .ace_gutter-active-line { + display: none; + } +} + .editor-dark { #history { aside.change-list { diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index a73f46abc3..4006b56a7b 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/services/web/public/stylesheets/app/editor/toolbar.less @@ -184,8 +184,12 @@ } } +/************************************** + Toggle Switch +***************************************/ + .toggle-wrapper { - width: 200px; + min-width: 200px; height: 24px; } @@ -241,3 +245,88 @@ transform: translate(100%); border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0; } + +/************************************** + Formatting buttons +***************************************/ +.formatting-buttons { + width: 100%; + overflow: hidden; +} + +.formatting-buttons-wrapper { + display: flex; +} + +.formatting-btn { + color: @formatting-btn-color; + background-color: @formatting-btn-bg; + padding: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + border: none; + border-left: 1px solid @formatting-btn-border; + border-radius: 0; + + &:hover { + color: @formatting-btn-color; + } +} + +.formatting-btn--icon { + min-width: 32px; + width: 32px; +} + +.formatting-btn--icon:last-of-type { + border-right: 1px solid @formatting-btn-border; +} + +.formatting-btn--more { + padding-left: 9px; + padding-right: 9px; + + .caret { + margin-top: 1px; + } +} + +.formatting-icon { + font-style: normal; + line-height: 1.5; +} + +.formatting-icon--small { + font-size: small; + line-height: 1.9; +} + +.formatting-icon--serif { + font-family: @font-family-serif; +} + +.formatting-more { + margin-left: auto; +} + +.formatting-menu { + min-width: auto; + max-width: 130px; + background-color: @formatting-menu-bg; +} + +.formatting-menu-item { + float: left; +} + +.formatting-menu-item > .formatting-btn { + border-right: none; +} + +// Disable border on left-most icon in menu +.formatting-menu-item:nth-of-type(4n + 1) > .formatting-btn { + border-left: none; +} diff --git a/services/web/public/stylesheets/app/plans.less b/services/web/public/stylesheets/app/plans.less index b830db9b23..41e2fc80f1 100644 --- a/services/web/public/stylesheets/app/plans.less +++ b/services/web/public/stylesheets/app/plans.less @@ -32,7 +32,7 @@ padding-bottom: @line-height-computed * 2; } } - + .circle { font-size: 1.5rem; font-weight: 700; @@ -63,6 +63,12 @@ } .card .btn { white-space:normal; } + + .top-switch { + .currency-dropdown { + margin-right: -15px; + } + } } #changePlanSection { @@ -127,4 +133,261 @@ input.paymentTypeOption.ng-valid { text-align: right; } +/** + Plans Test +*/ +@best-val-height: 35px; +@highlight-border: 3px; +@highlight-color: #d3584b; +@gray-med: #6d6d6d; +@white-med: #fdfdfd; +.more-details { + .best-value { + color: @red; + line-height: @line-height-computed; + } + blockquote { + footer{ + /* accessibility fix */ + color: @gray-med; + } + } + .btn-header { + font-family: @font-family-sans-serif; + margin-left: 10px; + margin-top: -10px; + text-shadow: 0 0 0; + } + .card-first, .card-last { + background: @white-med; + } + .card-highlighted { + border: @highlight-border solid @gray-lighter; + padding-top: 10px!important; + .best-value { + margin-bottom: 15px; + } + .card-header { + padding-bottom: 22px; /* align hr with other plans */ + } + } + .card-header { + margin-bottom: 15px; + } + .circle { + /* accessibility fix */ + span.small { + color: rgba(255, 255, 255, 0.85) + } + } + .circle-img { + border-radius: 50%; + float: right; + height: 100px; + overflow: hidden; + position: relative; + width: 100px; + img { + display: inline; + margin: 0 auto; + width: 100%; + } + } + .faq:last-child { + p { + margin-bottom: 0; + } + } + .questions-header { + color: @red; + line-height: 37px; + margin: 0; + text-align: right; + } + .tagline { + margin-bottom: 20px; + } + /* Media Queries */ + @media (max-width: @screen-md-min) { + .card-highlighted { + /*override style in cards.less */ + margin-top: @line-height-computed!important; + } + .circle-img { + float: left; + margin: 0 15px; + } + } + @media (min-width: @screen-md-min) { + blockquote { + margin-bottom: 0; + } + .faq { + .row:nth-child(2) { + h3 { + margin-top: 0; + } + } + } + } +} +.student-disclaimer { + font-size: 14px; /* match .paymentPageFeatures p */ + color: @gray; /* match .paymentPageFeatures p */ + margin: 12.5px 0 0 0; +} + +/** + Plans Table +*/ +.plans-table { + border: 1px solid @gray-lighter; + background-color: @white-med; + margin: @best-val-height 0 15px 0; + table-layout: fixed; + width: 100%; + + th, td { + -moz-background-clip: padding; + -webkit-background-clip: padding; + background-clip: padding-box; /* needed for firefox when there is bg color */ + border: 1px solid @gray-lighter; + padding: 6px; + text-align: center; + vertical-align: middle; + } + + td { + font-weight: bold; + } + + th { + border-top: 0; + font-family: @headings-font-family; + font-size: @font-size-h2; + font-weight: @headings-font-weight; + line-height: @headings-line-height; + padding: 18px; + } + + th:first-child, td:first-child { + border-left: 0; + } + + th:last-child, td:last-child { + border-right: 0; + } + + td:first-child { + font-weight: bold; + padding-left: 18px; + text-align: left; + } + + tr:first-child { + th { + position: relative; + /* keep here position here, otherwise messes up border on safari */ + } + } + + tr:last-child { + td { + border-bottom: 0; + padding: 18px; + } + /* highlighted column */ + td:nth-child(3) { + position: relative; + /* keep here position here, otherwise messes up border on safari when there is a bg color */ + &:before { + /* needed for safafi */ + border-top: 1px solid @gray-lighter; + content: ''; + left: 0; + position: absolute; + top: -1px; + width: 100%; + } + } + td:first-child { + border: 0; + } + } + + .fa-check { + color: @green; + } + + /* accessibility fixes */ + .small { + color: @gray-med; + } + + /* highlighted column */ + td:nth-child(3), th:nth-child(3) { + background-color: white; + border-left: @highlight-border solid @gray-lighter; + border-right: @highlight-border solid @gray-lighter; + } + .outer { + left: -@highlight-border; + right: -@highlight-border; + position: absolute; + + .outer-content { + background: white; + border: @highlight-border solid @gray-lighter; + border-radius: @border-radius-base; + font-size: @font-size-base; + font-family: @font-family-sans-serif; + font-weight: bold; + height: @best-val-height; + padding-top: 10px; + } + } + .outer.outer-top { + top: -@best-val-height; + .outer-content { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; + } + } + .outer.outer-btm { + bottom: -@best-val-height/2; + .outer-content { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 0; + height: @best-val-height/2; + } + } + + /* highlight rows on hover */ + tr:hover { + td { + background-color: @gray-lightest; + } + } + tr:first-child:hover { + background-color: transparent; + } + tr:last-child:hover { + background-color: transparent; + td { + background-color: transparent; + } + } + + /* tooltip */ + sup { + color: @red; + cursor: pointer; + margin-left: 5px; + } + .tooltip.in { + min-width: 200px + } +} diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 982086a262..569035ad72 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -369,6 +369,16 @@ ul.project-list { .v1-badge { margin-left: -4px; } + + .action-btn-row-header, .action-btn-row { + padding-right: 20px; + text-align: right; + } + + .action-btn { + padding: 0 0.3em; + margin-left: 0.2em; + } } i.tablesort { padding-left: 8px; diff --git a/services/web/public/stylesheets/components/forms.less b/services/web/public/stylesheets/components/forms.less index 8eb92411cd..00653b105b 100755 --- a/services/web/public/stylesheets/components/forms.less +++ b/services/web/public/stylesheets/components/forms.less @@ -145,10 +145,15 @@ output { opacity: 1; // iOS fix for unreadable disabled content } - // Reset height for `textarea`s + // Reset height for `textarea`s, and smaller border-radius textarea& { height: auto; + border-radius: @border-radius-base; } + // Smaller border-radius for `select` inputs + select& { + border-radius: @border-radius-base; + } } diff --git a/services/web/public/stylesheets/core/_common-variables.less b/services/web/public/stylesheets/core/_common-variables.less index 9cb074c754..5d08d9f95d 100644 --- a/services/web/public/stylesheets/core/_common-variables.less +++ b/services/web/public/stylesheets/core/_common-variables.less @@ -888,6 +888,7 @@ @footer-padding : 2em; // Editor header +@ide-body-top-offset : 40px; @toolbar-header-bg-color : transparent; @toolbar-header-shadow : 0 0 2px #ccc; @toolbar-btn-color : @link-color; @@ -940,6 +941,12 @@ @toggle-switch-bg : @gray-lightest; @toggle-switch-highlight-color : @brand-primary; +// Formatting buttons +@formatting-btn-color : @btn-default-color; +@formatting-btn-bg : @btn-default-bg; +@formatting-btn-border : @btn-default-border; +@formatting-menu-bg : @btn-default-bg; + // Chat @chat-bg : transparent; @chat-message-color : @text-color; @@ -972,4 +979,15 @@ // System messages @sys-msg-background : @state-warning-bg; @sys-msg-color : #333; -@sys-msg-border : 1px solid @common-border-color; \ No newline at end of file +@sys-msg-border : 1px solid @common-border-color; + +// v2 History +@history-base-font-size : @font-size-small; +@history-base-bg : @gray-lightest; +@history-entry-day-bg : @gray; +@history-entry-selected-bg : @red; +@history-base-color : @gray-light; +@history-highlight-color : @gray; +@history-toolbar-bg-color : @toolbar-alt-bg-color; +@history-toolbar-color : @text-color; + diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index e9125e0143..114d07a5b1 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -244,6 +244,12 @@ @toggle-switch-radius-left : @btn-border-radius-base 0 0 @btn-border-radius-base; @toggle-switch-radius-right : 0 @btn-border-radius-base @btn-border-radius-base 0; +// Formatting buttons +@formatting-btn-color : #FFF; +@formatting-btn-bg : @ol-blue-gray-5; +@formatting-btn-border : @ol-blue-gray-4; +@formatting-menu-bg : @ol-blue-gray-5; + // Chat @chat-bg : @ol-blue-gray-5; @chat-message-color : #FFF; @@ -265,6 +271,17 @@ @log-line-no-color : #FFF; @log-hints-color : @ol-blue-gray-4; + +// v2 History +@history-base-font-size : @font-size-small; +@history-base-bg : @ol-blue-gray-1; +@history-entry-day-bg : @ol-blue-gray-2; +@history-entry-selected-bg : @ol-green; +@history-base-color : @ol-blue-gray-2; +@history-highlight-color : @ol-type-color; +@history-toolbar-bg-color : @editor-toolbar-bg; +@history-toolbar-color : #FFF; + // System messages @sys-msg-background : @ol-blue; @sys-msg-color : #FFF; diff --git a/services/web/public/stylesheets/core/scaffolding.less b/services/web/public/stylesheets/core/scaffolding.less index df52ab1fb4..abadcabae9 100755 --- a/services/web/public/stylesheets/core/scaffolding.less +++ b/services/web/public/stylesheets/core/scaffolding.less @@ -161,4 +161,7 @@ hr { margin-top: @line-height-computed / 2; } +.row-spaced-large { + margin-top: @line-height-computed * 2; +} diff --git a/services/web/public/stylesheets/core/type.less b/services/web/public/stylesheets/core/type.less index ac1dcf3765..c8f2dcb9f1 100755 --- a/services/web/public/stylesheets/core/type.less +++ b/services/web/public/stylesheets/core/type.less @@ -122,6 +122,11 @@ cite { font-style: normal; } text-align: center; } +// Transformations +.text-capitalize { + text-transform: capitalize; +} + // Contextual backgrounds // For now we'll leave these alongside the text classes until v4 when we can // safely shift things around (per SemVer rules). @@ -256,7 +261,14 @@ blockquote { vertical-align: -0.4em; line-height: 0.1em; } - + + &:after { + content: close-quote; + display: inherit; + height: 0; + visibility: hidden; + } + p { display: inline; } diff --git a/services/web/scripts/add_multiple_emails.js b/services/web/scripts/add_multiple_emails.js new file mode 100644 index 0000000000..8da2d57dac --- /dev/null +++ b/services/web/scripts/add_multiple_emails.js @@ -0,0 +1,61 @@ +const mongojs = require('../app/js/infrastructure/mongojs') +const { db } = mongojs +const async = require('async') +const minilist = require('minimist') + +const updateUser = function (user, callback) { + console.log(`Updating user ${user._id}`) + const update = { + $set: { + emails: [{ + email: user.email, + createdAt: new Date() + }] + } + } + db.users.update({_id: user._id}, update, callback) +} + +const updateUsers = (users, callback) => + async.eachLimit(users, ASYNC_LIMIT, updateUser, function (error) { + if (error) { + callback(error) + return + } + counter += users.length + console.log(`${counter} users updated`) + loopForUsers(callback) + }) + +var loopForUsers = callback => + db.users.find( + { emails: {$exists: false} }, + { email: 1 } + ).limit(FETCH_LIMIT, function (error, users) { + if (error) { + callback(error) + return + } + if (users.length === 0) { + console.log(`DONE (${counter} users updated)`) + return callback() + } + updateUsers(users, callback) + }) + +var counter = 0 +var run = () => + loopForUsers(function (error) { + if (error) { throw error } + process.exit() + }) + +let FETCH_LIMIT, ASYNC_LIMIT +var setup = function () { + let args = minilist(process.argv.slice(2)) + FETCH_LIMIT = (args.fetch) ? args.fetch : 100 + ASYNC_LIMIT = (args.async) ? args.async : 10 +} + +setup() +run() diff --git a/services/web/test/acceptance/coffee/SubscriptionTests.coffee b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee similarity index 85% rename from services/web/test/acceptance/coffee/SubscriptionTests.coffee rename to services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee index ce762ea0ee..5bc3026fff 100644 --- a/services/web/test/acceptance/coffee/SubscriptionTests.coffee +++ b/services/web/test/acceptance/coffee/FeatureUpdaterTests.coffee @@ -6,27 +6,22 @@ settings = require "settings-sharelatex" {ObjectId} = require("../../../app/js/infrastructure/mongojs") Subscription = require("../../../app/js/models/Subscription").Subscription User = require("../../../app/js/models/User").User +FeaturesUpdater = require("../../../app/js/Features/Subscription/FeaturesUpdater") MockV1Api = require "./helpers/MockV1Api" +logger = require "logger-sharelatex" +logger.logger.level("error") syncUserAndGetFeatures = (user, callback = (error, features) ->) -> - request { - method: 'POST', - url: "/user/#{user._id}/features/sync", - auth: - user: 'sharelatex' - pass: 'password' - sendImmediately: true - }, (error, response, body) -> - throw error if error? - expect(response.statusCode).to.equal 200 + FeaturesUpdater.refreshFeatures user._id, false, (error) -> + return callback(error) if error? User.findById user._id, (error, user) -> return callback(error) if error? features = user.toObject().features delete features.$init # mongoose internals return callback null, features -describe "Subscriptions", -> +describe "FeatureUpdater.refreshFeatures", -> beforeEach (done) -> @user = new UserClient() @user.ensureUserExists (error) -> @@ -148,4 +143,22 @@ describe "Subscriptions", -> throw error if error? plan = settings.plans.find (plan) -> plan.planCode == 'professional' expect(features).to.deep.equal(plan.features) - done() \ No newline at end of file + done() + + describe "when the notifyV1Flag is passed", -> + beforeEach -> + User.update { + _id: @user._id + }, { + overleaf: + id: 42 + } # returns a promise + + it "should ping the v1 API end point to sync", (done) -> + FeaturesUpdater.refreshFeatures @user._id, true, (error) => + setTimeout () => + expect( + MockV1Api.syncUserFeatures.calledWith('42') + ).to.equal true + done() + , 500 diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee index 9ca7ecae42..f3e8694e2a 100644 --- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee +++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee @@ -27,6 +27,122 @@ describe "LinkedFiles", -> @owner.login -> mkdirp Settings.path.dumpFolder, done + describe "creating a project linked file", -> + before (done) -> + @source_doc_name = 'test.txt' + async.series [ + (cb) => + @owner.createProject 'plf-test-one', {template: 'blank'}, (error, project_id) => + @project_one_id = project_id + cb(error) + (cb) => + @owner.getProject @project_one_id, (error, project) => + @project_one = project + @project_one_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createProject 'plf-test-two', {template: 'blank'}, (error, project_id) => + @project_two_id = project_id + cb(error) + (cb) => + @owner.getProject @project_two_id, (error, project) => + @project_two = project + @project_two_root_folder_id = project.rootFolder[0]._id.toString() + cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + @source_doc_name, + (error, doc_id) => + @source_doc_id = doc_id + cb(error) + (cb) => + @owner.createDocInProject @project_two_id, + @project_two_root_folder_id, + 'some-harmless-doc.txt', + (error, doc_id) => + cb(error) + ], done + + it 'should produce a list of the users projects', (done) -> + @owner.request.get { + url: "/user/projects", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + projects: [ + { _id: @project_one_id, name: 'plf-test-one', accessLevel: 'owner' }, + { _id: @project_two_id, name: 'plf-test-two', accessLevel: 'owner' } + ] + } + done() + + it 'should produce a list of entities in the project', (done) -> + @owner.request.get { + url: "/project/#{@project_two_id}/entities", + json: true + }, (err, response, body) => + expect(err).to.not.exist + expect(body).to.deep.equal { + project_id: @project_two_id, + entities: [ + { path: '/main.tex', type: 'doc' }, + { path: '/some-harmless-doc.txt', type: 'doc' }, + { path: '/test.txt', type: 'doc' } + ] + } + done() + + + it 'should import a file from the source project', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + }, (error, response, body) => + new_file_id = body.new_file_id + @existing_file_id = new_file_id + expect(new_file_id).to.exist + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.linkedFileData).to.deep.equal { + provider: 'project_file', + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + source_project_display_name: "plf-test-two" + } + expect(firstFile.name).to.equal('test-link.txt') + done() + + it 'should refresh the file', (done) -> + @owner.request.post { + url: "/project/#{@project_one_id}/linked_file", + json: + name: 'test-link.txt', + parent_folder_id: @project_one_root_folder_id, + provider: 'project_file', + data: + source_project_id: @project_two_id, + source_entity_path: "/#{@source_doc_name}", + }, (error, response, body) => + new_file_id = body.new_file_id + expect(new_file_id).to.exist + expect(new_file_id).to.not.equal @existing_file_id + @owner.getProject @project_one_id, (error, project) => + return done(error) if error? + firstFile = project.rootFolder[0].fileRefs[0] + expect(firstFile._id.toString()).to.equal(new_file_id.toString()) + expect(firstFile.name).to.equal('test-link.txt') + done() + describe "creating a URL based linked file", -> before (done) -> @owner.createProject "url-linked-files-project", {template: "blank"}, (error, project_id) => @@ -50,7 +166,7 @@ describe "LinkedFiles", -> name: 'url-test-file-1' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[0] @@ -76,7 +192,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.request.post { url: "/project/#{@project_id}/linked_file", json: @@ -88,7 +204,7 @@ describe "LinkedFiles", -> name: 'url-test-file-2' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = project.rootFolder[0].fileRefs[1] @@ -168,7 +284,7 @@ describe "LinkedFiles", -> name: 'url-test-file-6' }, (error, response, body) => throw error if error? - expect(response.statusCode).to.equal 204 + expect(response.statusCode).to.equal 200 @owner.getProject @project_id, (error, project) => throw error if error? file = _.find project.rootFolder[0].fileRefs, (file) -> diff --git a/services/web/test/acceptance/coffee/RegistrationTests.coffee b/services/web/test/acceptance/coffee/RegistrationTests.coffee index 313722a71b..89d1bf3299 100644 --- a/services/web/test/acceptance/coffee/RegistrationTests.coffee +++ b/services/web/test/acceptance/coffee/RegistrationTests.coffee @@ -146,6 +146,15 @@ describe "LoginViaRegistration", -> describe "[Security] Trying to register/login as another user", -> + it 'should not allow sign in with secondary email', (done) -> + secondaryEmail = "acceptance-test-secondary@example.com" + @user1.addEmail secondaryEmail, (err) => + @user1.loginWith secondaryEmail, (err) => + expect(err?).to.equal false + @user1.isLoggedIn (err, isLoggedIn) -> + expect(isLoggedIn).to.equal false + done() + it 'should have user1 login', (done) -> @user1.login (err) -> expect(err?).to.equal false diff --git a/services/web/test/acceptance/coffee/SettingsTests.coffee b/services/web/test/acceptance/coffee/SettingsTests.coffee new file mode 100644 index 0000000000..bd2942e072 --- /dev/null +++ b/services/web/test/acceptance/coffee/SettingsTests.coffee @@ -0,0 +1,28 @@ +should = require('chai').should() +async = require("async") +User = require "./helpers/User" + +describe 'SettingsPage', -> + + before (done) -> + @user = new User() + async.series [ + @user.ensureUserExists.bind(@user) + @user.login.bind(@user) + @user.activateSudoMode.bind(@user) + ], done + + it 'load settigns page', (done) -> + @user.getUserSettingsPage (err, statusCode) -> + statusCode.should.equal 200 + done() + + it 'update main email address', (done) -> + newEmail = 'foo@bar.com' + @user.updateSettings email: newEmail, (error) => + should.not.exist error + @user.get (error, user) -> + user.email.should.equal newEmail + user.emails.length.should.equal 1 + user.emails[0].email.should.equal newEmail + done() diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index 5c2cf47ad9..389ecd1762 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -1,6 +1,7 @@ express = require("express") app = express() bodyParser = require('body-parser') +sinon = require 'sinon' app.use(bodyParser.json()) @@ -23,19 +24,25 @@ module.exports = MockV1Api = clearExportParams: () -> @exportParams = null + syncUserFeatures: sinon.stub() + run: () -> - app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) => - user = @users[req.params.ol_user_id] + app.get "/api/v1/sharelatex/users/:v1_user_id/plan_code", (req, res, next) => + user = @users[req.params.v1_user_id] if user res.json user else res.sendStatus 404 + app.post "/api/v1/sharelatex/users/:v1_user_id/sync", (req, res, next) => + @syncUserFeatures(req.params.v1_user_id) + res.sendStatus 200 + app.post "/api/v1/sharelatex/exports", (req, res, next) => - #{project, version, pathname} @exportParams = Object.assign({}, req.body) res.json exportId: @exportId + app.listen 5000, (error) -> throw error if error? .on "error", (error) -> diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index a02ab5c42c..f83780b535 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -3,13 +3,18 @@ _ = require("underscore") settings = require("settings-sharelatex") {db, ObjectId} = require("../../../../app/js/infrastructure/mongojs") UserModel = require("../../../../app/js/models/User").User +UserUpdater = require("../../../../app/js/Features/User/UserUpdater") AuthenticationManager = require("../../../../app/js/Features/Authentication/AuthenticationManager") count = 0 class User constructor: (options = {}) -> - @email = "acceptance-test-#{count}@example.com" + @emails = [ + email: "acceptance-test-#{count}@example.com" + createdAt: new Date() + ] + @email = @emails[0].email @password = "acceptance-test-#{count}-password" count++ @jar = request.jar() @@ -17,14 +22,20 @@ class User jar: @jar }) + get: (callback = (error, user)->) -> + db.users.findOne { _id: ObjectId(@_id) }, callback + login: (callback = (error) ->) -> + @loginWith(@email, callback) + + loginWith: (email, callback = (error) ->) -> @ensureUserExists (error) => return callback(error) if error? @getCsrfToken (error) => return callback(error) if error? @request.post { url: "/login" - json: { @email, @password } + json: { email, @password } }, callback ensureUserExists: (callback = (error) ->) -> @@ -34,11 +45,14 @@ class User return callback(error) if error? AuthenticationManager.setUserPassword user._id, @password, (error) => return callback(error) if error? - @id = user?._id?.toString() - @_id = user?._id?.toString() - @first_name = user?.first_name - @referal_id = user?.referal_id - callback(null, @password) + UserUpdater.updateUser user._id, $set: emails: @emails, (error) => + return callback(error) if error? + @id = user?._id?.toString() + @_id = user?._id?.toString() + @first_name = user?.first_name + @referal_id = user?.referal_id + + callback(null, @password) setFeatures: (features, callback = (error) ->) -> update = {} @@ -62,6 +76,10 @@ class User @_id = user?._id?.toString() callback() + addEmail: (email, callback = (error) ->) -> + @emails.push(email: email, createdAt: new Date()) + UserUpdater.addEmailAddress @id, email, callback + ensure_admin: (callback = (error) ->) -> db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback @@ -143,6 +161,18 @@ class User return callback(err) callback(null) + createDocInProject: (project_id, parent_folder_id, name, callback=(error, doc_id)->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: "/project/#{project_id}/doc", + json: { + name: name, + parent_folder_id: parent_folder_id + } + }, (error, response, body) => + callback(null, body._id) + addUserToProject: (project_id, user, privileges, callback = (error, user) ->) -> if privileges == 'readAndWrite' updateOp = {$addToSet: {collaberator_refs: user._id.toString()}} @@ -214,6 +244,23 @@ class User return callback(error) if error? callback(null, response.statusCode) + activateSudoMode: (callback = (error)->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + uri: '/confirm-password', + json: + password: @password + }, callback + + updateSettings: (newSettings, callback = (error, response, body) ->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: '/user/settings' + json: newSettings + }, callback + getProjectListPage: (callback=(error, statusCode)->) -> @getCsrfToken (error) => return callback(error) if error? diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index 92d2a7dbdb..1d8d8ab27d 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee @@ -12,6 +12,7 @@ ObjectId = require("mongojs").ObjectId describe "AuthenticationController", -> beforeEach -> + tk.freeze(Date.now()) @AuthenticationController = SandboxedModule.require modulePath, requires: "./AuthenticationManager": @AuthenticationManager = {} "../User/UserGetter" : @UserGetter = {} @@ -39,7 +40,6 @@ describe "AuthenticationController", -> @req = new MockRequest() @res = new MockResponse() @callback = @next = sinon.stub() - tk.freeze(Date.now()) afterEach -> tk.reset() diff --git a/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee b/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee index ab1f1b0567..713179b056 100644 --- a/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee +++ b/services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee @@ -23,8 +23,8 @@ describe "BetaProgramController", -> optIn: sinon.stub() optOut: sinon.stub() }, - "../User/UserLocator": @UserLocator = { - findById: sinon.stub() + "../User/UserGetter": @UserGetter = { + getUser: sinon.stub() }, "settings-sharelatex": @settings = { languages: {} @@ -119,7 +119,7 @@ describe "BetaProgramController", -> describe "optInPage", -> beforeEach -> - @UserLocator.findById.callsArgWith(1, null, @user) + @UserGetter.getUser.callsArgWith(1, null, @user) it "should render the opt-in page", () -> @BetaProgramController.optInPage @req, @res, @next @@ -128,10 +128,10 @@ describe "BetaProgramController", -> args[0].should.equal 'beta_program/opt_in' - describe "when UserLocator.findById produces an error", -> + describe "when UserGetter.getUser produces an error", -> beforeEach -> - @UserLocator.findById.callsArgWith(1, new Error('woops')) + @UserGetter.getUser.callsArgWith(1, new Error('woops')) it "should not render the opt-in page", () -> @BetaProgramController.optInPage @req, @res, @next diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee index cdfff78249..4b57ff3697 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -24,11 +24,14 @@ describe "CollaboratorsInviteController", -> addCount: sinon.stub @LimitationsManager = {} + @UserGetter = + getUserByMainEmail: sinon.stub() + getUser: sinon.stub() @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: "../Project/ProjectGetter": @ProjectGetter = {} '../Subscription/LimitationsManager' : @LimitationsManager - '../User/UserGetter': @UserGetter = {getUser: sinon.stub()} + '../User/UserGetter': @UserGetter "./CollaboratorsHandler": @CollaboratorsHandler = {} "./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} 'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()} @@ -713,7 +716,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = {_id: ObjectId().toString()} - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `true`', (done) -> @call (err, shouldAllow) => @@ -725,7 +728,7 @@ describe "CollaboratorsInviteController", -> beforeEach -> @user = null - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user) it 'should callback with `false`', (done) -> @call (err, shouldAllow) => @@ -735,15 +738,15 @@ describe "CollaboratorsInviteController", -> it 'should have called getUser', (done) -> @call (err, shouldAllow) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @email}, {_id: 1}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true done() describe 'when getUser produces an error', -> beforeEach -> @user = null - @UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should callback with an error', (done) -> @call (err, shouldAllow) => diff --git a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee index 177c42d4ba..58b373f61f 100644 --- a/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee +++ b/services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", -> _id: ObjectId() first_name: "jim" @existingUser = {_id: ObjectId()} - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser) @fakeProject = _id: @project_id name: "some project" @@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should call getProject', (done) -> @@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the user does not exist', -> beforeEach -> - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, null) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null) it 'should not produce an error', (done) -> @call (err) => @@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> @@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", -> describe 'when the getUser produces an error', -> beforeEach -> - @UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops')) + @UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err) => @@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", -> it 'should call getUser', (done) -> @call (err) => - @UserGetter.getUser.callCount.should.equal 1 - @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + @UserGetter.getUserByMainEmail.callCount.should.equal 1 + @UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true done() it 'should not call getProject', (done) -> diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee index 6333db8270..f10f4631c1 100644 --- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee @@ -27,6 +27,11 @@ describe 'ExportsHandler', -> @project_history_id = 987 @user_id = "user-id-456" @brand_variation_id = 789 + @export_params = { + project_id: @project_id, + brand_variation_id: @brand_variation_id, + user_id: @user_id + } @callback = sinon.stub() describe 'exportProject', -> @@ -35,13 +40,13 @@ describe 'ExportsHandler', -> @response_body = {iAmAResponseBody: true} @ExportsHandler._buildExport = sinon.stub().yields(null, @export_data) @ExportsHandler._requestExport = sinon.stub().yields(null, @response_body) - @ExportsHandler.exportProject @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler.exportProject @export_params, (error, export_data) => @callback(error, export_data) done() it "should build the export", -> @ExportsHandler._buildExport - .calledWith(@project_id, @user_id, @brand_variation_id) + .calledWith(@export_params) .should.equal true it "should request the export", -> @@ -76,7 +81,7 @@ describe 'ExportsHandler', -> describe "when all goes well", -> beforeEach (done) -> - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -104,10 +109,40 @@ describe 'ExportsHandler', -> @callback.calledWith(null, expected_export_data) .should.equal true + describe "when we send replacement user first and last name", -> + beforeEach (done) -> + @custom_first_name = "FIRST" + @custom_last_name = "LAST" + @export_params.first_name = @custom_first_name + @export_params.last_name = @custom_last_name + @ExportsHandler._buildExport @export_params, (error, export_data) => + @callback(error, export_data) + done() + + it "should send the data from the user input", -> + expected_export_data = + project: + id: @project_id + rootDocPath: @rootDocPath + historyId: @project_history_id + historyVersion: @historyVersion + user: + id: @user_id + firstName: @custom_first_name + lastName: @custom_last_name + email: @user.email + orcidId: null + destination: + brandVariationId: @brand_variation_id + options: + callbackUrl: null + @callback.calledWith(null, expected_export_data) + .should.equal true + describe "when project is not found", -> beforeEach (done) -> @ProjectGetter.getProject = sinon.stub().yields(new Error("project not found")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -118,7 +153,7 @@ describe 'ExportsHandler', -> describe "when project has no root doc", -> beforeEach (done) -> @ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, null]) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -129,7 +164,7 @@ describe 'ExportsHandler', -> describe "when user is not found", -> beforeEach (done) -> @UserGetter.getUser = sinon.stub().yields(new Error("user not found")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() @@ -140,7 +175,7 @@ describe 'ExportsHandler', -> describe "when project history request fails", -> beforeEach (done) -> @ExportsHandler._requestVersion = sinon.stub().yields(new Error("project history call failed")) - @ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) => + @ExportsHandler._buildExport @export_params, (error, export_data) => @callback(error, export_data) done() diff --git a/services/web/test/unit/coffee/History/RestoreManagerTests.coffee b/services/web/test/unit/coffee/History/RestoreManagerTests.coffee index 37fe6752e7..012c720a2b 100644 --- a/services/web/test/unit/coffee/History/RestoreManagerTests.coffee +++ b/services/web/test/unit/coffee/History/RestoreManagerTests.coffee @@ -10,6 +10,7 @@ moment = require('moment') describe 'RestoreManager', -> beforeEach -> + tk.freeze Date.now() # freeze the time for these tests @RestoreManager = SandboxedModule.require modulePath, requires: '../../infrastructure/FileWriter': @FileWriter = {} '../Uploads/FileSystemImportManager': @FileSystemImportManager = {} @@ -22,7 +23,6 @@ describe 'RestoreManager', -> @project_id = 'mock-project-id' @version = 42 @callback = sinon.stub() - tk.freeze Date.now() # freeze the time for these tests afterEach -> tk.reset() diff --git a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee index b29839246a..261f5582dd 100644 --- a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee +++ b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee @@ -16,7 +16,7 @@ describe "PasswordResetHandler", -> getNewToken:sinon.stub() getValueFromTokenAndExpire:sinon.stub() @UserGetter = - getUser:sinon.stub() + getUserByMainEmail:sinon.stub() @EmailHandler = sendEmail:sinon.stub() @AuthenticationManager = @@ -40,7 +40,7 @@ describe "PasswordResetHandler", -> describe "generateAndEmailResetToken", -> it "should check the user exists", (done)-> - @UserGetter.getUser.callsArgWith(1) + @UserGetter.getUserByMainEmail.callsArgWith(1) @OneTimeTokenHandler.getNewToken.callsArgWith(1) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> exists.should.equal false @@ -49,7 +49,7 @@ describe "PasswordResetHandler", -> it "should send the email with the token", (done)-> - @UserGetter.getUser.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) @OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token) @EmailHandler.sendEmail.callsArgWith(2) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> @@ -62,7 +62,7 @@ describe "PasswordResetHandler", -> it "should return exists = false for a holdingAccount", (done) -> @user.holdingAccount = true - @UserGetter.getUser.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) @OneTimeTokenHandler.getNewToken.callsArgWith(1) @PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=> exists.should.equal false diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee index ec7246754c..1b66ba9bf9 100644 --- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee @@ -67,6 +67,7 @@ describe "ProjectController", -> protectTokens: sinon.stub() @CollaboratorsHandler = userIsTokenMember: sinon.stub().callsArgWith(2, null, false) + @ProjectEntityHandler = {} @Modules = hooks: fire: sinon.stub() @@ -98,6 +99,7 @@ describe "ProjectController", -> "../TokenAccess/TokenAccessHandler": @TokenAccessHandler "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler "../../infrastructure/Modules": @Modules + "./ProjectEntityHandler": @ProjectEntityHandler @projectName = "£12321jkj9ujkljds" @req = @@ -520,7 +522,62 @@ describe "ProjectController", -> @ProjectUpdateHandler.markAsOpened.calledWith(@project_id).should.equal true done() @ProjectController.loadEditor @req, @res - + + describe 'userProjectsJson', -> + beforeEach (done) -> + projects = [ + {archived: true, id: 'a', name: 'A', accessLevel: 'a', somethingElse: 1} + {archived: false, id: 'b', name: 'B', accessLevel: 'b', somethingElse: 1} + {archived: false, id: 'c', name: 'C', accessLevel: 'c', somethingElse: 1} + {archived: false, id: 'd', name: 'D', accessLevel: 'd', somethingElse: 1} + ] + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, []) + @ProjectController._buildProjectList = sinon.stub().returns(projects) + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + done() + + it 'should produce a list of projects', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + projects: [ + {_id: 'b', name: 'B', accessLevel: 'b'}, + {_id: 'c', name: 'C', accessLevel: 'c'}, + {_id: 'd', name: 'D', accessLevel: 'd'} + ] + } + done() + @ProjectController.userProjectsJson @req, @res, @next + + describe 'projectEntitiesJson', -> + beforeEach () -> + @AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc' + @req.params = {Project_id: 'abcd'} + @project = { _id: 'abcd' } + @docs = [ + {path: '/things/b.txt', doc: true}, + {path: '/main.tex', doc: true} + ] + @files = [ + {path: '/things/a.txt'} + ] + @ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project) + @ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files) + + it 'should produce a list of entities', (done) -> + @res.json = (data) => + expect(data).to.deep.equal { + project_id: 'abcd', + entities: [ + {path: '/main.tex', type: 'doc'}, + {path: '/things/a.txt', type: 'file'}, + {path: '/things/b.txt', type: 'doc'} + ] + } + expect(@ProjectGetter.getProject.callCount).to.equal 1 + expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1 + done() + @ProjectController.projectEntitiesJson @req, @res, @next + describe '_isInPercentageRollout', -> before -> @ids = [ diff --git a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee index c8f77bfd40..489c5676ff 100644 --- a/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/FeaturesUpdaterTests.coffee @@ -22,6 +22,7 @@ describe "FeaturesUpdater", -> describe "refreshFeatures", -> beforeEach -> + @V1SubscriptionManager.notifyV1OfFeaturesChange = sinon.stub().yields() @UserFeaturesUpdater.updateFeatures = sinon.stub().yields() @FeaturesUpdater._getIndividualFeatures = sinon.stub().yields(null, { 'individual': 'features' }) @FeaturesUpdater._getGroupFeatureSets = sinon.stub().yields(null, [{ 'group': 'features' }, { 'group': 'features2' }]) @@ -29,48 +30,63 @@ describe "FeaturesUpdater", -> @ReferalFeatures.getBonusFeatures = sinon.stub().yields(null, { 'bonus': 'features' }) @FeaturesUpdater._mergeFeatures = sinon.stub().returns({'merged': 'features'}) @callback = sinon.stub() - @FeaturesUpdater.refreshFeatures @user_id, @callback - it "should get the individual features", -> - @FeaturesUpdater._getIndividualFeatures - .calledWith(@user_id) - .should.equal true + describe "normally", -> + beforeEach -> + @FeaturesUpdater.refreshFeatures @user_id, @callback - it "should get the group features", -> - @FeaturesUpdater._getGroupFeatureSets - .calledWith(@user_id) - .should.equal true + it "should get the individual features", -> + @FeaturesUpdater._getIndividualFeatures + .calledWith(@user_id) + .should.equal true - it "should get the v1 features", -> - @FeaturesUpdater._getV1Features - .calledWith(@user_id) - .should.equal true + it "should get the group features", -> + @FeaturesUpdater._getGroupFeatureSets + .calledWith(@user_id) + .should.equal true - it "should get the bonus features", -> - @ReferalFeatures.getBonusFeatures - .calledWith(@user_id) - .should.equal true + it "should get the v1 features", -> + @FeaturesUpdater._getV1Features + .calledWith(@user_id) + .should.equal true - it "should merge from the default features", -> - @FeaturesUpdater._mergeFeatures.calledWith(@Settings.defaultFeatures).should.equal true + it "should get the bonus features", -> + @ReferalFeatures.getBonusFeatures + .calledWith(@user_id) + .should.equal true - it "should merge the individual features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'individual': 'features' }).should.equal true + it "should merge from the default features", -> + @FeaturesUpdater._mergeFeatures.calledWith(@Settings.defaultFeatures).should.equal true - it "should merge the group features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true + it "should merge the individual features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'individual': 'features' }).should.equal true - it "should merge the v1 features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true + it "should merge the group features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features' }).should.equal true + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'group': 'features2' }).should.equal true - it "should merge the bonus features", -> - @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'bonus': 'features' }).should.equal true + it "should merge the v1 features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'v1': 'features' }).should.equal true - it "should update the user with the merged features", -> - @UserFeaturesUpdater.updateFeatures - .calledWith(@user_id, {'merged': 'features'}) - .should.equal true + it "should merge the bonus features", -> + @FeaturesUpdater._mergeFeatures.calledWith(sinon.match.any, { 'bonus': 'features' }).should.equal true + + it "should update the user with the merged features", -> + @UserFeaturesUpdater.updateFeatures + .calledWith(@user_id, {'merged': 'features'}) + .should.equal true + + it "should notify v1", -> + @V1SubscriptionManager.notifyV1OfFeaturesChange + .called.should.equal true + + describe "with notifyV1 == false", -> + beforeEach -> + @FeaturesUpdater.refreshFeatures @user_id, false, @callback + + it "should not notify v1", -> + @V1SubscriptionManager.notifyV1OfFeaturesChange + .called.should.equal false describe "_mergeFeatures", -> it "should prefer priority over standard for compileGroup", -> diff --git a/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee b/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee index d951653310..c4f6aff4c1 100644 --- a/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee +++ b/services/web/test/unit/coffee/Subscription/RecurlyWrapperTests.coffee @@ -116,6 +116,7 @@ describe "RecurlyWrapper", -> apiKey: 'nonsense' privateKey: 'private_nonsense' + tk.freeze Date.now() # freeze the time for these tests @RecurlyWrapper = RecurlyWrapper = SandboxedModule.require modulePath, requires: "settings-sharelatex": @settings "logger-sharelatex": @@ -124,10 +125,11 @@ describe "RecurlyWrapper", -> log: sinon.stub() "request": sinon.stub() - describe "sign", -> + after -> + tk.reset() + describe "sign", -> before (done) -> - tk.freeze Date.now() # freeze the time for these tests @RecurlyWrapper.sign({ subscription : plan_code : "gold" @@ -137,9 +139,6 @@ describe "RecurlyWrapper", -> done() ) - after -> - tk.reset() - it "should be signed correctly", -> signed = @signature.split("|")[0] query = @signature.split("|")[1] @@ -1061,4 +1060,4 @@ describe "RecurlyWrapper", -> @RecurlyWrapper.listAccountActiveSubscriptions @user_id, @callback it "should return an empty array of subscriptions", -> - @callback.calledWith(null, []).should.equal true \ No newline at end of file + @callback.calledWith(null, []).should.equal true diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee index 04888e2dd7..1592215040 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee @@ -87,35 +87,69 @@ describe "SubscriptionController", -> @stubbedCurrencyCode = "GBP" describe "plansPage", -> - beforeEach (done) -> + beforeEach -> @req.ip = "1234.3123.3131.333 313.133.445.666 653.5345.5345.534" @GeoIpLookup.getCurrencyCode.callsArgWith(1, null, @stubbedCurrencyCode) - @res.callback = done - @SubscriptionController.plansPage(@req, @res) - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) - it "should set the recommended currency from the geoiplookup", (done)-> - @res.renderedVariables.recomendedCurrency.should.equal(@stubbedCurrencyCode) - @GeoIpLookup.getCurrencyCode.calledWith(@req.ip).should.equal true - done() + describe 'when user is logged in', (done) -> + beforeEach (done) -> + @res.callback = done + @SubscriptionController.plansPage(@req, @res) + it 'should fetch the current user', (done) -> + @UserGetter.getUser.callCount.should.equal 1 + done() - it 'should fetch the current user', (done) -> - @UserGetter.getUser.callCount.should.equal 1 - done() + it 'should decide not to AB test the plans when signed up before 2016-10-27', (done) -> + @res.renderedVariables.shouldABTestPlans.should.equal false + done() - it 'should decide not to AB test the plans', (done) -> - @res.renderedVariables.shouldABTestPlans.should.equal false - done() + describe 'not dependant on logged in state', (done) -> + # these could have been put in 'when user is not logged in' too + it "should set the recommended currency from the geoiplookup", (done)-> + @res.renderedVariables.recomendedCurrency.should.equal(@stubbedCurrencyCode) + @GeoIpLookup.getCurrencyCode.calledWith(@req.ip).should.equal true + done() + it 'should include data for features table', (done) -> + # this is part of AB test. If default wins test, then remove this test + @res.renderedVariables.planFeatures.length.should.not.equal 0 + done() describe 'when user is not logged in', (done) -> + beforeEach (done) -> + @UserGetter = + getUser: sinon.stub().callsArgWith(2, null, null) + @res.callback = done + @SubscriptionController.plansPage(@req, @res) + @AuthenticationController = + getLoggedInUser: sinon.stub().callsArgWith(1, null, null) + getLoggedInUserId: sinon.stub().returns(null) + getSessionUser: sinon.stub().returns(null) + isUserLoggedIn: sinon.stub().returns(false) - beforeEach -> - @AuthenticationController.getLoggedInUserId.returns(null) + @SubscriptionController = SandboxedModule.require modulePath, requires: + '../Authentication/AuthenticationController': @AuthenticationController + './SubscriptionHandler': @SubscriptionHandler + "./PlansLocator": @PlansLocator + './SubscriptionViewModelBuilder': @SubscriptionViewModelBuilder + "./LimitationsManager": @LimitationsManager + "../../infrastructure/GeoIpLookup":@GeoIpLookup + "logger-sharelatex": + log:-> + warn:-> + "settings-sharelatex": @settings + "./SubscriptionDomainHandler":@SubscriptionDomainHandler + "../User/UserGetter": @UserGetter + "./RecurlyWrapper": @RecurlyWrapper = {} + "./FeaturesUpdater": @FeaturesUpdater = {} it 'should not fetch the current user', (done) -> @UserGetter.getUser.callCount.should.equal 0 done() + it 'should decide to AB test', (done) -> + @res.renderedVariables.shouldABTestPlans.should.equal true + done() + describe "paymentPage", -> beforeEach -> @req.headers = {} @@ -460,4 +494,4 @@ describe "SubscriptionController", -> @SubscriptionHandler.updateSubscription.calledWith(@user, "collaborator-annual", "COLLABORATORCODEHERE").should.equal true done() - @SubscriptionController.processUpgradeToAnnualPlan @req, @res + @SubscriptionController.processUpgradeToAnnualPlan @req, @res \ No newline at end of file diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index acb5bdea16..12bf842ca2 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -33,9 +33,9 @@ describe "SubscriptionGroupHandler", -> addEmailInviteToGroup: sinon.stub().callsArgWith(2) removeEmailInviteFromGroup: sinon.stub().callsArgWith(2) - @UserLocator = - findById: sinon.stub() - findByEmail: sinon.stub() + @UserGetter = + getUser: sinon.stub() + getUserByMainEmail: sinon.stub() @LimitationsManager = hasGroupMembersLimitReached: sinon.stub() @@ -63,7 +63,7 @@ describe "SubscriptionGroupHandler", -> "./SubscriptionUpdater": @SubscriptionUpdater "./SubscriptionLocator": @SubscriptionLocator "../../models/Subscription": Subscription: @Subscription - "../User/UserLocator": @UserLocator + "../User/UserGetter": @UserGetter "./LimitationsManager": @LimitationsManager "../Security/OneTimeTokenHandler":@OneTimeTokenHandler "../Email/EmailHandler":@EmailHandler @@ -78,11 +78,11 @@ describe "SubscriptionGroupHandler", -> describe "addUserToGroup", -> beforeEach -> @LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription) - @UserLocator.findByEmail.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should find the user", (done)-> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> - @UserLocator.findByEmail.calledWith(@newEmail).should.equal true + @UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true done() it "should add the user to the group", (done)-> @@ -109,7 +109,7 @@ describe "SubscriptionGroupHandler", -> done() it "should add an email invite if no user is found", (done) -> - @UserLocator.findByEmail.callsArgWith(1, null, null) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, null) @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true done() @@ -155,26 +155,26 @@ describe "SubscriptionGroupHandler", -> beforeEach -> @subscription = {} @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) it "should locate the subscription", (done)-> - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> @SubscriptionLocator.getUsersSubscription.calledWith(@adminUser_id).should.equal true done() it "should get the users by id", (done)-> - @UserLocator.findById.callsArgWith(1, null, {_id:"31232"}) + @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) @subscription.member_ids = ["1234", "342432", "312312"] @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - @UserLocator.findById.calledWith(@subscription.member_ids[0]).should.equal true - @UserLocator.findById.calledWith(@subscription.member_ids[1]).should.equal true - @UserLocator.findById.calledWith(@subscription.member_ids[2]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true + @UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true users.length.should.equal @subscription.member_ids.length done() it "should just return the id if the user can not be found as they may have deleted their account", (done)-> - @UserLocator.findById.callsArgWith(1) + @UserGetter.getUser.callsArgWith(1) @subscription.member_ids = ["1234", "342432", "312312"] @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> assert.deepEqual users[0], {_id:@subscription.member_ids[0]} diff --git a/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee b/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee index ae6237e627..9d85d81f84 100644 --- a/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/V1SusbcriptionManagerTests.coffee @@ -16,10 +16,10 @@ describe 'V1SubscriptionManager', -> err: sinon.stub() warn: sinon.stub() "settings-sharelatex": - overleaf: - host: @host = "http://overleaf.example.com" + apis: + v1: + host: @host = "http://overleaf.example.com" "request": @request = sinon.stub() - @V1SubscriptionManager._v1PlanRequest = sinon.stub() @userId = 'abcd' @v1UserId = 42 @user = @@ -33,33 +33,20 @@ describe 'V1SubscriptionManager', -> @responseBody = id: 32, plan_name: 'pro' - @UserGetter.getUser = sinon.stub() - .yields(null, @user) - @V1SubscriptionManager._v1PlanRequest = sinon.stub() + @V1SubscriptionManager._v1Request = sinon.stub() .yields(null, @responseBody) @call = (cb) => @V1SubscriptionManager.getPlanCodeFromV1 @userId, cb describe 'when all goes well', -> - - it 'should call getUser', (done) -> + it 'should call _v1Request', (done) -> @call (err, planCode) => expect( - @UserGetter.getUser.callCount + @V1SubscriptionManager._v1Request.callCount ).to.equal 1 expect( - @UserGetter.getUser.calledWith(@userId) - ).to.equal true - done() - - it 'should call _v1PlanRequest', (done) -> - @call (err, planCode) => - expect( - @V1SubscriptionManager._v1PlanRequest.callCount - ).to.equal 1 - expect( - @V1SubscriptionManager._v1PlanRequest.calledWith( - @v1UserId + @V1SubscriptionManager._v1Request.calledWith( + @userId ) ).to.equal true done() @@ -80,49 +67,56 @@ describe 'V1SubscriptionManager', -> expect(planCode).to.equal null done() + describe '_v1Request', -> + beforeEach -> + @UserGetter.getUser = sinon.stub() + .yields(null, @user) + describe 'when getUser produces an error', -> beforeEach -> @UserGetter.getUser = sinon.stub() .yields(new Error('woops')) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb - it 'should not call _v1PlanRequest', (done) -> + it 'should not call request', (done) -> @call (err, planCode) => expect( - @V1SubscriptionManager._v1PlanRequest.callCount + @request.callCount ).to.equal 0 done() it 'should produce an error', (done) -> @call (err, planCode) => expect(err).to.exist - expect(planCode).to.not.exist done() describe 'when getUser does not find a user', -> beforeEach -> @UserGetter.getUser = sinon.stub() .yields(null, null) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb - it 'should not call _v1PlanRequest', (done) -> + it 'should not call request', (done) -> @call (err, planCode) => expect( - @V1SubscriptionManager._v1PlanRequest.callCount + @request.callCount ).to.equal 0 done() - it 'should produce a null plan-code, without error', (done) -> - @call (err, planCode) => + it 'should not error', (done) -> + @call (err) => expect(err).to.not.exist - expect(planCode).to.not.exist done() describe 'when the request to v1 fails', -> beforeEach -> - @V1SubscriptionManager._v1PlanRequest = sinon.stub() - .yields(new Error('woops')) + @request.yields(new Error('woops')) + @call = (cb) => + @V1SubscriptionManager._v1Request @user_id, { url: () -> '/foo' }, cb it 'should produce an error', (done) -> - @call (err, planCode) => + @call (err) => expect(err).to.exist - expect(planCode).to.not.exist done() diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee new file mode 100644 index 0000000000..5cf52eca39 --- /dev/null +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -0,0 +1,76 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = '../../../../app/js/Features/Templates/TemplatesController' + + +describe 'TemplatesController', -> + + project_id = "213432" + + beforeEach -> + @request = sinon.stub() + @request.returns { + pipe:-> + on:-> + } + @fs = { + unlink : sinon.stub() + createWriteStream : sinon.stub().returns(on:(_, cb)->cb()) + } + @ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})} + @dumpFolder = "dump/path" + @ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)} + @uuid = "1234" + @ProjectDetailsHandler = + getProjectDescription:sinon.stub() + @Project = + update: sinon.stub().callsArgWith(3, null) + @controller = SandboxedModule.require modulePath, requires: + '../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager + '../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler + '../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()} + './TemplatesPublisher':@TemplatesPublisher + "logger-sharelatex": + log:-> + err:-> + "settings-sharelatex": + path: + dumpFolder:@dumpFolder + siteUrl: @siteUrl = "http://localhost:3000" + apis: + v1: + url: @v1Url="http://overleaf.com" + user: "sharelatex" + pass: "password" + overleaf: + host: @v1Url + "uuid":v4:=>@uuid + "request": @request + "fs":@fs + "../../../../app/js/models/Project": {Project: @Project} + @zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex" + @templateName = "project name here" + @user_id = "1234" + @req = + session: + user: _id:@user_id + templateData: + zipUrl: @zipUrl + templateName: @templateName + @redirect = {} + @AuthenticationController.getLoggedInUserId.returns(@user_id) + + describe 'v1Templates', -> + + it "should fetch zip from v1 based on template id", (done)-> + @templateVersionId = 15 + @req.body = {templateVersionId: @templateVersionId} + + redirect = => + @request.calledWith("#{@v1Url}/api/v1/sharelatex/templates/#{@templateVersionId}").should.equal true + done() + res = redirect:redirect + @controller.createProjectFromV1Template @req, res diff --git a/services/web/test/unit/coffee/User/UserControllerTests.coffee b/services/web/test/unit/coffee/User/UserControllerTests.coffee index c358f35b22..e815d8d701 100644 --- a/services/web/test/unit/coffee/User/UserControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserControllerTests.coffee @@ -30,8 +30,8 @@ describe "UserController", -> @UserDeleter = deleteUser: sinon.stub().callsArgWith(1) - @UserLocator = - findById: sinon.stub().callsArgWith(1, null, @user) + @UserGetter = + getUser: sinon.stub().callsArgWith(1, null, @user) @User = findById: sinon.stub().callsArgWith(1, null, @user) @NewsLetterManager = @@ -63,7 +63,7 @@ describe "UserController", -> @SudoModeHandler = clearSudoMode: sinon.stub() @UserController = SandboxedModule.require modulePath, requires: - "./UserLocator": @UserLocator + "./UserGetter": @UserGetter "./UserDeleter": @UserDeleter "./UserUpdater":@UserUpdater "../../models/User": User:@User diff --git a/services/web/test/unit/coffee/User/UserCreatorTests.coffee b/services/web/test/unit/coffee/User/UserCreatorTests.coffee index 8470e5621f..cc2b1ec150 100644 --- a/services/web/test/unit/coffee/User/UserCreatorTests.coffee +++ b/services/web/test/unit/coffee/User/UserCreatorTests.coffee @@ -15,34 +15,16 @@ describe "UserCreator", -> constructor: -> return self.user - @UserLocator = - findByEmail: sinon.stub() + @UserGetter = + getUserByMainEmail: sinon.stub() @UserCreator = SandboxedModule.require modulePath, requires: "../../models/User": User:@UserModel - "./UserLocator":@UserLocator + "./UserGetter":@UserGetter "logger-sharelatex":{log:->} 'metrics-sharelatex': {timeAsyncMethod: ()->} @email = "bob.oswald@gmail.com" - - describe "getUserOrCreateHoldingAccount", -> - - it "should immediately return the user if found", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null, @user) - @UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=> - assert.deepEqual returnedUser, @user - done() - - it "should create new holding account if the user is not found", (done)-> - @UserLocator.findByEmail.callsArgWith(1) - @UserCreator.createNewUser = sinon.stub().callsArgWith(1, null, @user) - @UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=> - @UserCreator.createNewUser.calledWith(email:@email, holdingAccount:true).should.equal true - assert.deepEqual returnedUser, @user - done() - - describe "createNewUser", -> it "should take the opts and put them in the model", (done)-> diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee new file mode 100644 index 0000000000..7fb14a7f7d --- /dev/null +++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee @@ -0,0 +1,87 @@ +should = require('chai').should() +SandboxedModule = require('sandboxed-module') +assert = require('assert') +path = require('path') +sinon = require('sinon') +modulePath = path.join __dirname, "../../../../app/js/Features/User/UserGetter" +expect = require("chai").expect + +describe "UserGetter", -> + + beforeEach -> + @fakeUser = {_id:"12390i"} + @findOne = sinon.stub().callsArgWith(2, null, @fakeUser) + @Mongo = + db: users: findOne: @findOne + ObjectId: (id) -> return id + + @UserGetter = SandboxedModule.require modulePath, requires: + "logger-sharelatex": log:-> + "../../infrastructure/mongojs": @Mongo + "metrics-sharelatex": timeAsyncMethod: sinon.stub() + + describe "getUser", -> + it "should get user", (done)-> + query = _id: 'foo' + projection = email: 1 + @UserGetter.getUser query, projection, (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(query, projection).should.equal true + user.should.deep.equal @fakeUser + done() + + it "should not allow email in query", (done)-> + @UserGetter.getUser email: 'foo@bar.com', {}, (error, user) => + error.should.exist + done() + + describe "getUserbyMainEmail", -> + it "query user by main email", (done)-> + email = 'hello@world.com' + projection = emails: 1 + @UserGetter.getUserByMainEmail email, projection, (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(email: email, projection).should.equal true + done() + + it "return user if found", (done)-> + email = 'hello@world.com' + @UserGetter.getUserByMainEmail email, (error, user) => + user.should.deep.equal @fakeUser + done() + + it "trim email", (done)-> + email = 'hello@world.com' + @UserGetter.getUserByMainEmail " #{email} ", (error, user) => + @findOne.called.should.equal true + @findOne.calledWith(email: email).should.equal true + done() + + describe "getUserByAnyEmail", -> + it "query user for any email", (done)-> + email = 'hello@world.com' + expectedQuery = + emails: { $exists: true } + 'emails.email': email + projection = emails: 1 + @UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) => + @findOne.calledWith(expectedQuery, projection).should.equal true + user.should.deep.equal @fakeUser + done() + + it "query contains $exists:true so partial index is used", (done)-> + expectedQuery = + emails: { $exists: true } + 'emails.email': '' + @UserGetter.getUserByAnyEmail '', {}, (error, user) => + @findOne.calledWith(expectedQuery, {}).should.equal true + done() + + it "checks main email as well", (done)-> + @findOne.callsArgWith(2, null, null) + email = 'hello@world.com' + projection = emails: 1 + @UserGetter.getUserByAnyEmail " #{email} ", projection, (error, user) => + @findOne.calledTwice.should.equal true + @findOne.calledWith(email: email, projection).should.equal true + done() diff --git a/services/web/test/unit/coffee/User/UserLocatorTests.coffee b/services/web/test/unit/coffee/User/UserLocatorTests.coffee deleted file mode 100644 index dc3fc84dfa..0000000000 --- a/services/web/test/unit/coffee/User/UserLocatorTests.coffee +++ /dev/null @@ -1,39 +0,0 @@ -sinon = require('sinon') -chai = require('chai') -should = chai.should() -modulePath = "../../../../app/js/Features/User/UserLocator.js" -SandboxedModule = require('sandboxed-module') - -describe "UserLocator", -> - - beforeEach -> - @user = {_id:"12390i"} - @UserLocator = SandboxedModule.require modulePath, requires: - "../../infrastructure/mongojs": db: @db = { users: {} } - "metrics-sharelatex": timeAsyncMethod: sinon.stub() - 'logger-sharelatex' : { log: sinon.stub() } - @db.users = - findOne : sinon.stub().callsArgWith(1, null, @user) - - @email = "bob.oswald@gmail.com" - - - describe "findByEmail", -> - - it "should try and find a user with that email address", (done)-> - @UserLocator.findByEmail @email, (err, user)=> - @db.users.findOne.calledWith(email:@email).should.equal true - done() - - it "should trim white space", (done)-> - @UserLocator.findByEmail "#{@email} ", (err, user)=> - @db.users.findOne.calledWith(email:@email).should.equal true - done() - - it "should return the user if found", (done)-> - @UserLocator.findByEmail @email, (err, user)=> - user.should.deep.equal @user - done() - - - diff --git a/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee b/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee index 529f5b1be6..a0f155846f 100644 --- a/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee +++ b/services/web/test/unit/coffee/User/UserPagesControllerTests.coffee @@ -16,10 +16,7 @@ describe "UserPagesController", -> features:{} email: "joe@example.com" - @UserLocator = - findById: sinon.stub().callsArgWith(1, null, @user) - @UserGetter = - getUser: sinon.stub().callsArgWith(2, null, @user) + @UserGetter = getUser: sinon.stub() @UserSessionsManager = getAllUserSessions: sinon.stub() @dropboxStatus = {} @@ -37,7 +34,6 @@ describe "UserPagesController", -> "logger-sharelatex": log:-> err:-> - "./UserLocator": @UserLocator "./UserGetter": @UserGetter "./UserSessionsManager": @UserSessionsManager "../Errors/ErrorController": @ErrorController @@ -136,6 +132,8 @@ describe "UserPagesController", -> @UserPagesController.sessionsPage @req, @res, @next describe "settingsPage", -> + beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user) it "should render user/settings", (done)-> @res.render = (page)-> @@ -185,6 +183,7 @@ describe "UserPagesController", -> describe "activateAccountPage", -> beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) @req.query.user_id = @user_id @req.query.token = @token = "mock-token-123" diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index d0b96da2de..9411059022 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -12,8 +12,9 @@ describe "UserRegistrationHandler", -> @user = _id: @user_id = "31j2lk21kjl" @User = - findOne:sinon.stub() update: sinon.stub().callsArgWith(2) + @UserGetter = + getUserByMainEmail: sinon.stub() @UserCreator = createNewUser:sinon.stub().callsArgWith(1, null, @user) @AuthenticationManager = @@ -26,6 +27,7 @@ describe "UserRegistrationHandler", -> getNewToken: sinon.stub() @handler = SandboxedModule.require modulePath, requires: "../../models/User": {User:@User} + "./UserGetter": @UserGetter "./UserCreator": @UserCreator "../Authentication/AuthenticationManager":@AuthenticationManager "../Newsletter/NewsletterManager":@NewsLetterManager @@ -70,7 +72,7 @@ describe "UserRegistrationHandler", -> beforeEach -> @user.holdingAccount = true @handler._registrationRequestIsValid = sinon.stub().returns true - @User.findOne.callsArgWith(1, null, @user) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user) it "should not create a new user if there is a holding account there", (done)-> @handler.registerNewUser @passingRequest, (err)=> @@ -94,7 +96,7 @@ describe "UserRegistrationHandler", -> done() it "should return email registered in the error if there is a non holdingAccount there", (done)-> - @User.findOne.callsArgWith(1, null, @user = {holdingAccount:false}) + @UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false}) @handler.registerNewUser @passingRequest, (err, user)=> err.should.deep.equal new Error("EmailAlreadyRegistered") user.should.deep.equal @user @@ -103,7 +105,7 @@ describe "UserRegistrationHandler", -> describe "validRequest", -> beforeEach -> @handler._registrationRequestIsValid = sinon.stub().returns true - @User.findOne.callsArgWith 1 + @UserGetter.getUserByMainEmail.callsArgWith 1 it "should create a new user", (done)-> @handler.registerNewUser @passingRequest, (err)=> diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee index a6239e2e65..b952a688ae 100644 --- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee +++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee @@ -14,36 +14,136 @@ describe "UserUpdater", -> @mongojs = db:{} ObjectId:(id)-> return id - @UserLocator = - findByEmail:sinon.stub() + @UserGetter = + getUserEmail: sinon.stub() + getUserByAnyEmail: sinon.stub() + @logger = err: sinon.stub(), log: -> @UserUpdater = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings - "logger-sharelatex": log:-> - "./UserLocator":@UserLocator + "logger-sharelatex": @logger + "./UserGetter": @UserGetter "../../infrastructure/mongojs":@mongojs "metrics-sharelatex": timeAsyncMethod: sinon.stub() @stubbedUser = + _id: "3131231" name:"bob" email:"hello@world.com" - @user_id = "3131231" @newEmail = "bob@bob.com" - describe "changeEmailAddress", -> + describe 'changeEmailAddress', -> beforeEach -> - @UserUpdater.updateUser = sinon.stub().callsArgWith(2) + @UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email) + @UserUpdater.addEmailAddress = sinon.stub().callsArgWith(2) + @UserUpdater.setDefaultEmailAddress = sinon.stub().callsArgWith(2) + @UserUpdater.removeEmailAddress = sinon.stub().callsArgWith(2) - it "should check if the new email already has an account", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null, @stubbedUser) - @UserUpdater.changeEmailAddress @user_id, @stubbedUser.email, (err)=> - @UserUpdater.updateUser.called.should.equal false + it 'change email', (done)-> + @UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.addEmailAddress.calledWith( + @stubbedUser._id, @newEmail + ).should.equal true + @UserUpdater.setDefaultEmailAddress.calledWith( + @stubbedUser._id, @newEmail + ).should.equal true + @UserUpdater.removeEmailAddress.calledWith( + @stubbedUser._id, @stubbedUser.email + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.removeEmailAddress.callsArgWith(2, new Error('nope')) + @UserUpdater.changeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + describe 'addEmailAddress', -> + beforeEach -> + @UserUpdater._ensureUniqueEmailAddress = sinon.stub().callsArgWith(1) + + it 'add email', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null) + + @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> + @UserUpdater._ensureUniqueEmailAddress.called.should.equal true + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + @stubbedUser._id, + $push: { emails: { email: @newEmail, createdAt: sinon.match.date } } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=> + @logger.err.called.should.equal true + should.exist(err) + done() + + describe 'removeEmailAddress', -> + it 'remove email', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + { _id: @stubbedUser._id, email: { $ne: @newEmail } }, + $pull: { emails: { email: @newEmail } } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + it 'handle missed update', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0) + + @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + describe 'setDefaultEmailAddress', -> + it 'set default', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.not.exist(err) + @UserUpdater.updateUser.calledWith( + { _id: @stubbedUser._id, 'emails.email': @newEmail }, + $set: { email: @newEmail } + ).should.equal true + done() + + it 'handle error', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope')) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> + should.exist(err) + done() + + it 'handle missed update', (done)-> + @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0) + + @UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=> should.exist(err) done() - it "should set the users password", (done)-> - @UserLocator.findByEmail.callsArgWith(1, null) - @UserUpdater.changeEmailAddress @user_id, @newEmail, (err)=> - @UserUpdater.updateUser.calledWith(@user_id, $set: { "email": @newEmail}).should.equal true + describe '_ensureUniqueEmailAddress', -> + it 'should return error if existing user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1, null, @stubbedUser) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.exist(err) done() + it 'should return null if no user is found', (done)-> + @UserGetter.getUserByAnyEmail.callsArgWith(1) + @UserUpdater._ensureUniqueEmailAddress @newEmail, (err)=> + should.not.exist(err) + done()