diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee
index 428e4d506d..0e3329c41f 100644
--- a/services/web/Gruntfile.coffee
+++ b/services/web/Gruntfile.coffee
@@ -7,18 +7,69 @@ module.exports = (grunt) ->
grunt.loadNpmTasks 'grunt-mocha-test'
grunt.loadNpmTasks 'grunt-available-tasks'
grunt.loadNpmTasks 'grunt-contrib-requirejs'
- grunt.loadNpmTasks 'grunt-execute'
grunt.loadNpmTasks 'grunt-bunyan'
grunt.loadNpmTasks 'grunt-sed'
grunt.loadNpmTasks 'grunt-git-rev-parse'
grunt.loadNpmTasks 'grunt-file-append'
grunt.loadNpmTasks 'grunt-file-append'
grunt.loadNpmTasks 'grunt-env'
+ grunt.loadNpmTasks 'grunt-newer'
+ grunt.loadNpmTasks 'grunt-contrib-watch'
+ grunt.loadNpmTasks 'grunt-parallel'
+ grunt.loadNpmTasks 'grunt-exec'
+ # grunt.loadNpmTasks 'grunt-contrib-imagemin'
+ # grunt.loadNpmTasks 'grunt-sprity'
config =
- execute:
- app:
- src: "app.js"
+
+ exec:
+ run:
+ command:"node app.js | ./node_modules/logger-sharelatex/node_modules/bunyan/bin/bunyan --color"
+ cssmin:
+ command:"node_modules/clean-css/bin/cleancss --s0 -o public/stylesheets/style.css public/stylesheets/style.css"
+
+
+ watch:
+ coffee:
+ files: 'public/**/*.coffee'
+ tasks: ['quickcompile:coffee']
+ options: {}
+
+ less:
+ files: '**/*.less'
+ tasks: ['compile:css']
+ options: {}
+
+
+ parallel:
+ run:
+ tasks:['exec', 'watch']
+ options:
+ grunt:true
+ stream:true
+
+
+ # imagemin:
+ # dynamic:
+ # files: [{
+ # expand: true
+ # cwd: 'public/img/'
+ # src: ['**/*.{png,jpg,gif}']
+ # dest: 'public/img/'
+ # }]
+ # options:
+ # interlaced:false
+ # optimizationLevel: 7
+
+ # sprity:
+ # sprite:
+ # options:
+ # cssPath:"/img/"
+ # 'style': '../../public/stylesheets/app/sprites.less'
+ # margin: 0
+ # src: ['./public/img/flags/24/*.png']
+ # dest: './public/img/sprite'
+
coffee:
app_dir:
@@ -84,11 +135,16 @@ module.exports = (grunt) ->
files:
"public/stylesheets/style.css": "public/stylesheets/style.less"
+
+
+
env:
run:
add:
NODE_TLS_REJECT_UNAUTHORIZED:0
+
+
requirejs:
compile:
options:
@@ -170,44 +226,47 @@ module.exports = (grunt) ->
pattern: "@@RELEASE@@"
replacement: process.env.BUILD_NUMBER || "(unknown build)"
+
+
+
availabletasks:
tasks:
options:
- filter: 'exclude',
- tasks: [
- 'coffee'
- 'less'
- 'clean'
- 'mochaTest'
- 'availabletasks'
- 'wrap_sharejs'
- 'requirejs'
- 'execute'
- 'bunyan'
- ]
- groups:
- "Compile tasks": [
- "compile:server"
- "compile:client"
- "compile:tests"
- "compile"
- "compile:unit_tests"
- "compile:smoke_tests"
- "compile:css"
- "compile:minify"
- "install"
- ]
- "Test tasks": [
- "test:unit"
- "test:acceptance"
- ]
- "Run tasks": [
- "run"
- "default"
- ]
- "Misc": [
- "help"
- ]
+ filter: 'exclude',
+ tasks: [
+ 'coffee'
+ 'less'
+ 'clean'
+ 'mochaTest'
+ 'availabletasks'
+ 'wrap_sharejs'
+ 'requirejs'
+ 'execute'
+ 'bunyan'
+ ]
+ groups:
+ "Compile tasks": [
+ "compile:server"
+ "compile:client"
+ "compile:tests"
+ "compile"
+ "compile:unit_tests"
+ "compile:smoke_tests"
+ "compile:css"
+ "compile:minify"
+ "install"
+ ]
+ "Test tasks": [
+ "test:unit"
+ "test:acceptance"
+ ]
+ "Run tasks": [
+ "run"
+ "default"
+ ]
+ "Misc": [
+ "help"
+ ]
moduleCompileServerTasks = []
moduleCompileUnitTestTasks = []
@@ -304,12 +363,14 @@ module.exports = (grunt) ->
grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
- grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append"]
+ grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
grunt.registerTask 'compile:smoke_tests', 'Compile the smoke tests', ['coffee:smoke_tests']
grunt.registerTask 'compile:tests', 'Compile all the tests', ['compile:smoke_tests', 'compile:unit_tests']
grunt.registerTask 'compile', 'Compiles everything need to run web-sharelatex', ['compile:server', 'compile:client', 'compile:css']
+ grunt.registerTask 'quickcompile:coffee', 'Compiles only changed coffee files',['newer:coffee']
+
grunt.registerTask 'install', "Compile everything when installing as an npm module", ['compile']
@@ -319,11 +380,13 @@ module.exports = (grunt) ->
grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
- grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'bunyan', 'env:run', 'execute']
+ grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
+
grunt.registerTask 'default', 'run'
grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
+
grunt.registerTask 'create-admin-user', "Create a user with the given email address and make them an admin. Update in place if the user already exists", () ->
done = @async()
email = grunt.option("email")
@@ -356,4 +419,24 @@ module.exports = (grunt) ->
#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}
"""
- done()
\ No newline at end of file
+ done()
+
+ grunt.registerTask 'delete-user', "deletes a user and all their data", () ->
+ done = @async()
+ email = grunt.option("email")
+ if !email?
+ console.error "Usage: grunt delete-user --email joe@example.com"
+ process.exit(1)
+ settings = require "settings-sharelatex"
+ UserGetter = require "./app/js/Features/User/UserGetter"
+ UserDeleter = require "./app/js/Features/User/UserDeleter"
+ UserGetter.getUser email:email, (error, user) ->
+ if error?
+ throw error
+ if !user?
+ console.log("user #{email} not in database, potentially already deleted")
+ return done()
+ UserDeleter.deleteUser user._id, (err)->
+ if err?
+ throw err
+ done()
\ No newline at end of file
diff --git a/services/web/README.md b/services/web/README.md
index 00d94343ab..f777e7e5f5 100644
--- a/services/web/README.md
+++ b/services/web/README.md
@@ -6,9 +6,17 @@ web-sharelatex is the front-end web service of the open-source web-based collabo
It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains
a lot of logic around creating and editing projects, and account management.
+
The rest of the ShareLaTeX stack, along with information about contributing can be found in the
[sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
+Build process
+----------------
+
+web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related assets.
+
+Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`).
+
Unit test status
----------------
@@ -42,3 +50,11 @@ in the `public/img/iconshock` directory found via
[findicons.com](http://findicons.com/icon/498089/height?id=526085#)
+## Acceptance Tests
+
+To run the Acceptance tests:
+
+- set `allowPublicAccess` to true, either in the configuration file,
+ or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
+- start the server (`grunt`)
+- in a separate terminal, run `grunt test:acceptance`
diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
index e7db5d9f65..d75bef5207 100644
--- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
+++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
@@ -9,11 +9,12 @@ Url = require("url")
Settings = require "settings-sharelatex"
basicAuth = require('basic-auth-connect')
UserHandler = require("../User/UserHandler")
+UserSessionsManager = require("../User/UserSessionsManager")
module.exports = AuthenticationController =
login: (req, res, next = (error) ->) ->
AuthenticationController.doLogin req.body, req, res, next
-
+
doLogin: (options, req, res, next) ->
email = options.email?.toLowerCase()
password = options.password
@@ -61,7 +62,7 @@ module.exports = AuthenticationController =
requireLogin: () ->
doRequest = (req, res, next = (error) ->) ->
if !req.session.user?
- AuthenticationController._redirectToLoginOrRegisterPage(req, res)
+ AuthenticationController._redirectToLoginOrRegisterPage(req, res)
else
req.user = req.session.user
return next()
@@ -92,9 +93,9 @@ module.exports = AuthenticationController =
_redirectToLoginOrRegisterPage: (req, res)->
if req.query.zipUrl? or req.query.project_name?
- return AuthenticationController._redirectToRegisterPage(req, res)
+ return AuthenticationController._redirectToRegisterPage(req, res)
else
- AuthenticationController._redirectToLoginPage(req, res)
+ AuthenticationController._redirectToLoginPage(req, res)
_redirectToLoginPage: (req, res) ->
@@ -132,6 +133,8 @@ module.exports = AuthenticationController =
isAdmin: user.isAdmin
email: user.email
referal_id: user.referal_id
+ session_created: (new Date()).toISOString()
+ ip_address: req.ip
# Regenerate the session to get a new sessionID (cookie value) to
# protect against session fixation attacks
oldSession = req.session
@@ -141,4 +144,6 @@ module.exports = AuthenticationController =
req.session[key] = value
req.session.user = lightUser
+
+ UserSessionsManager.trackSession(user, req.sessionID, () ->)
callback()
diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
index 201643e88e..bfcd55855d 100644
--- a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
+++ b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
@@ -1,8 +1,11 @@
+Settings = require "settings-sharelatex"
User = require("../../models/User").User
{db, ObjectId} = require("../../infrastructure/mongojs")
crypto = require 'crypto'
bcrypt = require 'bcrypt'
+BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12
+
module.exports = AuthenticationManager =
authenticate: (query, password, callback = (error, user) ->) ->
# Using Mongoose for legacy reasons here. The returned User instance
@@ -15,7 +18,9 @@ module.exports = AuthenticationManager =
bcrypt.compare password, user.hashedPassword, (error, match) ->
return callback(error) if error?
if match
- callback null, user
+ AuthenticationManager.checkRounds user, user.hashedPassword, password, (err) ->
+ return callback(err) if err?
+ callback null, user
else
callback null, null
else
@@ -24,7 +29,7 @@ module.exports = AuthenticationManager =
callback null, null
setUserPassword: (user_id, password, callback = (error) ->) ->
- bcrypt.genSalt 7, (error, salt) ->
+ bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
return callback(error) if error?
bcrypt.hash password, salt, (error, hash) ->
return callback(error) if error?
@@ -35,3 +40,10 @@ module.exports = AuthenticationManager =
$unset: password: true
}, callback)
+ checkRounds: (user, hashedPassword, password, callback = (error) ->) ->
+ # check current number of rounds and rehash if necessary
+ currentRounds = bcrypt.getRounds hashedPassword
+ if currentRounds < BCRYPT_ROUNDS
+ AuthenticationManager.setUserPassword user._id, password, callback
+ else
+ callback()
diff --git a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
new file mode 100644
index 0000000000..a986eb8e4c
--- /dev/null
+++ b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
@@ -0,0 +1,39 @@
+BetaProgramHandler = require './BetaProgramHandler'
+UserLocator = require "../User/UserLocator"
+Settings = require "settings-sharelatex"
+logger = require 'logger-sharelatex'
+
+
+module.exports = BetaProgramController =
+
+ optIn: (req, res, next) ->
+ user_id = req?.session?.user?._id
+ logger.log {user_id}, "user opting in to beta program"
+ if !user_id
+ return next(new Error("no user id in session"))
+ BetaProgramHandler.optIn user_id, (err) ->
+ if err
+ return next(err)
+ return res.redirect "/beta/participate"
+
+ optOut: (req, res, next) ->
+ user_id = req?.session?.user?._id
+ logger.log {user_id}, "user opting out of beta program"
+ if !user_id
+ return next(new Error("no user id in session"))
+ BetaProgramHandler.optOut user_id, (err) ->
+ if err
+ return next(err)
+ return res.redirect "/beta/participate"
+
+ optInPage: (req, res, next)->
+ user_id = req.session?.user?._id
+ logger.log {user_id}, "showing beta participation page for user"
+ UserLocator.findById user_id, (err, user)->
+ if err
+ logger.err {err, user_id}, "error fetching user"
+ return next(err)
+ res.render 'beta_program/opt_in',
+ title:'sharelatex_beta_program'
+ user: user,
+ languages: Settings.languages,
diff --git a/services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee b/services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee
new file mode 100644
index 0000000000..788782d578
--- /dev/null
+++ b/services/web/app/coffee/Features/BetaProgram/BetaProgramHandler.coffee
@@ -0,0 +1,31 @@
+User = require("../../models/User").User
+logger = require 'logger-sharelatex'
+metrics = require("../../infrastructure/Metrics")
+
+module.exports = BetaProgramHandler =
+
+ optIn: (user_id, callback=(err)->) ->
+ User.findById user_id, (err, user) ->
+ if err
+ logger.err {err, user_id}, "problem adding user to beta"
+ return callback(err)
+ metrics.inc "beta-program.opt-in"
+ user.betaProgram = true
+ user.save (err) ->
+ if err
+ logger.err {err, user_id}, "problem adding user to beta"
+ return callback(err)
+ return callback(null)
+
+ optOut: (user_id, callback=(err)->) ->
+ User.findById user_id, (err, user) ->
+ if err
+ logger.err {err, user_id}, "problem removing user from beta"
+ return callback(err)
+ metrics.inc "beta-program.opt-out"
+ user.betaProgram = false
+ user.save (err) ->
+ if err
+ logger.err {err, user_id}, "problem removing user from beta"
+ return callback(err)
+ return callback(null)
diff --git a/services/web/app/coffee/Features/Blog/BlogController.coffee b/services/web/app/coffee/Features/Blog/BlogController.coffee
index 8f726602c3..4aaee6962a 100644
--- a/services/web/app/coffee/Features/Blog/BlogController.coffee
+++ b/services/web/app/coffee/Features/Blog/BlogController.coffee
@@ -20,13 +20,15 @@ module.exports = BlogController =
logger.log url:url, "proxying request to blog api"
request.get blogUrl, (err, r, data)->
- if r?.statusCode == 404
+ if r?.statusCode == 404 or r?.statusCode == 403
return ErrorController.notFound(req, res, next)
if err?
return res.send 500
data = data.trim()
try
data = JSON.parse(data)
+ if settings.cdn?.web?.host?
+ data?.content = data?.content?.replace(/src="([^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
catch err
logger.err err:err, data:data, "error parsing data from data"
res.render "blog/blog_holder", data
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
index 71737eecff..81557fea42 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
@@ -30,12 +30,17 @@ module.exports = CollaboratorsHandler =
getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
return callback(error) if error?
+ result = []
async.mapLimit members, 3,
(member, cb) ->
UserGetter.getUser member.id, (error, user) ->
return cb(error) if error?
- return cb(null, { user: user, privilegeLevel: member.privilegeLevel })
- callback
+ if user?
+ result.push { user: user, privilegeLevel: member.privilegeLevel }
+ cb()
+ (error) ->
+ return callback(error) if error?
+ callback null, result
getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
# In future if the schema changes and getting all member ids is more expensive (multiple documents)
@@ -82,6 +87,18 @@ module.exports = CollaboratorsHandler =
logger.error err: err, "problem removing user from project collaberators"
callback(err)
+ removeUserFromAllProjets: (user_id, callback = (error) ->) ->
+ CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
+ return callback(error) if error?
+ allProjects = readAndWriteProjects.concat(readOnlyProjects)
+ jobs = []
+ for project in allProjects
+ do (project) ->
+ jobs.push (cb) ->
+ return cb() if !project?
+ CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
+ async.series jobs, callback
+
addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
emails = mimelib.parseAddresses(unparsed_email)
email = emails[0]?.address?.toLowerCase()
diff --git a/services/web/app/coffee/Features/Compile/ClsiFormatChecker.coffee b/services/web/app/coffee/Features/Compile/ClsiFormatChecker.coffee
new file mode 100644
index 0000000000..b285d0e6b7
--- /dev/null
+++ b/services/web/app/coffee/Features/Compile/ClsiFormatChecker.coffee
@@ -0,0 +1,68 @@
+_ = require("lodash")
+async = require("async")
+
+module.exports = ClsiFormatChecker =
+
+ checkRecoursesForProblems: (resources, callback)->
+ jobs =
+ conflictedPaths: (cb)->
+ ClsiFormatChecker._checkForConflictingPaths resources, cb
+
+ sizeCheck: (cb)->
+ ClsiFormatChecker._checkDocsAreUnderSizeLimit resources, cb
+
+ async.series jobs, (err, problems)->
+ if err?
+ return callback(err)
+
+ problems = _.omitBy(problems, _.isEmpty)
+
+ if _.isEmpty(problems)
+ return callback()
+ else
+ callback(null, problems)
+
+
+ _checkForConflictingPaths: (resources, callback)->
+ paths = _.map(resources, 'path')
+
+ conflicts = _.filter paths, (path)->
+ matchingPaths = _.filter paths, (checkPath)->
+ return checkPath.indexOf(path+"/") != -1
+
+ return matchingPaths.length > 0
+
+ conflictObjects = _.map conflicts, (conflict)->
+ path:conflict
+
+ callback null, conflictObjects
+
+ _checkDocsAreUnderSizeLimit: (resources, callback)->
+
+ FIVEMB = 1000 * 1000 * 5
+
+ totalSize = 0
+
+ sizedResources = _.map resources, (resource)->
+ result = {path:resource.path}
+ if resource.content?
+ result.size = resource.content.replace(/\n/g).length
+ result.kbSize = Math.ceil(result.size / 1000)
+ else
+ result.size = 0
+ totalSize += result.size
+ return result
+
+ tooLarge = totalSize > FIVEMB
+ if !tooLarge
+ return callback()
+ else
+ sizedResources = _.sortBy(sizedResources, "size").reverse().slice(0, 10)
+ return callback(null, {resources:sizedResources, totalSize:totalSize})
+
+
+
+
+
+
+
diff --git a/services/web/app/coffee/Features/Compile/ClsiManager.coffee b/services/web/app/coffee/Features/Compile/ClsiManager.coffee
index 9af1602d2c..568d806e99 100755
--- a/services/web/app/coffee/Features/Compile/ClsiManager.coffee
+++ b/services/web/app/coffee/Features/Compile/ClsiManager.coffee
@@ -7,15 +7,24 @@ ProjectEntityHandler = require("../Project/ProjectEntityHandler")
logger = require "logger-sharelatex"
Url = require("url")
ClsiCookieManager = require("./ClsiCookieManager")
-
+_ = require("underscore")
+async = require("async")
+ClsiFormatChecker = require("./ClsiFormatChecker")
module.exports = ClsiManager =
- sendRequest: (project_id, options = {}, callback = (error, success) ->) ->
+ sendRequest: (project_id, user_id, options = {}, callback = (error, status, outputFiles, clsiServerId, validationProblems) ->) ->
ClsiManager._buildRequest project_id, options, (error, req) ->
return callback(error) if error?
logger.log project_id: project_id, "sending compile to CLSI"
- ClsiManager._postToClsi project_id, req, options.compileGroup, (error, response) ->
+ ClsiFormatChecker.checkRecoursesForProblems req.compile?.resources, (err, validationProblems)->
+ if err?
+ logger.err err, project_id, "could not check resources for potential problems before sending to clsi"
+ return callback(err)
+ if validationProblems?
+ logger.log project_id:project_id, validationProblems:validationProblems, "problems with users latex before compile was attempted"
+ return callback(null, "validation-problems", null, null, validationProblems)
+ ClsiManager._postToClsi project_id, user_id, req, options.compileGroup, (error, response) ->
if error?
logger.err err:error, project_id:project_id, "error sending request to clsi"
return callback(error)
@@ -27,13 +36,19 @@ module.exports = ClsiManager =
outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
callback(null, response?.compile?.status, outputFiles, clsiServerId)
- deleteAuxFiles: (project_id, options, callback = (error) ->) ->
- compilerUrl = @_getCompilerUrl(options?.compileGroup)
+ stopCompile: (project_id, user_id, options, callback = (error) ->) ->
+ compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id, "compile/stop")
opts =
- url:"#{compilerUrl}/project/#{project_id}"
- method:"DELETE"
+ url:compilerUrl
+ method:"POST"
ClsiManager._makeRequest project_id, opts, callback
+ deleteAuxFiles: (project_id, user_id, options, callback = (error) ->) ->
+ compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id)
+ opts =
+ url:compilerUrl
+ method:"DELETE"
+ ClsiManager._makeRequest project_id, opts, callback
_makeRequest: (project_id, opts, callback)->
ClsiCookieManager.getCookieJar project_id, (err, jar)->
@@ -51,14 +66,17 @@ module.exports = ClsiManager =
return callback err, response, body
+ _getCompilerUrl: (compileGroup, project_id, user_id, action) ->
+ host = Settings.apis.clsi.url
+ path = "/project/#{project_id}"
+ path += "/user/#{user_id}" if user_id?
+ path += "/#{action}" if action?
+ return "#{host}#{path}"
- _getCompilerUrl: (compileGroup) ->
- return Settings.apis.clsi.url
-
- _postToClsi: (project_id, req, compileGroup, callback = (error, response) ->) ->
- compilerUrl = Settings.apis.clsi.url
- opts =
- url: "#{compilerUrl}/project/#{project_id}/compile"
+ _postToClsi: (project_id, user_id, req, compileGroup, callback = (error, response) ->) ->
+ compileUrl = @_getCompilerUrl(compileGroup, project_id, user_id, "compile")
+ opts =
+ url: compileUrl
json: req
method: "POST"
ClsiManager._makeRequest project_id, opts, (error, response, body) ->
@@ -75,10 +93,9 @@ module.exports = ClsiManager =
_parseOutputFiles: (project_id, rawOutputFiles = []) ->
outputFiles = []
for file in rawOutputFiles
- path = Url.parse(file.url).path
- path = path.replace("/project/#{project_id}/output/", "")
outputFiles.push
- path: path
+ path: file.path # the clsi is now sending this to web
+ url: Url.parse(file.url).path # the location of the file on the clsi, excluding the host part
type: file.type
build: file.build
return outputFiles
@@ -134,15 +151,15 @@ module.exports = ClsiManager =
resources: resources
}
- wordCount: (project_id, file, options, callback = (error, response) ->) ->
+ wordCount: (project_id, user_id, file, options, callback = (error, response) ->) ->
ClsiManager._buildRequest project_id, options, (error, req) ->
- compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
filename = file || req?.compile?.rootResourcePath
- wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
- if req.compile.options.imageName?
- wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
+ wordcount_url = ClsiManager._getCompilerUrl(options?.compileGroup, project_id, user_id, "wordcount")
opts =
url: wordcount_url
+ qs:
+ file: filename
+ image: req.compile.options.imageName
method: "GET"
ClsiManager._makeRequest project_id, opts, (error, response, body) ->
return callback(error) if error?
diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee
index b50c6ffd1a..f3680dc38e 100755
--- a/services/web/app/coffee/Features/Compile/CompileController.coffee
+++ b/services/web/app/coffee/Features/Compile/CompileController.coffee
@@ -29,8 +29,8 @@ module.exports = CompileController =
options.compiler = req.body.compiler
if req.body?.draft
options.draft = req.body.draft
- logger.log {options, project_id}, "got compile request"
- CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits) ->
+ logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
+ CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
return next(error) if error?
res.contentType("application/json")
res.status(200).send JSON.stringify {
@@ -38,8 +38,32 @@ module.exports = CompileController =
outputFiles: outputFiles
compileGroup: limits?.compileGroup
clsiServerId:clsiServerId
+ validationProblems:validationProblems
}
+ stopCompile: (req, res, next = (error) ->) ->
+ project_id = req.params.Project_id
+ AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+ return next(error) if error?
+ logger.log {project_id:project_id, user_id:user_id}, "stop compile request"
+ CompileManager.stopCompile project_id, user_id, (error) ->
+ return next(error) if error?
+ res.status(200).send()
+
+ _compileAsUser: (req, callback) ->
+ # callback with user_id if per-user, undefined otherwise
+ if not Settings.disablePerUserCompiles
+ AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+ else
+ callback() # do a per-project compile, not per-user
+
+ _downloadAsUser: (req, callback) ->
+ # callback with user_id if per-user, undefined otherwise
+ if not Settings.disablePerUserCompiles
+ AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+ else
+ callback() # do a per-project compile, not per-user
+
downloadPdf: (req, res, next = (error) ->)->
Metrics.inc "pdf-downloads"
project_id = req.params.Project_id
@@ -72,16 +96,22 @@ module.exports = CompileController =
logger.log project_id:project_id, ip:req.ip, "rate limit hit downloading pdf"
res.send 500
else
- CompileController.proxyToClsi(project_id, "/project/#{project_id}/output/output.pdf", req, res, next)
+ CompileController._downloadAsUser req, (error, user_id) ->
+ url = CompileController._getFileUrl project_id, user_id, req.params.build_id, "output.pdf"
+ CompileController.proxyToClsi(project_id, url, req, res, next)
deleteAuxFiles: (req, res, next) ->
project_id = req.params.Project_id
- CompileManager.deleteAuxFiles project_id, (error) ->
+ CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
- res.sendStatus(200)
+ CompileManager.deleteAuxFiles project_id, user_id, (error) ->
+ return next(error) if error?
+ res.sendStatus(200)
+ # this is only used by templates, so is not called with a user_id
compileAndDownloadPdf: (req, res, next)->
project_id = req.params.project_id
+ # pass user_id as null, since templates are an "anonymous" compile
CompileManager.compile project_id, null, {}, (err)->
if err?
logger.err err:err, project_id:project_id, "something went wrong compile and downloading pdf"
@@ -91,12 +121,28 @@ module.exports = CompileController =
getFileFromClsi: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
- build = req.params.build
- if build?
- url = "/project/#{project_id}/build/#{build}/output/#{req.params.file}"
+ CompileController._downloadAsUser req, (error, user_id) ->
+ return next(error) if error?
+ url = CompileController._getFileUrl project_id, user_id, req.params.build_id, req.params.file
+ CompileController.proxyToClsi(project_id, url, req, res, next)
+
+ # compute a GET file url for a given project, user (optional), build (optional) and file
+ _getFileUrl: (project_id, user_id, build_id, file) ->
+ if user_id? and build_id?
+ url = "/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{file}"
+ else if user_id?
+ url = "/project/#{project_id}/user/#{user_id}/output/#{file}"
+ else if build_id?
+ url = "/project/#{project_id}/build/#{build_id}/output/#{file}"
else
- url = "/project/#{project_id}/output/#{req.params.file}"
- CompileController.proxyToClsi(project_id, url, req, res, next)
+ url = "/project/#{project_id}/output/#{file}"
+ return url
+
+ # compute a POST url for a project, user (optional) and action
+ _getUrl: (project_id, user_id, action) ->
+ path = "/project/#{project_id}"
+ path += "/user/#{user_id}" if user_id?
+ return "#{path}/#{action}"
proxySyncPdf: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
@@ -107,20 +153,35 @@ module.exports = CompileController =
return next(new Error("invalid h parameter"))
if not v?.match(/^\d+\.\d+$/)
return next(new Error("invalid v parameter"))
- destination = {url: "/project/#{project_id}/sync/pdf", qs: {page, h, v}}
- CompileController.proxyToClsi(project_id, destination, req, res, next)
+ # whether this request is going to a per-user container
+ CompileController._compileAsUser req, (error, user_id) ->
+ return next(error) if error?
+ url = CompileController._getUrl(project_id, user_id, "sync/pdf")
+ destination = {url: url, qs: {page, h, v}}
+ CompileController.proxyToClsi(project_id, destination, req, res, next)
proxySyncCode: (req, res, next = (error) ->) ->
project_id = req.params.Project_id
{file, line, column} = req.query
- if not file? or Path.resolve("/", file) isnt "/#{file}"
+ if not file?
+ return next(new Error("missing file parameter"))
+ # Check that we are dealing with a simple file path (this is not
+ # strictly needed because synctex uses this parameter as a label
+ # to look up in the synctex output, and does not open the file
+ # itself). Since we have valid synctex paths like foo/./bar we
+ # allow those by replacing /./ with /
+ testPath = file.replace '/./', '/'
+ if Path.resolve("/", testPath) isnt "/#{testPath}"
return next(new Error("invalid file parameter"))
if not line?.match(/^\d+$/)
return next(new Error("invalid line parameter"))
if not column?.match(/^\d+$/)
return next(new Error("invalid column parameter"))
- destination = {url:"/project/#{project_id}/sync/code", qs: {file, line, column}}
- CompileController.proxyToClsi(project_id, destination, req, res, next)
+ CompileController._compileAsUser req, (error, user_id) ->
+ return next(error) if error?
+ url = CompileController._getUrl(project_id, user_id, "sync/code")
+ destination = {url:url, qs: {file, line, column}}
+ CompileController.proxyToClsi(project_id, destination, req, res, next)
proxyToClsi: (project_id, url, req, res, next = (error) ->) ->
if req.query?.compileGroup
@@ -138,10 +199,7 @@ module.exports = CompileController =
# expand any url parameter passed in as {url:..., qs:...}
if typeof url is "object"
{url, qs} = url
- if limits.compileGroup == "priority"
- compilerUrl = Settings.apis.clsi_priority.url
- else
- compilerUrl = Settings.apis.clsi.url
+ compilerUrl = Settings.apis.clsi.url
url = "#{compilerUrl}#{url}"
logger.log url: url, "proxying to CLSI"
oneMinute = 60 * 1000
@@ -168,7 +226,9 @@ module.exports = CompileController =
wordCount: (req, res, next) ->
project_id = req.params.Project_id
file = req.query.file || false
- CompileManager.wordCount project_id, file, (error, body) ->
+ CompileController._compileAsUser req, (error, user_id) ->
return next(error) if error?
- res.contentType("application/json")
- res.send body
+ CompileManager.wordCount project_id, user_id, file, (error, body) ->
+ return next(error) if error?
+ res.contentType("application/json")
+ res.send body
diff --git a/services/web/app/coffee/Features/Compile/CompileManager.coffee b/services/web/app/coffee/Features/Compile/CompileManager.coffee
index 0d8d480b7b..24a051f9f3 100755
--- a/services/web/app/coffee/Features/Compile/CompileManager.coffee
+++ b/services/web/app/coffee/Features/Compile/CompileManager.coffee
@@ -37,15 +37,23 @@ module.exports = CompileManager =
return callback(error) if error?
for key, value of limits
options[key] = value
- ClsiManager.sendRequest project_id, options, (error, status, outputFiles, clsiServerId) ->
+ # only pass user_id down to clsi if this is a per-user compile
+ compileAsUser = if Settings.disablePerUserCompiles then undefined else user_id
+ ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
return callback(error) if error?
logger.log files: outputFiles, "output files"
- callback(null, status, outputFiles, clsiServerId, limits)
+ callback(null, status, outputFiles, clsiServerId, limits, validationProblems)
- deleteAuxFiles: (project_id, callback = (error) ->) ->
+
+ stopCompile: (project_id, user_id, callback = (error) ->) ->
CompileManager.getProjectCompileLimits project_id, (error, limits) ->
return callback(error) if error?
- ClsiManager.deleteAuxFiles project_id, limits, callback
+ ClsiManager.stopCompile project_id, user_id, limits, callback
+
+ deleteAuxFiles: (project_id, user_id, callback = (error) ->) ->
+ CompileManager.getProjectCompileLimits project_id, (error, limits) ->
+ return callback(error) if error?
+ ClsiManager.deleteAuxFiles project_id, user_id, limits, callback
getProjectCompileLimits: (project_id, callback = (error, limits) ->) ->
Project.findById project_id, {owner_ref: 1}, (error, project) ->
@@ -92,7 +100,7 @@ module.exports = CompileManager =
else
ProjectRootDocManager.setRootDocAutomatically project_id, callback
- wordCount: (project_id, file, callback = (error) ->) ->
+ wordCount: (project_id, user_id, file, callback = (error) ->) ->
CompileManager.getProjectCompileLimits project_id, (error, limits) ->
return callback(error) if error?
- ClsiManager.wordCount project_id, file, limits, callback
+ ClsiManager.wordCount project_id, user_id, file, limits, callback
diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
index e14b9e4582..4bcf0c671d 100644
--- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee
+++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
@@ -3,6 +3,8 @@ PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
settings = require("settings-sharelatex")
+
+
templates = {}
templates.registered =
@@ -114,6 +116,8 @@ module.exports =
template = templates[templateName]
opts.siteUrl = settings.siteUrl
opts.body = template.compiledTemplate(opts)
+ if settings.email?.templates?.customFooter?
+ opts.body += settings.email?.templates?.customFooter
return {
subject : template.subject(opts)
html: template.layout(opts)
diff --git a/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
index 314142b3af..9f960b1d15 100644
--- a/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
+++ b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
@@ -1,15 +1,15 @@
-
+logger = require("logger-sharelatex")
NotificationsHandler = require("./NotificationsHandler")
module.exports =
groupPlan: (user, licence)->
key : "join-sub-#{licence.subscription_id}"
-
create: (callback = ->)->
messageOpts =
groupName: licence.name
subscription_id: licence.subscription_id
+ logger.log user_id:user._id, key:key, "creating notification key for user"
NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
read: (callback = ->)->
diff --git a/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
index a26bfff16a..a7cf6a4672 100644
--- a/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
+++ b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
@@ -3,14 +3,22 @@ request = require("request")
logger = require("logger-sharelatex")
oneSecond = 1000
+
+makeRequest = (opts, callback)->
+ if !settings.apis.notifications?.url?
+ return callback(null, statusCode:200)
+ else
+ request(opts, callback)
+
module.exports =
getUserNotifications: (user_id, callback)->
opts =
- uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+ uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
json: true
timeout: oneSecond
- request.get opts, (err, res, unreadNotifications)->
+ method: "GET"
+ makeRequest opts, (err, res, unreadNotifications)->
statusCode = if res? then res.statusCode else 500
if err? or statusCode != 200
e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
@@ -23,30 +31,33 @@ module.exports =
createNotification: (user_id, key, templateKey, messageOpts, callback)->
opts =
- uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+ uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
timeout: oneSecond
+ method:"POST"
json: {
key:key
messageOpts:messageOpts
templateKey:templateKey
}
logger.log opts:opts, "creating notification for user"
- request.post opts, callback
+ makeRequest opts, callback
markAsReadWithKey: (user_id, key, callback)->
opts =
- uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+ uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
+ method: "DELETE"
timeout: oneSecond
json: {
key:key
}
logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
- request.del opts, callback
+ makeRequest opts, callback
markAsRead: (user_id, notification_id, callback)->
opts =
- uri: "#{settings.apis.notifications.url}/user/#{user_id}/notification/#{notification_id}"
+ method: "DELETE"
+ uri: "#{settings.apis.notifications?.url}/user/#{user_id}/notification/#{notification_id}"
timeout:oneSecond
logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
- request.del opts, callback
+ makeRequest opts, callback
diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
index fb0f75beee..ec5371f0f2 100644
--- a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
+++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
@@ -2,6 +2,7 @@ PasswordResetHandler = require("./PasswordResetHandler")
RateLimiter = require("../../infrastructure/RateLimiter")
AuthenticationController = require("../Authentication/AuthenticationController")
UserGetter = require("../User/UserGetter")
+UserSessionsManager = require("../User/UserSessionsManager")
logger = require "logger-sharelatex"
module.exports =
@@ -47,11 +48,13 @@ module.exports =
PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
return next(err) if err?
if found
- if req.body.login_after
- UserGetter.getUser user_id, {email: 1}, (err, user) ->
- return next(err) if err?
- AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
- else
- res.sendStatus 200
+ UserSessionsManager.revokeAllUserSessions {_id: user_id}, [], (err) ->
+ return next(err) if err?
+ if req.body.login_after
+ UserGetter.getUser user_id, {email: 1}, (err, user) ->
+ return next(err) if err?
+ AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+ else
+ res.sendStatus 200
else
res.sendStatus 404
diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee
index f810067991..d273516056 100644
--- a/services/web/app/coffee/Features/Project/ProjectController.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectController.coffee
@@ -245,11 +245,13 @@ module.exports = ProjectController =
first_name : user.first_name
last_name : user.last_name
referal_id : user.referal_id
+ signUpDate : user.signUpDate
subscription :
freeTrial: {allowed: allowedFreeTrial}
featureSwitches: user.featureSwitches
features: user.features
refProviders: user.refProviders
+ betaProgram: user.betaProgram
}
userSettings: {
mode : user.ace.mode
diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee
index 8ba8a65845..f398ea75b5 100644
--- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee
@@ -24,9 +24,11 @@ module.exports = ProjectDeleter =
update = {deletedByExternalDataSource: false}
Project.update conditions, update, {}, callback
- deleteUsersProjects: (owner_id, callback)->
- logger.log owner_id:owner_id, "deleting users projects"
- Project.remove owner_ref:owner_id, callback
+ deleteUsersProjects: (user_id, callback)->
+ logger.log {user_id}, "deleting users projects"
+ Project.remove owner_ref:user_id, (error) ->
+ return callback(error) if error?
+ CollaboratorsHandler.removeUserFromAllProjets user_id, callback
deleteProject: (project_id, callback = (error) ->) ->
# archiveProject takes care of the clean-up
diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
index 946a57d527..c68f732f16 100644
--- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
@@ -512,6 +512,11 @@ module.exports = ProjectEntityHandler =
return callback(e)
type = sanitizeTypeOfElement type
+ if path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
+ e = new Error("invalid element name")
+ logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as name was invalid"
+ return callback(e)
+
if !folder_id?
folder_id = project.rootFolder[0]._id
ProjectEntityHandler._countElements project, (err, count)->
diff --git a/services/web/app/coffee/Features/References/ReferencesController.coffee b/services/web/app/coffee/Features/References/ReferencesController.coffee
index 0536e3b680..305c4715b5 100644
--- a/services/web/app/coffee/Features/References/ReferencesController.coffee
+++ b/services/web/app/coffee/Features/References/ReferencesController.coffee
@@ -15,7 +15,7 @@ module.exports = ReferencesController =
return res.sendStatus 400
logger.log {projectId, docIds: docIds}, "index references for project"
ReferencesHandler.index projectId, docIds, (err, data) ->
- if err
+ if err?
logger.err {err, projectId}, "error indexing all references"
return res.sendStatus 500
ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
@@ -25,12 +25,14 @@ module.exports = ReferencesController =
shouldBroadcast = req.body.shouldBroadcast
logger.log {projectId}, "index all references for project"
ReferencesHandler.indexAll projectId, (err, data) ->
- if err
+ if err?
logger.err {err, projectId}, "error indexing all references"
return res.sendStatus 500
ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
_handleIndexResponse: (req, res, projectId, shouldBroadcast, data) ->
+ if !data? or !data.keys?
+ return res.json({projectId, keys: []})
if shouldBroadcast
logger.log {projectId}, "emitting new references keys to connected clients"
EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys
diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee
index 7894ab6915..fa38ddedf6 100644
--- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee
+++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee
@@ -66,6 +66,8 @@ module.exports = ReferencesHandler =
ReferencesHandler._doIndexOperation(projectId, project, docIds, [], callback)
_doIndexOperation: (projectId, project, docIds, fileIds, callback) ->
+ if !settings.apis?.references?.url?
+ return callback()
ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
if err
logger.err {err, projectId}, "error checking whether to do full index"
diff --git a/services/web/app/coffee/Features/StaticPages/UniversityController.coffee b/services/web/app/coffee/Features/StaticPages/UniversityController.coffee
index 9b55b60077..e607c3a020 100644
--- a/services/web/app/coffee/Features/StaticPages/UniversityController.coffee
+++ b/services/web/app/coffee/Features/StaticPages/UniversityController.coffee
@@ -5,6 +5,12 @@ _ = require("underscore")
ErrorController = require "../Errors/ErrorController"
StaticPageHelpers = require("./StaticPageHelpers")
sanitize = require('sanitizer')
+Settings = require("settings-sharelatex")
+contentful = require('contentful')
+marked = require("marked")
+
+
+
module.exports = UniversityController =
@@ -17,7 +23,7 @@ module.exports = UniversityController =
logger.log url:url, "proxying request to university api"
request.get universityUrl, (err, r, data)->
if r?.statusCode == 404
- return ErrorController.notFound(req, res, next)
+ return UniversityController.getContentfulPage(req, res, next)
if err?
return res.send 500
data = data.trim()
@@ -37,4 +43,28 @@ module.exports = UniversityController =
upstream = request.get(originUrl)
upstream.on "error", (error) ->
logger.error err: error, "university proxy error"
- upstream.pipe res
\ No newline at end of file
+ upstream.pipe res
+
+ getContentfulPage: (req, res, next)->
+ console.log Settings.contentful
+ if !Settings.contentful?.uni?.space? and !Settings.contentful?.uni?.accessToken?
+ return ErrorController.notFound(req, res, next)
+
+ client = contentful.createClient({
+ space: Settings.contentful?.uni?.space
+ accessToken: Settings.contentful?.uni?.accessToken
+ })
+
+ url = req.url?.toLowerCase().replace("/university/","")
+ client.getEntries({content_type: 'caseStudy', 'fields.slug':url})
+ .catch (e)->
+ return res.send 500
+ .then (entry)->
+ if !entry? or !entry.items? or entry.items.length == 0
+ return ErrorController.notFound(req, res, next)
+ viewData = entry.items[0].fields
+ viewData.html = marked(viewData.content)
+ res.render "university/case_study", viewData:viewData
+
+
+
diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
index e2f633e185..6c402220a9 100644
--- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
+++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
@@ -38,12 +38,12 @@ module.exports =
SubscriptionLocator.getUsersSubscription user._id, (err, subscription)->
if err?
return callback(err)
- hasValidSubscription = subscription? and subscription.recurlySubscription_id?
+ hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true)
logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription"
callback err, hasValidSubscription, subscription
userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) ->
- logger.log user_id: user._ud, "checking is user is member of subscription groups"
+ logger.log user_id: user._id, "checking is user is member of subscription groups"
SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) ->
return callback(err) if err?
callback err, subscriptions.length > 0, subscriptions
diff --git a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
index 680905ca6c..059c5fb02c 100644
--- a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
+++ b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
@@ -4,11 +4,201 @@ request = require 'request'
Settings = require "settings-sharelatex"
xml2js = require "xml2js"
logger = require("logger-sharelatex")
+Async = require('async')
module.exports = RecurlyWrapper =
apiUrl : "https://api.recurly.com/v2"
- createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
+ _addressToXml: (address) ->
+ allowedKeys = ['address1', 'address2', 'city', 'country', 'state', 'zip', 'postal_code']
+ resultString = "\n"
+ for k, v of address
+ if k == 'postal_code'
+ k = 'zip'
+ if v and (k in allowedKeys)
+ resultString += "<#{k}#{if k == 'address2' then ' nil="nil"' else ''}>#{v || ''}#{k}>\n"
+ resultString += "\n"
+ return resultString
+
+ _paypal:
+ checkAccountExists: (cache, next) ->
+ user = cache.user
+ recurly_token_id = cache.recurly_token_id
+ subscriptionDetails = cache.subscriptionDetails
+ logger.log {user_id: user._id, recurly_token_id}, "checking if recurly account exists for user"
+ RecurlyWrapper.apiRequest({
+ url: "accounts/#{user._id}"
+ method: "GET"
+ }, (error, response, responseBody) ->
+ if error
+ if response.statusCode == 404 # actually not an error in this case, just no existing account
+ cache.userExists = false
+ return next(null, cache)
+ logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account"
+ return next(error)
+ logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly"
+ RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
+ if err
+ logger.error {err, user_id: user._id, recurly_token_id}, "error parsing account"
+ return next(err)
+ cache.userExists = true
+ cache.account = account
+ return next(null, cache)
+ )
+ createAccount: (cache, next) ->
+ user = cache.user
+ recurly_token_id = cache.recurly_token_id
+ subscriptionDetails = cache.subscriptionDetails
+ address = subscriptionDetails.address
+ if !address
+ return next(new Error('no address in subscriptionDetails at createAccount stage'))
+ if cache.userExists
+ logger.log {user_id: user._id, recurly_token_id}, "user already exists in recurly"
+ return next(null, cache)
+ logger.log {user_id: user._id, recurly_token_id}, "creating user in recurly"
+ requestBody = """
+
+ #{user._id}
+ #{user.email}
+ #{user.first_name}
+ #{user.last_name}
+
+ #{address.address1}
+ #{address.address2}
+ #{address.city || ''}
+ #{address.state || ''}
+ #{address.zip || ''}
+ #{address.country}
+
+
+ """
+ RecurlyWrapper.apiRequest({
+ url : "accounts"
+ method : "POST"
+ body : requestBody
+ }, (error, response, responseBody) =>
+ if error
+ logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating account"
+ return next(error)
+ RecurlyWrapper._parseAccountXml responseBody, (err, account) ->
+ if err
+ logger.error {err, user_id: user._id, recurly_token_id}, "error creating account"
+ return next(err)
+ cache.account = account
+ return next(null, cache)
+ )
+ createBillingInfo: (cache, next) ->
+ user = cache.user
+ recurly_token_id = cache.recurly_token_id
+ subscriptionDetails = cache.subscriptionDetails
+ logger.log {user_id: user._id, recurly_token_id}, "creating billing info in recurly"
+ accountCode = cache?.account?.account_code
+ if !accountCode
+ return next(new Error('no account code at createBillingInfo stage'))
+ requestBody = """
+
+ #{recurly_token_id}
+
+ """
+ RecurlyWrapper.apiRequest({
+ url: "accounts/#{accountCode}/billing_info"
+ method: "POST"
+ body: requestBody
+ }, (error, response, responseBody) =>
+ if error
+ logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating billing info"
+ return next(error)
+ RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
+ if err
+ logger.error {err, user_id: user._id, accountCode, recurly_token_id}, "error creating billing info"
+ return next(err)
+ cache.billingInfo = billingInfo
+ return next(null, cache)
+ )
+
+ setAddress: (cache, next) ->
+ user = cache.user
+ recurly_token_id = cache.recurly_token_id
+ subscriptionDetails = cache.subscriptionDetails
+ logger.log {user_id: user._id, recurly_token_id}, "setting billing address in recurly"
+ accountCode = cache?.account?.account_code
+ if !accountCode
+ return next(new Error('no account code at setAddress stage'))
+ address = subscriptionDetails.address
+ if !address
+ return next(new Error('no address in subscriptionDetails at setAddress stage'))
+ requestBody = RecurlyWrapper._addressToXml(address)
+ RecurlyWrapper.apiRequest({
+ url: "accounts/#{accountCode}/billing_info"
+ method: "PUT"
+ body: requestBody
+ }, (error, response, responseBody) =>
+ if error
+ logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while setting address"
+ return next(error)
+ RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) ->
+ if err
+ logger.error {err, user_id: user._id, recurly_token_id}, "error updating billing info"
+ return next(err)
+ cache.billingInfo = billingInfo
+ return next(null, cache)
+ )
+ createSubscription: (cache, next) ->
+ user = cache.user
+ recurly_token_id = cache.recurly_token_id
+ subscriptionDetails = cache.subscriptionDetails
+ logger.log {user_id: user._id, recurly_token_id}, "creating subscription in recurly"
+ requestBody = """
+
+ #{subscriptionDetails.plan_code}
+ #{subscriptionDetails.currencyCode}
+ #{subscriptionDetails.coupon_code}
+
+ #{user._id}
+
+
+ """ # TODO: check account details and billing
+ RecurlyWrapper.apiRequest({
+ url : "subscriptions"
+ method : "POST"
+ body : requestBody
+ }, (error, response, responseBody) =>
+ if error
+ logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating subscription"
+ return next(error)
+ RecurlyWrapper._parseSubscriptionXml responseBody, (err, subscription) ->
+ if err
+ logger.error {err, user_id: user._id, recurly_token_id}, "error creating subscription"
+ return next(err)
+ cache.subscription = subscription
+ return next(null, cache)
+ )
+
+ _createPaypalSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
+ logger.log {user_id: user._id, recurly_token_id}, "starting process of creating paypal subscription"
+ # We use `async.waterfall` to run each of these actions in sequence
+ # passing a `cache` object along the way. The cache is initialized
+ # with required data, and `async.apply` to pass the cache to the first function
+ cache = {user, recurly_token_id, subscriptionDetails}
+ Async.waterfall([
+ Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache),
+ RecurlyWrapper._paypal.createAccount,
+ RecurlyWrapper._paypal.createBillingInfo,
+ RecurlyWrapper._paypal.setAddress,
+ RecurlyWrapper._paypal.createSubscription,
+ ], (err, result) ->
+ if err
+ logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
+ return callback(err)
+ if !result.subscription
+ err = new Error('no subscription object in result')
+ logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process"
+ return callback(err)
+ logger.log {user_id: user._id, recurly_token_id}, "done creating paypal subscription for user"
+ callback(null, result.subscription)
+ )
+
+ _createCreditCardSubscription: (user, subscriptionDetails, recurly_token_id, callback) ->
requestBody = """
#{subscriptionDetails.plan_code}
@@ -25,17 +215,23 @@ module.exports = RecurlyWrapper =
"""
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url : "subscriptions"
method : "POST"
body : requestBody
}, (error, response, responseBody) =>
return callback(error) if error?
- @_parseSubscriptionXml responseBody, callback
- )
+ RecurlyWrapper._parseSubscriptionXml responseBody, callback
+ )
+
+ createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
+ isPaypal = subscriptionDetails.isPaypal
+ logger.log {user_id: user._id, isPaypal, recurly_token_id}, "setting up subscription in recurly"
+ fn = if isPaypal then RecurlyWrapper._createPaypalSubscription else RecurlyWrapper._createCreditCardSubscription
+ fn user, subscriptionDetails, recurly_token_id, callback
apiRequest : (options, callback) ->
- options.url = @apiUrl + "/" + options.url
+ options.url = RecurlyWrapper.apiUrl + "/" + options.url
options.headers =
"Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64")
"Accept" : "application/xml"
@@ -60,7 +256,7 @@ module.exports = RecurlyWrapper =
newAttributes[key] = value
else
newAttributes[newKey] = value
-
+
return newAttributes
crypto.randomBytes 32, (error, buffer) ->
@@ -74,14 +270,14 @@ module.exports = RecurlyWrapper =
signature = "#{signed}|#{unsignedQuery}"
callback null, signature
-
+
getSubscriptions: (accountId, callback)->
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}/subscriptions"
}, (error, response, body) =>
return callback(error) if error?
- @_parseXml body, callback
+ RecurlyWrapper._parseXml body, callback
)
@@ -94,11 +290,11 @@ module.exports = RecurlyWrapper =
else
url = "subscriptions/#{subscriptionId}"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: url
}, (error, response, body) =>
return callback(error) if error?
- @_parseSubscriptionXml body, (error, recurlySubscription) =>
+ RecurlyWrapper._parseSubscriptionXml body, (error, recurlySubscription) =>
return callback(error) if error?
if options.includeAccount
if recurlySubscription.account? and recurlySubscription.account.url?
@@ -106,7 +302,7 @@ module.exports = RecurlyWrapper =
else
return callback "I don't understand the response from Recurly"
- @getAccount accountId, (error, account) ->
+ RecurlyWrapper.getAccount accountId, (error, account) ->
return callback(error) if error?
recurlySubscription.account = account
callback null, recurlySubscription
@@ -124,9 +320,9 @@ module.exports = RecurlyWrapper =
per_page:200
if cursor?
opts.qs.cursor = cursor
- @apiRequest opts, (error, response, body) =>
+ RecurlyWrapper.apiRequest opts, (error, response, body) =>
return callback(error) if error?
- @_parseXml body, (err, data)->
+ RecurlyWrapper._parseXml body, (err, data)->
if err?
logger.err err:err, "could not get accoutns"
callback(err)
@@ -142,19 +338,19 @@ module.exports = RecurlyWrapper =
getAccount: (accountId, callback) ->
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}"
}, (error, response, body) =>
return callback(error) if error?
- @_parseAccountXml body, callback
+ RecurlyWrapper._parseAccountXml body, callback
)
getBillingInfo: (accountId, callback)->
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "accounts/#{accountId}/billing_info"
}, (error, response, body) =>
return callback(error) if error?
- @_parseXml body, callback
+ RecurlyWrapper._parseXml body, callback
)
@@ -166,13 +362,13 @@ module.exports = RecurlyWrapper =
#{options.timeframe}
"""
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url : "subscriptions/#{subscriptionId}"
method : "put"
body : requestBody
}, (error, response, responseBody) =>
return callback(error) if error?
- @_parseSubscriptionXml responseBody, callback
+ RecurlyWrapper._parseSubscriptionXml responseBody, callback
)
createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)->
@@ -191,7 +387,7 @@ module.exports = RecurlyWrapper =
"""
logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url : "coupons"
method : "post"
body : requestBody
@@ -203,16 +399,16 @@ module.exports = RecurlyWrapper =
lookupCoupon: (coupon_code, callback)->
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "coupons/#{coupon_code}"
}, (error, response, body) =>
return callback(error) if error?
- @_parseXml body, callback
+ RecurlyWrapper._parseXml body, callback
)
cancelSubscription: (subscriptionId, callback) ->
logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "subscriptions/#{subscriptionId}/cancel",
method: "put"
}, (error, response, body) ->
@@ -221,13 +417,13 @@ module.exports = RecurlyWrapper =
reactivateSubscription: (subscriptionId, callback) ->
logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url: "subscriptions/#{subscriptionId}/reactivate",
method: "put"
}, (error, response, body) ->
callback(error)
)
-
+
redeemCoupon: (account_code, coupon_code, callback)->
requestBody = """
@@ -237,7 +433,7 @@ module.exports = RecurlyWrapper =
"""
logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url : "coupons/#{coupon_code}/redeem"
method : "post"
body : requestBody
@@ -251,7 +447,7 @@ module.exports = RecurlyWrapper =
next_renewal_date = new Date()
next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire)
logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user"
- @apiRequest({
+ RecurlyWrapper.apiRequest({
url : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false"
method : "put"
}, (error, response, responseBody) =>
@@ -261,7 +457,7 @@ module.exports = RecurlyWrapper =
)
_parseSubscriptionXml: (xml, callback) ->
- @_parseXml xml, (error, data) ->
+ RecurlyWrapper._parseXml xml, (error, data) ->
return callback(error) if error?
if data? and data.subscription?
recurlySubscription = data.subscription
@@ -270,7 +466,7 @@ module.exports = RecurlyWrapper =
callback null, recurlySubscription
_parseAccountXml: (xml, callback) ->
- @_parseXml xml, (error, data) ->
+ RecurlyWrapper._parseXml xml, (error, data) ->
return callback(error) if error?
if data? and data.account?
account = data.account
@@ -278,6 +474,15 @@ module.exports = RecurlyWrapper =
return callback "I don't understand the response from Recurly"
callback null, account
+ _parseBillingInfoXml: (xml, callback) ->
+ RecurlyWrapper._parseXml xml, (error, data) ->
+ return callback(error) if error?
+ if data? and data.billing_info?
+ billingInfo = data.billing_info
+ else
+ return callback "I don't understand the response from Recurly"
+ callback null, billingInfo
+
_parseXml: (xml, callback) ->
convertDataTypes = (data) ->
if data? and data["$"]?
@@ -299,7 +504,7 @@ module.exports = RecurlyWrapper =
else
array.push(convertDataTypes(value))
data = array
-
+
if data instanceof Array
data = (convertDataTypes(entry) for entry in data)
else if typeof data == "object"
@@ -315,6 +520,3 @@ module.exports = RecurlyWrapper =
return callback(error) if error?
result = convertDataTypes(data)
callback null, result
-
-
-
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
index 2a1e576231..ca932ffab3 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
@@ -82,9 +82,9 @@ module.exports = SubscriptionController =
logger.log user: user, "redirecting to plans"
res.redirect "/user/subscription/plans"
else
- SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
+ SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
return next(error) if error?
- logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groups:groups, "showing subscription dashboard"
+ logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
plans = SubscriptionViewModelBuilder.buildViewModel()
res.render "subscriptions/dashboard",
title: "your_subscription"
@@ -92,8 +92,9 @@ module.exports = SubscriptionController =
taxRate:subscription?.taxRate
plans: plans
subscription: subscription || {}
- groups: groups
+ groupSubscriptions: groupSubscriptions
subscriptionTabActive: true
+ user:user
userCustomSubscriptionPage: (req, res, next)->
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
index bdb9c41fde..e239a779b9 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
@@ -8,7 +8,7 @@ _ = require("underscore")
module.exports =
- buildUsersSubscriptionViewModel: (user, callback = (error, subscription, groups) ->) ->
+ buildUsersSubscriptionViewModel: (user, callback = (error, subscription, memberSubscriptions) ->) ->
SubscriptionLocator.getUsersSubscription user, (err, subscription) ->
return callback(err) if err?
SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) ->
@@ -19,6 +19,7 @@ module.exports =
RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
tax = recurlySubscription?.tax_in_cents || 0
callback null, {
+ admin_id:subscription.admin_id
name: plan.name
nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
state: recurlySubscription?.state
diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee
index 451729d7e9..546cea035e 100644
--- a/services/web/app/coffee/Features/User/UserController.coffee
+++ b/services/web/app/coffee/Features/User/UserController.coffee
@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
metrics = require("../../infrastructure/Metrics")
Url = require("url")
AuthenticationManager = require("../Authentication/AuthenticationManager")
+UserSessionsManager = require("./UserSessionsManager")
UserUpdater = require("./UserUpdater")
settings = require "settings-sharelatex"
@@ -72,6 +73,7 @@ module.exports = UserController =
if err?
logger.err err:err, user_id:user_id, "error getting user for email update"
return res.send 500
+ req.session.user.email = user.email
UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
if err?
logger.err err:err, "error populateGroupLicenceInvite"
@@ -80,9 +82,12 @@ module.exports = UserController =
logout : (req, res)->
metrics.inc "user.logout"
logger.log user: req?.session?.user, "logging out"
+ sessionId = req.sessionID
+ user = req?.session?.user
req.session.destroy (err)->
if err
logger.err err: err, 'error destorying session'
+ UserSessionsManager.untrackSession(user, sessionId)
res.redirect '/login'
register : (req, res, next = (error) ->)->
@@ -116,17 +121,15 @@ module.exports = UserController =
logger.log user: user, "password changed"
AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
return next(error) if error?
- res.send
- message:
- type:'success'
- text:'Your password has been changed'
+ UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+ return next(err) if err?
+ res.send
+ message:
+ type:'success'
+ text:'Your password has been changed'
else
logger.log user: user, "current password wrong"
res.send
message:
type:'error'
text:'Your old password is wrong'
-
- changeEmailAddress: (req, res)->
-
-
diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee
index e9a05c46f2..ee5a426e78 100644
--- a/services/web/app/coffee/Features/User/UserCreator.coffee
+++ b/services/web/app/coffee/Features/User/UserCreator.coffee
@@ -1,5 +1,6 @@
User = require("../../models/User").User
UserLocator = require("./UserLocator")
+logger = require("logger-sharelatex")
module.exports =
@@ -12,16 +13,23 @@ module.exports =
self.createNewUser email:email, holdingAccount:true, callback
createNewUser: (opts, callback)->
+ logger.log opts:opts, "creating new user"
user = new User()
user.email = opts.email
user.holdingAccount = opts.holdingAccount
username = opts.email.match(/^[^@]*/)
- if username?
+ if opts.first_name? and opts.first_name.length != 0
+ user.first_name = opts.first_name
+ else if username?
user.first_name = username[0]
else
user.first_name = ""
- user.last_name = ""
+
+ if opts.last_name?
+ user.last_name = opts.last_name
+ else
+ user.last_name = ""
user.featureSwitches?.pdfng = true
diff --git a/services/web/app/coffee/Features/User/UserHandler.coffee b/services/web/app/coffee/Features/User/UserHandler.coffee
index 8af78573d6..602858eac3 100644
--- a/services/web/app/coffee/Features/User/UserHandler.coffee
+++ b/services/web/app/coffee/Features/User/UserHandler.coffee
@@ -6,7 +6,7 @@ logger = require("logger-sharelatex")
module.exports = UserHandler =
- populateGroupLicenceInvite: (user, callback)->
+ populateGroupLicenceInvite: (user, callback = ->)->
logger.log user_id:user._id, "populating any potential group licence invites"
licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
if !licence?
diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee
index 5a967605fe..567cacd35c 100644
--- a/services/web/app/coffee/Features/User/UserPagesController.coffee
+++ b/services/web/app/coffee/Features/User/UserPagesController.coffee
@@ -26,6 +26,7 @@ module.exports =
activateAccountPage: (req, res) ->
# An 'activation' is actually just a password reset on an account that
# was set with a random password originally.
+ logger.log query:req.query, "activiate account page called"
if !req.query?.user_id? or !req.query?.token?
return ErrorController.notFound(req, res)
@@ -34,6 +35,7 @@ module.exports =
if !user
return ErrorController.notFound(req, res)
if user.loginCount > 0
+ logger.log user:user, "user has already logged in so is active, sending them to /login"
# Already seen this user, so account must be activate
# This lets users keep clicking the 'activate' link in their email
# as a way to log in which, if I know our users, they will.
diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
index f407091122..75a9debba4 100644
--- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
+++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
@@ -35,7 +35,8 @@ module.exports = UserRegistrationHandler =
_createNewUserIfRequired: (user, userDetails, callback)->
if !user?
- UserCreator.createNewUser {holdingAccount:false, email:userDetails.email}, callback
+ userDetails.holdingAccount = false
+ UserCreator.createNewUser {holdingAccount:false, email:userDetails.email, first_name:userDetails.first_name, last_name:userDetails.last_name}, callback
else
callback null, user
diff --git a/services/web/app/coffee/Features/User/UserSessionsManager.coffee b/services/web/app/coffee/Features/User/UserSessionsManager.coffee
new file mode 100644
index 0000000000..95974ec59a
--- /dev/null
+++ b/services/web/app/coffee/Features/User/UserSessionsManager.coffee
@@ -0,0 +1,126 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+_ = require('underscore')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports = UserSessionsManager =
+
+ _sessionSetKey: (user) ->
+ return "UserSessions:#{user._id}"
+
+ # mimic the key used by the express sessions
+ _sessionKey: (sessionId) ->
+ return "sess:#{sessionId}"
+
+ trackSession: (user, sessionId, callback=(err)-> ) ->
+ if !user?
+ logger.log {sessionId}, "no user to track, returning"
+ return callback(null)
+ if !sessionId?
+ logger.log {user_id: user._id}, "no sessionId to track, returning"
+ return callback(null)
+ logger.log {user_id: user._id, sessionId}, "onLogin handler"
+ sessionSetKey = UserSessionsManager._sessionSetKey(user)
+ value = UserSessionsManager._sessionKey sessionId
+ rclient.multi()
+ .sadd(sessionSetKey, value)
+ .expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+ .exec (err, response) ->
+ if err?
+ logger.err {err, user_id: user._id, sessionSetKey}, "error while adding session key to UserSessions set"
+ return callback(err)
+ UserSessionsManager._checkSessions(user, () ->)
+ callback()
+
+ untrackSession: (user, sessionId, callback=(err)-> ) ->
+ if !user?
+ logger.log {sessionId}, "no user to untrack, returning"
+ return callback(null)
+ if !sessionId?
+ logger.log {user_id: user._id}, "no sessionId to untrack, returning"
+ return callback(null)
+ logger.log {user_id: user._id, sessionId}, "onLogout handler"
+ sessionSetKey = UserSessionsManager._sessionSetKey(user)
+ value = UserSessionsManager._sessionKey sessionId
+ rclient.multi()
+ .srem(sessionSetKey, value)
+ .expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+ .exec (err, response) ->
+ if err?
+ logger.err {err, user_id: user._id, sessionSetKey}, "error while removing session key from UserSessions set"
+ return callback(err)
+ UserSessionsManager._checkSessions(user, () ->)
+ callback()
+
+ revokeAllUserSessions: (user, retain, callback=(err)->) ->
+ if !retain
+ retain = []
+ retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
+ if !user
+ logger.log {}, "no user to revoke sessions for, returning"
+ return callback(null)
+ logger.log {user_id: user._id}, "revoking all existing sessions for user"
+ sessionSetKey = UserSessionsManager._sessionSetKey(user)
+ rclient.smembers sessionSetKey, (err, sessionKeys) ->
+ if err?
+ logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+ return callback(err)
+ keysToDelete = _.filter(sessionKeys, (k) -> k not in retain)
+ if keysToDelete.length == 0
+ logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
+ return callback(null)
+ logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
+ rclient.multi()
+ .del(keysToDelete)
+ .srem(sessionSetKey, keysToDelete)
+ .exec (err, result) ->
+ if err?
+ logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+ return callback(err)
+ callback(null)
+
+ touch: (user, callback=(err)->) ->
+ if !user
+ logger.log {}, "no user to touch sessions for, returning"
+ return callback(null)
+ sessionSetKey = UserSessionsManager._sessionSetKey(user)
+ rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
+ if err?
+ logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
+ return callback(err)
+ callback(null)
+
+ _checkSessions: (user, callback=(err)->) ->
+ if !user
+ logger.log {}, "no user, returning"
+ return callback(null)
+ logger.log {user_id: user._id}, "checking sessions for user"
+ sessionSetKey = UserSessionsManager._sessionSetKey(user)
+ rclient.smembers sessionSetKey, (err, sessionKeys) ->
+ if err?
+ logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+ return callback(err)
+ logger.log {user_id: user._id, count: sessionKeys.length}, "checking sessions for user"
+ Async.series(
+ sessionKeys.map(
+ (key) ->
+ (next) ->
+ rclient.get key, (err, val) ->
+ if err?
+ return next(err)
+ if !val?
+ logger.log {user_id: user._id, key}, ">> removing key from UserSessions set"
+ rclient.srem sessionSetKey, key, (err, result) ->
+ if err?
+ return next(err)
+ return next(null)
+ else
+ next()
+ )
+ , (err, results) ->
+ logger.log {user_id: user._id}, "done checking sessions for user"
+ return callback(err)
+ )
diff --git a/services/web/app/coffee/Features/Wiki/WikiController.coffee b/services/web/app/coffee/Features/Wiki/WikiController.coffee
deleted file mode 100644
index c75ee06f86..0000000000
--- a/services/web/app/coffee/Features/Wiki/WikiController.coffee
+++ /dev/null
@@ -1,81 +0,0 @@
-request = require("request")
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-ErrorController = require "../Errors/ErrorController"
-_ = require("underscore")
-AuthenticationController = require("../Authentication/AuthenticationController")
-async = require("async")
-other_lngs = ["es"]
-
-module.exports = WikiController =
-
-
- _checkIfLoginIsNeeded: (req, res, next)->
- if settings.apis.wiki.requireLogin
- AuthenticationController.requireLogin()(req, res, next)
- else
- next()
-
- getPage: (req, res, next) ->
- WikiController._checkIfLoginIsNeeded req, res, ->
-
- page = req.url.replace(/^\/learn/, "").replace(/^\//, "")
- if page == ""
- page = "Main_Page"
-
- logger.log page: page, "getting page from wiki"
- if _.include(other_lngs, req.lng)
- lngPage = "#{page}_#{req.lng}"
- else
- lngPage = page
- jobs =
- contents: (cb)->
- WikiController._getPageContent "Contents", cb
- pageData: (cb)->
- WikiController._getPageContent lngPage, cb
- async.parallel jobs, (error, results)->
- return next(error) if error?
- {pageData, contents} = results
- if pageData.content?.length > 280
- if _.include(other_lngs, req.lng)
- pageData.title = pageData.title.slice(0, pageData.title.length - (req.lng.length+1) )
- WikiController._renderPage(pageData, contents, res)
- else
- WikiController._getPageContent page, (error, pageData) ->
- return next(error) if error?
- WikiController._renderPage(pageData, contents, res)
-
-
-
-
- _getPageContent: (page, callback = (error, data = { content: "", title: "" }) ->) ->
- request {
- url: "#{settings.apis.wiki.url}/learn-scripts/api.php"
- qs: {
- page: decodeURI(page)
- action: "parse"
- format: "json"
- }
- }, (err, response, data)->
- return callback(err) if err?
- try
- data = JSON.parse(data)
- catch err
- logger.err err:err, data:data, "error parsing data from wiki"
- result =
- content: data?.parse?.text?['*']
- title: data?.parse?.title
- callback null, result
-
-
- _renderPage: (page, contents, res)->
- if page.title == "Main Page"
- title = "Documentation"
- else
- title = page.title
-
- res.render "wiki/page", {
- page: page
- contents: contents
- title: title
- }
\ No newline at end of file
diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee
index faba1e4623..ee7418dacc 100644
--- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee
+++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee
@@ -7,15 +7,19 @@ querystring = require('querystring')
SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
_ = require("underscore")
Modules = require "./Modules"
+Url = require "url"
fingerprints = {}
Path = require 'path'
+
+
jsPath =
if Settings.useMinifiedJs
"/minjs/"
else
"/js/"
+
logger.log "Generating file fingerprints..."
for path in [
"#{jsPath}libs/require.js",
@@ -37,7 +41,18 @@ for path in [
fingerprints[path] = hash
else
logger.log filePath:filePath, "file does not exist for fingerprints"
-
+
+getFingerprint = (path) ->
+ if fingerprints[path]?
+ return fingerprints[path]
+ else
+ logger.err "No fingerprint for file: #{path}"
+ return ""
+
+logger.log "Finished generating file fingerprints"
+
+cdnAvailable = Settings.cdn?.web?.host?
+darkCdnAvailable = Settings.cdn?.web?.darkHost?
module.exports = (app, webRouter, apiRouter)->
webRouter.use (req, res, next)->
@@ -45,9 +60,55 @@ module.exports = (app, webRouter, apiRouter)->
next()
webRouter.use (req, res, next)->
+
+ isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
+ isSmoke = req.headers?.host?.slice(0,5)?.toLowerCase() == "smoke"
+ isLive = !isDark and !isSmoke
+
+ if cdnAvailable and isLive
+ staticFilesBase = Settings.cdn?.web?.host
+ else if darkCdnAvailable and isDark
+ staticFilesBase = Settings.cdn?.web?.darkHost
+ else
+ staticFilesBase = ""
+
res.locals.jsPath = jsPath
+ res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
+
+
+ res.locals.buildJsPath = (jsFile, opts = {})->
+ path = Path.join(jsPath, jsFile)
+
+ doFingerPrint = opts.fingerprint != false
+
+ if !opts.qs?
+ opts.qs = {}
+
+ if !opts.qs?.fingerprint? and doFingerPrint
+ opts.qs.fingerprint = getFingerprint(path)
+
+ if opts.cdn != false
+ path = Url.resolve(staticFilesBase, path)
+
+ qs = querystring.stringify(opts.qs)
+
+ if qs? and qs.length > 0
+ path = path + "?" + qs
+ return path
+
+
+ res.locals.buildCssPath = (cssFile)->
+ path = Path.join("/stylesheets/", cssFile)
+ return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
+
+ res.locals.buildImgPath = (imgFile)->
+ path = Path.join("/img/", imgFile)
+ return Url.resolve(staticFilesBase, path)
+
next()
+
+
webRouter.use (req, res, next)->
res.locals.settings = Settings
next()
@@ -113,12 +174,7 @@ module.exports = (app, webRouter, apiRouter)->
next()
webRouter.use (req, res, next)->
- res.locals.fingerprint = (path) ->
- if fingerprints[path]?
- return fingerprints[path]
- else
- logger.err "No fingerprint for file: #{path}"
- return ""
+ res.locals.fingerprint = getFingerprint
next()
webRouter.use (req, res, next)->
diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee
index 11a3b5237e..8ac543c698 100644
--- a/services/web/app/coffee/infrastructure/Server.coffee
+++ b/services/web/app/coffee/infrastructure/Server.coffee
@@ -31,6 +31,7 @@ translations = require("translations-sharelatex").setup(Settings.i18n)
Modules = require "./Modules"
ErrorController = require "../Features/Errors/ErrorController"
+UserSessionsManager = require "../Features/User/UserSessionsManager"
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
@@ -89,6 +90,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
# Measure expiry from last request, not last login
webRouter.use (req, res, next) ->
req.session.touch()
+ if req?.session?.user?
+ UserSessionsManager.touch(req.session.user, (err)->)
next()
webRouter.use ReferalConnect.use
@@ -98,23 +101,23 @@ if app.get('env') == 'production'
logger.info "Production Enviroment"
app.enable('view cache')
-
-
app.use (req, res, next)->
metrics.inc "http-request"
crawlerLogger.log(req)
next()
-app.use (req, res, next) ->
- if !Settings.editorIsOpen
+webRouter.use (req, res, next) ->
+ if Settings.editorIsOpen
+ next()
+ else if req.url.indexOf("/admin") == 0
+ next()
+ else
res.status(503)
res.render("general/closed", {title:"maintenance"})
- else
- next()
apiRouter.get "/status", (req, res)->
res.send("web sharelatex is alive")
-
+
profiler = require "v8-profiler"
apiRouter.get "/profile", (req, res) ->
time = parseInt(req.query.time || "1000")
diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee
index b0a7628f17..ba0a862fd2 100644
--- a/services/web/app/coffee/models/User.coffee
+++ b/services/web/app/coffee/models/User.coffee
@@ -58,6 +58,7 @@ UserSchema = new Schema
mendeley: Boolean # coerce the refProviders values to Booleans
zotero: Boolean
}
+ betaProgram: { type:Boolean, default: false}
conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10)
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index 80c52c5a24..d6deffe755 100644
--- a/services/web/app/coffee/router.coffee
+++ b/services/web/app/coffee/router.coffee
@@ -30,7 +30,6 @@ PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
ChatController = require("./Features/Chat/ChatController")
BlogController = require("./Features/Blog/BlogController")
-WikiController = require("./Features/Wiki/WikiController")
Modules = require "./infrastructure/Modules"
RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
@@ -38,6 +37,7 @@ InactiveProjectController = require("./Features/InactiveData/InactiveProjectCont
ContactRouter = require("./Features/Contacts/ContactRouter")
ReferencesController = require('./Features/References/ReferencesController')
AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
+BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
logger = require("logger-sharelatex")
_ = require("underscore")
@@ -79,6 +79,7 @@ module.exports = class Router
webRouter.get '/blog/*', BlogController.getPage
webRouter.get '/user/activate', UserPagesController.activateAccountPage
+ AuthenticationController.addEndpointToLoginWhitelist '/user/activate'
webRouter.get '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
@@ -104,7 +105,11 @@ module.exports = class Router
webRouter.post '/project/:Project_id/settings/admin', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.updateProjectAdminSettings
webRouter.post '/project/:Project_id/compile', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.compile
- webRouter.get '/Project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
+ webRouter.post '/project/:Project_id/compile/stop', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.stopCompile
+
+ # Used by the web download buttons, adds filename header
+ webRouter.get '/project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf
+ # Used by the pdf viewers
webRouter.get /^\/project\/([^\/]*)\/output\/(.*)$/,
((req, res, next) ->
params =
@@ -118,12 +123,36 @@ module.exports = class Router
((req, res, next) ->
params =
"Project_id": req.params[0]
- "build": req.params[1]
+ "build_id": req.params[1]
"file": req.params[2]
req.params = params
next()
), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
+ # direct url access to output files for user but no build, to retrieve files when build fails
+ webRouter.get /^\/project\/([^\/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/,
+ ((req, res, next) ->
+ params =
+ "Project_id": req.params[0]
+ "user_id": req.params[1]
+ "file": req.params[2]
+ req.params = params
+ next()
+ ), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
+
+ # direct url access to output files for a specific user and build (query string not required)
+ webRouter.get /^\/project\/([^\/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
+ ((req, res, next) ->
+ params =
+ "Project_id": req.params[0]
+ "user_id": req.params[1]
+ "build_id": req.params[2]
+ "file": req.params[3]
+ req.params = params
+ next()
+ ), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi
+
+
webRouter.delete "/project/:Project_id/output", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.deleteAuxFiles
webRouter.get "/project/:Project_id/sync/code", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncCode
webRouter.get "/project/:Project_id/sync/pdf", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncPdf
@@ -187,16 +216,13 @@ module.exports = class Router
webRouter.get "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
- webRouter.get /learn(\/.*)?/, RateLimiterMiddlewear.rateLimit({
- endpointName: "wiki"
- params: []
- maxRequests: 60
- timeInterval: 60
- }), WikiController.getPage
-
webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
+ webRouter.get "/beta/participate", AuthenticationController.requireLogin(), BetaProgramController.optInPage
+ webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
+ webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
+
#Admin Stuff
webRouter.get '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index
webRouter.get '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
@@ -216,9 +242,11 @@ module.exports = class Router
apiRouter.get '/status', (req,res)->
res.send("websharelatex is up")
+ webRouter.get '/dev/csrf', (req, res) ->
+ res.send res.locals.csrfToken
- webRouter.get '/health_check', HealthCheckController.check
- webRouter.get '/health_check/redis', HealthCheckController.checkRedis
+ apiRouter.get '/health_check', HealthCheckController.check
+ apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
sendRes = _.once (statusCode, message)->
diff --git a/services/web/app/views/admin/index.jade b/services/web/app/views/admin/index.jade
index d28d88c929..0fa9017cc1 100644
--- a/services/web/app/views/admin/index.jade
+++ b/services/web/app/views/admin/index.jade
@@ -9,6 +9,22 @@ block content
.page-header
h1 Admin Panel
tabset(ng-cloak)
+ tab(heading="System Messages")
+ each message in systemMessages
+ .alert.alert-info.row-spaced !{message.content}
+ hr
+ form(enctype='multipart/form-data', method='post', action='/admin/messages')
+ input(name="_csrf", type="hidden", value=csrfToken)
+ .form-group
+ label(for="content")
+ input.form-control(name="content", type="text", placeholder="Message...", required)
+ button.btn.btn-primary(type="submit") Post Message
+ hr
+ form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
+ input(name="_csrf", type="hidden", value=csrfToken)
+ button.btn.btn-danger(type="submit") Clear all messages
+
+
tab(heading="Open Sockets")
.row-spaced
ul
@@ -17,7 +33,7 @@ block content
ul
-each agent in agents
li #{agent}
-
+
tab(heading="Close Editor")
.row-spaced
form(enctype='multipart/form-data', method='post',action='/admin/closeEditor')
@@ -66,19 +82,6 @@ block content
.form-group
button.btn-primary.btn(type='submit') Poll
- tab(heading="System Messages")
- each message in systemMessages
- .alert.alert-info.row-spaced !{message.content}
- hr
- form(enctype='multipart/form-data', method='post', action='/admin/messages')
- input(name="_csrf", type="hidden", value=csrfToken)
- .form-group
- label(for="content")
- input.form-control(name="content", type="text", placeholder="Message...", required)
- button.btn.btn-primary(type="submit") Post Message
- hr
- form(enctype='multipart/form-data', method='post', action='/admin/messages/clear')
- input(name="_csrf", type="hidden", value=csrfToken)
- button.btn.btn-danger(type="submit") Clear all messages
+
diff --git a/services/web/app/views/beta_program/opt_in.jade b/services/web/app/views/beta_program/opt_in.jade
new file mode 100644
index 0000000000..c6411cb211
--- /dev/null
+++ b/services/web/app/views/beta_program/opt_in.jade
@@ -0,0 +1,47 @@
+extends ../layout
+
+block content
+ .content.content-alt
+ .container.beta-opt-in-wrapper
+ .row
+ .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2
+ .card
+ .page-header.text-centered
+ h1
+ | #{translate("sharelatex_beta_program")}
+ .beta-opt-in
+ .container-fluid
+ .row
+ .col-md-12
+ p.text-centered #{translate("beta_program_benefits")}
+ p.text-centered
+ | #{translate("beta_program_badge_description")}
+ span.beta-feature-badge
+ p.text-centered
+ | #{translate("beta_program_current_beta_features_description")}
+ ul.list-unstyled.text-center
+ li
+ i.fa.fa-fw.fa-book
+ | #{translate("mendeley_integration")}
+ .row.text-centered
+ .col-md-12
+ if user.betaProgram
+ p #{translate("beta_program_already_participating")}
+ form(id="beta-program-opt-out", method="post", action="/beta/opt-out", novalidate)
+ .form-group
+ input(type="hidden", name="_csrf", value=csrfToken)
+ button.btn.btn-primary(
+ type="submit"
+ )
+ span #{translate("beta_program_opt_out_action")}
+ .form-group
+ a(href="/project").btn.btn-info #{translate("back_to_your_projects")}
+ else
+ form(id="beta-program-opt-in", method="post", action="/beta/opt-in", novalidate)
+ .form-group
+ input(type="hidden", name="_csrf", value=csrfToken)
+ button.btn.btn-primary(
+ type="submit"
+ )
+ span #{translate("beta_program_opt_in_action")}
+
diff --git a/services/web/app/views/contact-us-modal.jade b/services/web/app/views/contact-us-modal.jade
index 46090eec1b..f4fd8938b7 100644
--- a/services/web/app/views/contact-us-modal.jade
+++ b/services/web/app/views/contact-us-modal.jade
@@ -11,7 +11,19 @@ script(type='text/ng-template', id='supportModalTemplate')
label
| #{translate("subject")}
.form-group
- input.field.text.medium.span8.form-control(ng-model="form.subject", maxlength='255', tabindex='1', onkeyup='')
+ input.field.text.medium.span8.form-control(
+ 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: "__kb__", kb: 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")
@@ -21,7 +33,7 @@ script(type='text/ng-template', id='supportModalTemplate')
.form-group
input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
label.desc
- | #{translate("suggestion")}
+ | #{translate("contact_message_label")}
.form-group
textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
.form-group.text-center
diff --git a/services/web/app/views/general/500.jade b/services/web/app/views/general/500.jade
index e045a7d457..ef74ed7435 100644
--- a/services/web/app/views/general/500.jade
+++ b/services/web/app/views/general/500.jade
@@ -3,7 +3,7 @@ html(itemscope, itemtype='http://schema.org/Product')
head
title Something went wrong
link(rel="icon", href="/favicon.ico")
- link(rel='stylesheet', href='/stylesheets/style.css')
+ link(rel='stylesheet', href=buildCssPath('/style.css'))
link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
body
.content
@@ -12,7 +12,7 @@ html(itemscope, itemtype='http://schema.org/Product')
.col-md-8.col-md-offset-2.text-center
.page-header
h2 Oh dear, something went wrong.
- p: img(src="/img/lion-sad-128.png", alt="Sad Lion")
+ p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
p
| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
p
diff --git a/services/web/app/views/layout.jade b/services/web/app/views/layout.jade
index ae0e7550be..83d7da4838 100644
--- a/services/web/app/views/layout.jade
+++ b/services/web/app/views/layout.jade
@@ -13,13 +13,12 @@ html(itemscope, itemtype='http://schema.org/Product')
-if (typeof(title) == "undefined")
- title ShareLaTeX, the Online LaTeX Editor
+ title= 'ShareLaTeX, '+ translate("online_latex_editor")
-else
- title= translate(title) + ' - ShareLaTeX, '+translate("online_latex_editor")
+ title= translate(title) + ' - ShareLaTeX, ' + translate("online_latex_editor")
link(rel="icon", href="/favicon.ico")
- link(rel='stylesheet', href='/stylesheets/style.css?fingerprint='+fingerprint('/stylesheets/style.css'))
- link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
+ link(rel='stylesheet', href=buildCssPath('/style.css'))
if settings.i18n.subdomainLang
each subdomainDetails in settings.i18n.subdomainLang
@@ -30,7 +29,7 @@ html(itemscope, itemtype='http://schema.org/Product')
meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
-if (typeof(meta) == "undefined")
- meta(itemprop="description", name="description", content="An online LaTeX editor that's easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.")
+ meta(itemprop="description", name="description", content='#{translate("site_description")}')
-else
meta(itemprop="description", name="description" , content=meta)
@@ -46,15 +45,84 @@ html(itemscope, itemtype='http://schema.org/Product')
ga('send', 'pageview');
- else
script(type='text/javascript').
- window.ga = function() { console.log("Sending to GA", arguments) };
+ window.ga = function() { console.log("would send to GA", arguments) };
+ // Countly Analytics
+ if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
+ script(type="text/javascript").
+ var Countly = Countly || {};
+ Countly.q = Countly.q || [];
+ Countly.app_key = '#{settings.analytics.countly.token}';
+ Countly.url = '#{settings.analytics.countly.server}';
+ !{ session.user ? 'Countly.device_id = "' + session.user._id + '";' : '' }
+
+ (function() {
+ var cly = document.createElement('script'); cly.type = 'text/javascript';
+ cly.async = true;
+ //enter url of script here
+ cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
+ cly.onload = function(){Countly.init()};
+ var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
+ })();
+
+ script(type="text/javascript")
+ if (session && session.user)
+ - var name = session.user.first_name + (session.user.last_name ? ' ' + session.user.last_name : '');
+ | Countly.q.push(['user_details', { email: '#{session.user.email}', name: '#{name}' }]);
+
+ if (justRegistered)
+ | Countly.q.push(['add_event',{ key: 'user-registered' }]);
+
+ if (justLoggedIn)
+ | Countly.q.push(['add_event',{ key: 'user-logged-in' }]);
+
+ if (user && user.features)
+ - featureFlagSet = false;
+
+ if user.features.hasOwnProperty('collaborators')
+ | Countly.q.push([ 'userData.set', 'features-collaborators', #{ user.features.collaborators } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('compileGroup')
+ | Countly.q.push([ 'userData.set', 'features-compileGroup', '#{ user.features.compileGroup }' ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('compileTimeout')
+ | Countly.q.push([ 'userData.set', 'features-compileTimeout', #{ user.features.compileTimeout } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('dropbox')
+ | Countly.q.push([ 'userData.set', 'features-dropbox', #{ user.features.dropbox } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('github')
+ | Countly.q.push([ 'userData.set', 'features-github', #{ user.features.github } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('references')
+ | Countly.q.push([ 'userData.set', 'features-references', #{ user.features.references } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('templates')
+ | Countly.q.push([ 'userData.set', 'features-templates', #{ user.features.templates } ]);
+ - featureFlagSet = true;
+
+ if user.features.hasOwnProperty('versioning')
+ | Countly.q.push([ 'userData.set', 'features-versioning', #{ user.features.versioning } ]);
+ - featureFlagSet = true;
+
+
+ if featureFlagSet
+ | Countly.q.push(['userData.save'])
+
+ // End countly Analytics
+
script(type="text/javascript").
window.csrfToken = "#{csrfToken}";
block scripts
- script(src="#{jsPath}libs/jquery-1.11.1.min.js")
- script(src="#{jsPath}libs/angular-1.3.15.min.js")
- include sentry
+ script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
+ script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
script.
window.sharelatex = {
siteUrl: '#{settings.siteUrl}',
@@ -123,12 +191,14 @@ html(itemscope, itemtype='http://schema.org/Product')
}
};
script(
- data-main=jsPath+'main.js',
- baseurl=jsPath,
- src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
+ data-main=buildJsPath('main.js', {fingerprint:false}),
+ baseurl=fullJsPath,
+ src=buildJsPath('libs/require.js')
)
+
include contact-us-modal
-
+ include sentry
+
diff --git a/services/web/app/views/layout/footer.jade b/services/web/app/views/layout/footer.jade
index 8c9652ecc5..efd64b6f6e 100644
--- a/services/web/app/views/layout/footer.jade
+++ b/services/web/app/views/layout/footer.jade
@@ -15,7 +15,7 @@ footer.site-footer
aria-expanded="false",
tooltip="#{translate('language')}"
)
- img(src="/img/flags/24/#{currentLngCode}.png")
+ figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}")
ul.dropdown-menu(role="menu")
li.dropdown-header #{translate("language")}
@@ -23,9 +23,9 @@ footer.site-footer
if !subdomainDetails.hide
li.lngOption
a.menu-indent(href=subdomainDetails.url+currentUrl)
- img(src="/img/flags/24/#{subdomainDetails.lngCode}.png")
+ figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}")
| #{translate(subdomainDetails.lngCode)}
-
+ //- img(src="/img/flags/24/.png")
each item in nav.left_footer
li
if item.url
@@ -41,4 +41,3 @@ footer.site-footer
a(href=item.url, class=item.class) !{item.text}
else
| !{item.text}
-
diff --git a/services/web/app/views/layout/navbar.jade b/services/web/app/views/layout/navbar.jade
index 89ff4e1dc2..b6b3c61741 100644
--- a/services/web/app/views/layout/navbar.jade
+++ b/services/web/app/views/layout/navbar.jade
@@ -3,7 +3,9 @@ nav.navbar.navbar-default
.navbar-header
button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
i.fa.fa-bars
- if nav.title
+ if settings.nav.custom_logo
+ a(href='/', style='background-image:url("#{settings.nav.custom_logo}")').navbar-brand
+ else if (nav.title)
a(href='/').navbar-title #{nav.title}
else
a(href='/').navbar-brand
@@ -17,6 +19,8 @@ nav.navbar.navbar-default
| Admin
b.caret
ul.dropdown-menu
+ li
+ a(href="/admin") Manage Site
li
a(href="/admin/user") Manage Users
diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.jade
index e666724b09..1e36e374f1 100644
--- a/services/web/app/views/project/editor.jade
+++ b/services/web/app/views/project/editor.jade
@@ -97,9 +97,10 @@ block content
window.csrfToken = "!{csrfToken}";
window.anonymous = #{anonymous};
window.maxDocLength = #{maxDocLength};
+ window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
window.requirejs = {
"paths" : {
- "mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
+ "mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {qs:{config:'TeX-AMS_HTML', fingerprint:false}})}",
"moment": "libs/moment-2.7.0",
"libs/pdf": "libs/pdfjs-1.3.91/pdf"
},
@@ -128,15 +129,18 @@ block content
- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
- var fingerprintedPath = fingerprint(jsPath+pdfPath)
- - var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
+ - var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
+
+
script(type='text/javascript').
window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
script(
- data-main=jsPath+"ide.js",
- baseurl=jsPath,
- data-ace-base=jsPath+'ace',
- src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
+ data-main=buildJsPath("ide.js", {fingerprint:false}),
+ baseurl=fullJsPath,
+ data-ace-base=buildJsPath('ace', {fingerprint:false}),
+ src=buildJsPath('libs/require.js')
)
+
diff --git a/services/web/app/views/project/editor/editor.jade b/services/web/app/views/project/editor/editor.jade
index d6c0876f96..ea5efff304 100644
--- a/services/web/app/views/project/editor/editor.jade
+++ b/services/web/app/views/project/editor/editor.jade
@@ -35,7 +35,7 @@ div.full-size(
resize-on="layout:main:resize,layout:pdf:resize",
annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
read-only="!permissions.write",
- on-ctrl-enter="recompile"
+ on-ctrl-enter="recompileViaKey"
)
.ui-layout-east
diff --git a/services/web/app/views/project/editor/left-menu.jade b/services/web/app/views/project/editor/left-menu.jade
index 52ea62d505..05fa19542f 100644
--- a/services/web/app/views/project/editor/left-menu.jade
+++ b/services/web/app/views/project/editor/left-menu.jade
@@ -15,7 +15,7 @@ aside#left-menu.full-size(
| #{translate("source")}
li
a(
- ng-href="{{pdf.url}}"
+ ng-href="{{pdf.downloadUrl || pdf.url}}"
target="_blank"
ng-if="pdf.url"
)
diff --git a/services/web/app/views/project/editor/pdf.jade b/services/web/app/views/project/editor/pdf.jade
index df97ff889e..200c609d97 100644
--- a/services/web/app/views/project/editor/pdf.jade
+++ b/services/web/app/views/project/editor/pdf.jade
@@ -1,6 +1,13 @@
div.full-size.pdf(ng-controller="PdfController")
.toolbar.toolbar-tall
- .btn-group(dropdown)
+ .btn-group(
+ dropdown,
+ tooltip-html="'#{translate('recompile_pdf')} ({{modifierKey}} + Enter)'"
+ tooltip-class="keyboard-tooltip"
+ tooltip-popup-delay="500"
+ tooltip-append-to-body="true"
+ tooltip-placement="bottom"
+ )
a.btn.btn-info(
href,
ng-disabled="pdf.compiling",
@@ -29,6 +36,14 @@ div.full-size.pdf(ng-controller="PdfController")
i.fa.fa-fw(ng-class="{'fa-check': draft}")
| #{translate("fast")}
span.subdued [draft]
+ a(
+ href
+ ng-click="stop()"
+ ng-show="pdf.compiling",
+ tooltip="#{translate('stop_compile')}"
+ tooltip-placement="bottom"
+ )
+ i.fa.fa-stop()
a.log-btn(
href
ng-click="toggleLogs()"
@@ -46,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController")
) {{ pdf.logEntries.errors.length + pdf.logEntries.warnings.length }}
a(
- ng-href="{{pdf.url}}"
+ ng-href="{{pdf.downloadUrl || pdf.url}}"
target="_blank"
ng-if="pdf.url"
tooltip="#{translate('download_pdf')}"
@@ -93,15 +108,98 @@ div.full-size.pdf(ng-controller="PdfController")
'alert-info': entry.level == 'typesetting'\
}"
ng-click="openInEditor(entry)"
+ ng-init="feedbackSent = false; showNegFeedbackUI = false; negFeedbackReason = ''; negFeedbackReasonFreeText = ''"
)
span.line-no
+ i.fa.fa-link(aria-hidden="true")
+ |
span(ng-show="entry.file") {{ entry.file }}
span(ng-show="entry.line") , line {{ entry.line }}
p.entry-message(ng-show="entry.message") {{ entry.message }}
- p.entry-content(ng-show="entry.content") {{ entry.content }}
+ .card.card-hint(
+ ng-if="entry.humanReadableHint"
+ stop-propagation="click"
+ )
+ figure.card-hint-icon-container
+ i.fa.fa-lightbulb-o(aria-hidden="true")
+ p.card-hint-text(
+ ng-show="entry.humanReadableHint",
+ ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
+ .card-hint-actions.clearfix
+ .card-hint-ext-link(ng-if="wikiEnabled")
+ a(
+ ng-href="{{ entry.extraInfoURL }}",
+ ng-click="trackLogHintsLearnMore()"
+ target="_blank"
+ )
+ i.fa.fa-external-link
+ | #{translate("log_hint_extra_info")}
+ .card-hint-feedback(
+ ng-hide="feedbackSent || showNegFeedbackUI"
+ ng-class="entry.ruleId"
+ )
+ label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
+ a.card-hint-feedback-positive(
+ ng-click="trackLogHintsPositiveFeedback(entry.ruleId); feedbackSent = true;"
+ href
+ ) #{translate("answer_yes")}
+ span /
+ a.card-hint-feedback-negative(
+ ng-click="trackLogHintsNegativeFeedback(entry.ruleId); showNegFeedbackUI = true;"
+ href
+ ) #{translate("answer_no")}
+ .card-hint-extra-feedback(ng-hide="!showNegFeedbackUI || feedbackSent")
+ p.card-hint-extra-feedback-label #{translate("log_hint_ask_extra_feedback")}
+ .radio: label
+ input(
+ type="radio"
+ name="{{ 'neg-feedback-reason-' + $index }}"
+ ng-model="negFeedbackReason"
+ value="{{ logHintsNegFeedbackValues.DIDNT_UNDERSTAND }}"
+ )
+ | #{translate("log_hint_extra_feedback_didnt_understand")}
+ .radio: label
+ input(
+ type="radio"
+ name="{{ 'neg-feedback-reason-' + $index }}"
+ ng-model="negFeedbackReason"
+ value="{{ logHintsNegFeedbackValues.NOT_APPLICABLE }}"
+ )
+ | #{translate("log_hint_extra_feedback_not_applicable")}
+ .radio: label
+ input(
+ type="radio"
+ name="{{ 'neg-feedback-reason-' + $index }}"
+ ng-model="negFeedbackReason"
+ value="{{ logHintsNegFeedbackValues.INCORRECT }}"
+ )
+ | #{translate("log_hint_extra_feedback_incorrect")}
+ .radio: label
+ input(
+ type="radio"
+ name="{{ 'neg-feedback-reason-' + $index }}"
+ ng-model="negFeedbackReason"
+ value="{{ logHintsNegFeedbackValues.OTHER }}"
+ )
+ | #{translate("log_hint_extra_feedback_other")}
+ textarea.form-control(
+ ng-show="negFeedbackReason === logHintsNegFeedbackValues.OTHER"
+ ng-model="negFeedbackReasonFreeText"
+ rows="2"
+ )
+ .clearfix
+ button.btn.btn-default.btn-sm.pull-right(
+ ng-disabled="!negFeedbackReason"
+ ng-click="trackLogHintsNegFeedbackDetails(entry.ruleId, negFeedbackReason, negFeedbackReasonFreeText); feedbackSent = true;"
+ ) #{translate("log_hint_extra_feedback_submit")}
+
+ .card-hint-feedback(ng-show="feedbackSent")
+ label.card-hint-feedback-label #{translate("log_hint_feedback_gratitude")}
+
+ p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
p
- .pull-right
+ .files-dropdown-container
a.btn.btn-default.btn-sm(
href,
tooltip="#{translate('clear_cached_files')}",
@@ -111,12 +209,15 @@ div.full-size.pdf(ng-controller="PdfController")
)
i.fa.fa-trash-o
|
- div.dropdown(style="display: inline-block;", dropdown)
+ div.files-dropdown(
+ ng-class="shouldDropUp ? 'dropup' : 'dropdown'"
+ dropdown
+ )
a.btn.btn-default.btn-sm(
href
dropdown-toggle
)
- | !{translate("other_logs_and_files")}
+ | !{translate("other_logs_and_files")}
span.caret
ul.dropdown-menu.dropdown-menu-right
li(ng-repeat="file in pdf.outputFiles")
@@ -154,6 +255,27 @@ div.full-size.pdf(ng-controller="PdfController")
ng-if="settings.pdfViewer == 'native'"
)
+ .pdf-validation-problems(ng-switch-when="validation-problems")
+
+ .alert.alert-danger(ng-show="pdf.validation.duplicatePaths")
+ strong #{translate("latex_error")}
+ span #{translate("duplicate_paths_found")}
+
+
+ .alert.alert-danger(ng-show="pdf.validation.sizeCheck")
+ strong #{translate("project_too_large")}
+ div #{translate("project_too_large_please_reduce")}
+ div
+ li(ng-repeat="entry in pdf.validation.sizeCheck.resources") {{ '/'+entry['path'] }} - {{entry['kbSize']}}kb
+
+ .alert.alert-danger(ng-show="pdf.validation.conflictedPaths")
+ div
+ strong #{translate("conflicting_paths_found")}
+ div #{translate("following_paths_conflict")}
+ div
+ li(ng-repeat="entry in pdf.validation.conflictedPaths") {{ '/'+entry['path'] }}
+
+
.pdf-errors(ng-switch-when="errors")
.alert.alert-danger(ng-show="pdf.error")
@@ -171,7 +293,11 @@ div.full-size.pdf(ng-controller="PdfController")
.alert.alert-danger(ng-show="pdf.tooRecentlyCompiled")
strong #{translate("server_error")}
span #{translate("too_recently_compiled")}
-
+
+ .alert.alert-danger(ng-show="pdf.compileTerminated")
+ strong #{translate("terminated")}.
+ span #{translate("compile_terminated_by_user")}
+
.alert.alert-danger(ng-show="pdf.timedout")
p
strong #{translate("timedout")}.
diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade
index dad1d42108..ad3b2bd9ba 100644
--- a/services/web/app/views/project/editor/share.jade
+++ b/services/web/app/views/project/editor/share.jade
@@ -49,7 +49,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
.form-group
tags-input(
template="shareTagTemplate"
- placeholder="joe@example.com, sue@example.com, ..."
+ placeholder="#{settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'}"
ng-model="inputs.contacts"
focus-on="open"
display-property="display"
diff --git a/services/web/app/views/project/list/side-bar.jade b/services/web/app/views/project/list/side-bar.jade
index a30127840a..96365c813e 100644
--- a/services/web/app/views/project/list/side-bar.jade
+++ b/services/web/app/views/project/list/side-bar.jade
@@ -94,18 +94,7 @@
- if (showUserDetailsArea)
span(ng-controller="LeftHandMenuPromoController", ng-cloak)
- .row-spaced(ng-if="showDatajoy")
- hr
- .card.card-thin
- p.text-center.small
- | Python or R user?
- p.text-center.small
- a(href="https://www.getdatajoy.com/", target="_blank").btn.btn-info.btn-small Try DataJoy
- p.text-center.small(style="font-size: 0.8em")
- a(href="https://www.getdatajoy.com/", target="_blank") DataJoy
- | is a new online Python and R editor from ShareLaTeX.
-
- .row-spaced#userProfileInformation(ng-if="hasProjects && !showDatajoy")
+ .row-spaced#userProfileInformation(ng-if="hasProjects")
div(ng-controller="UserProfileController")
hr(ng-show="percentComplete < 100")
.text-centered.user-profile(ng-show="percentComplete < 100")
@@ -120,7 +109,7 @@
) #{translate("complete")}
- .row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
+ .row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
span(sixpack-default).text-centered
hr
p.small #{translate("on_free_sl")}
@@ -146,7 +135,7 @@
p
span Get Dropbox Sync
p
- img(src="/img/dropbox/simple_logo.png")
+ img(src=buildImgPath("dropbox/simple_logo.png"))
p
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
p.small.text-centered
@@ -159,13 +148,13 @@
p
span Get Github Sync
p
- img(src="/img/github/octocat.jpg")
+ img(src=buildImgPath("github/octocat.jpg"))
p
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
p.small.text-centered
| #{translate("or_unlock_features_bonus")}
a(href="/user/bonus") #{translate("sharing_sl")} .
script.
- window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
+ window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}
diff --git a/services/web/app/views/sentry.jade b/services/web/app/views/sentry.jade
index 9fe5dd3b7d..4d838cd698 100644
--- a/services/web/app/views/sentry.jade
+++ b/services/web/app/views/sentry.jade
@@ -2,7 +2,7 @@
- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
script(src="#{sentrySrc}")
- else
- script(src="#{jsPath}libs/#{sentrySrc}")
+ script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false}))
- if (typeof(sentrySrc) != "undefined")
script(type="text/javascript").
if (typeof(Raven) != "undefined" && Raven.config) {
diff --git a/services/web/app/views/subscriptions/dashboard.jade b/services/web/app/views/subscriptions/dashboard.jade
index dd3e600aa1..9b9464419e 100644
--- a/services/web/app/views/subscriptions/dashboard.jade
+++ b/services/web/app/views/subscriptions/dashboard.jade
@@ -43,12 +43,8 @@ block content
.card(ng-if="view == 'overview'")
.page-header
h1 #{translate("your_subscription")}
- -if (groups.length != 0)
- each groupSubscription in groups
- p !{translate("member_of_group_subscription", {admin_email: "" + groupSubscription.admin_id.email + ""})}
- span
- button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
- - else if (subscription)
+
+ - if (subscription && user._id+'' == subscription.admin_id+'')
case subscription.state
when "free-trial"
p !{translate("on_free_trial_expiring_at", {expiresAt:"" + subscription.expiresAt + ""})}
@@ -72,15 +68,9 @@ block content
p !{translate("your_subscription_has_expired")}
a(href="/user/subscription/plans") !{translate("create_new_subscription")}
default
- -if(groups.length == 0)
+ -if(groupSubscriptions.length == 0)
p !{translate("problem_with_subscription_contact_us")}
- -if(subscription.groupPlan)
- a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
-
-
-
-
div(ng-show="changePlan", ng-cloak)#changePlanSection
h2.col-md-7 !{translate("change_plan")}
span.dropdown.col-md-1.changePlanButton(ng-controller="CurrenyDropdownController", style="display:none", dropdown)
@@ -105,6 +95,20 @@ block content
mixin printPlans(plans.individualMonthlyPlans)
mixin printPlans(plans.individualAnnualPlans)
+
+ each groupSubscription in groupSubscriptions
+ - if (user._id+'' != groupSubscription.admin_id._id+'')
+ div
+ p !{translate("member_of_group_subscription", {admin_email: "" + groupSubscription.admin_id.email + ""})}
+ span
+ button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
+
+ -if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
+ div
+ a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
+
+
+
.card(ng-if="view == 'cancelation'")
.page-header
h1 #{translate("Cancel Subscription")}
diff --git a/services/web/app/views/subscriptions/edit-billing-details.jade b/services/web/app/views/subscriptions/edit-billing-details.jade
index bc47efb4cb..f0b6671bcd 100644
--- a/services/web/app/views/subscriptions/edit-billing-details.jade
+++ b/services/web/app/views/subscriptions/edit-billing-details.jade
@@ -3,7 +3,7 @@ extends ../layout
block content
- locals.supressDefaultJs = true
script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
- script(src=jsPath+'libs/recurly.min.js')
+ script(src=buildJsPath('libs/recurly.min.js'))
.content.content-alt
.container
diff --git a/services/web/app/views/subscriptions/successful_subscription.jade b/services/web/app/views/subscriptions/successful_subscription.jade
index 86b0263238..8413f5e036 100644
--- a/services/web/app/views/subscriptions/successful_subscription.jade
+++ b/services/web/app/views/subscriptions/successful_subscription.jade
@@ -30,10 +30,10 @@ block content
| Henry and James
.portraits
span.img-circle
- img(src="/img/about/henry_oswald.jpg")
+ img(src=buildImgPath("about/henry_oswald.jpg"))
|
span.img-circle
- img(src="/img/about/james_allen.jpg")
+ img(src=buildImgPath("about/james_allen.jpg"))
p
a.btn.btn-primary(href="/project") < #{translate("back_to_your_projects")}
diff --git a/services/web/app/views/translations/translation_message.jade b/services/web/app/views/translations/translation_message.jade
index 333139139f..225ad3ea2c 100644
--- a/services/web/app/views/translations/translation_message.jade
+++ b/services/web/app/views/translations/translation_message.jade
@@ -2,7 +2,7 @@
span(ng-controller="TranslationsPopupController", ng-cloak)
.translations-message(ng-hide="hidei18nNotification")
a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"" + translate(recomendSubdomain.lngCode) + ""})}
- img(src="/img/flags/24/#{recomendSubdomain.lngCode}.png")
+ img(src=buildImgPath("flags/24/#{recomendSubdomain.lngCode}.png"))
button(ng-click="dismiss()").close.pull-right
span(aria-hidden="true") ×
span.sr-only #{translate("close")}
\ No newline at end of file
diff --git a/services/web/app/views/university/case_study.jade b/services/web/app/views/university/case_study.jade
new file mode 100644
index 0000000000..7974eb9e6c
--- /dev/null
+++ b/services/web/app/views/university/case_study.jade
@@ -0,0 +1,19 @@
+extends ../layout
+
+block content
+ .masthead
+ .container
+ .row
+ .col-md-12
+ h1 !{viewData.title}
+ .row.row-spaced
+
+ .pattern-container
+ .content
+ .container
+ .row
+ .col-md-10.col-md-offset-1
+ .card
+ .row
+ | !{viewData.html}
+
diff --git a/services/web/app/views/user/settings.jade b/services/web/app/views/user/settings.jade
index 5d25d80c1b..a23c3660aa 100644
--- a/services/web/app/views/user/settings.jade
+++ b/services/web/app/views/user/settings.jade
@@ -99,6 +99,17 @@ block content
| !{moduleIncludes("userSettings", locals)}
+ if (user.betaProgram)
+ hr
+
+ h3
+ | #{translate("sharelatex_beta_program")}
+
+ p.small
+ | #{translate("beta_program_already_participating")}
+ div
+ a(id="beta-program-participate-link" href="/beta/participate") #{translate("manage_beta_program_membership")}
+
hr
if !externalAuthenticationSystemUsed()
diff --git a/services/web/app/views/wiki/page.jade b/services/web/app/views/wiki/page.jade
deleted file mode 100644
index 6a6bea2202..0000000000
--- a/services/web/app/views/wiki/page.jade
+++ /dev/null
@@ -1,68 +0,0 @@
-extends ../layout
-
-block content
- .content.content-alt(ng-cloak)
- .container.wiki
- .row.template-page-header
- .col-md-8(ng-cloak)
-
- .row
- .col-xs-3.contents(ng-non-bindable)
- | !{contents.content}
-
- .col-xs-9.page
- - if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined")
- span(ng-controller="SearchWikiController")
- .row
- form.project-search.form-horizontal.col-md-9(role="form")
- .form-group.has-feedback.has-feedback-left.col-md-12
- input.form-control.col-md-12(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....")
- i.fa.fa-search.form-control-feedback-left
- i.fa.fa-times.form-control-feedback(
- ng-click="clearSearchText()",
- style="cursor: pointer;",
- ng-show="searchQueryText.length > 0"
- )
- .col-md-3.text-right
- a.btn.btn-primary(ng-click="showMissingTemplateModal()") #{translate("suggest_new_doc")}
-
- .row
- .col-md-12(ng-cloak)
- a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin
- span(ng-bind-html='hit.name')
- div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content')
-
- .card.row-spaced(ng-non-bindable)
- .page-header
- h1 #{title}
-
- | !{page.content}
-
-
-
-
- script(type="text/ng-template", id="missingWikiPageModal")
- .modal-header
- button.close(
- type="button"
- data-dismiss="modal"
- ng-click="close()"
- ) ×
- h3 #{translate("suggest_new_doc")}
- .modal-body.contact-us-modal
- span(ng-show="sent == false")
- label.desc
- | #{translate("email")} (#{translate("optional")})
- .form-group
- input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
- label.desc
- | #{translate("suggestion")}
- .form-group
- textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', maxlength='255', tabindex='4', onkeyup='')
- span(ng-show="sent")
- p #{translate("request_sent_thank_you")}
- .modal-footer
- button.btn.btn-default(ng-click="close()")
- span #{translate("dismiss")}
- button.btn-success.btn(type='submit', ng-disabled="sending", ng-click="contactUs()") #{translate("contact_us")}
-
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index beb933e4c9..f345d14129 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/services/web/config/settings.defaults.coffee
@@ -15,7 +15,7 @@ httpAuthUsers[httpAuthUser] = httpAuthPass
sessionSecret = "secret-please-change"
-module.exports =
+module.exports = settings =
# File storage
# ------------
#
@@ -104,16 +104,21 @@ module.exports =
url: "http://localhost:3036"
sixpack:
url: ""
- references:
- url: "http://localhost:3040"
- notifications:
- url: "http://localhost:3042"
+ # references:
+ # url: "http://localhost:3040"
+ # notifications:
+ # url: "http://localhost:3042"
templates:
user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
showSocialButtons: false
showComments: false
+ # cdn:
+ # web:
+ # host:"http://cdn.sharelatex.dev:3000"
+ # darkHost:"http://cdn.sharelatex.dev:3000"
+
# Where your instance of ShareLaTeX can be found publically. Used in emails
# that are sent out, generated links, etc.
siteUrl : siteUrl = 'http://localhost:3000'
@@ -137,6 +142,7 @@ module.exports =
# --------
security:
sessionSecret: sessionSecret
+ bcryptRounds: 12 # number of rounds used to hash user passwords (raised to power 2)
httpAuthUsers: httpAuthUsers
@@ -261,6 +267,10 @@ module.exports =
# Should we allow access to any page without logging in? This includes
# public projects, /learn, /templates, about pages, etc.
allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
+
+ # Use a single compile directory for all users in a project
+ # (otherwise each user has their own directory)
+ # disablePerUserCompiles: true
# Maximum size of text documents in the real-time editing system.
max_doc_length: 2 * 1024 * 1024 # 2mb
@@ -334,7 +344,9 @@ module.exports =
url: "/logout"
}]
}]
-
+
+ customisation: {}
+
# templates: [{
# name : "cv_or_resume",
# url : "/templates/cv"
diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json
new file mode 100644
index 0000000000..89d5dfe8bb
--- /dev/null
+++ b/services/web/npm-shrinkwrap.json
@@ -0,0 +1,3853 @@
+{
+ "name": "web-sharelatex",
+ "version": "0.1.4",
+ "dependencies": {
+ "archiver": {
+ "version": "0.9.0",
+ "from": "archiver@0.9.0",
+ "resolved": "https://registry.npmjs.org/archiver/-/archiver-0.9.0.tgz",
+ "dependencies": {
+ "buffer-crc32": {
+ "version": "0.2.5",
+ "from": "buffer-crc32@~0.2.1",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.5.tgz"
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "from": "readable-stream@~1.0.24",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "from": "isarray@0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "tar-stream": {
+ "version": "0.3.3",
+ "from": "tar-stream@~0.3.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-0.3.3.tgz",
+ "dependencies": {
+ "bl": {
+ "version": "0.6.0",
+ "from": "bl@~0.6.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-0.6.0.tgz"
+ },
+ "end-of-stream": {
+ "version": "0.1.5",
+ "from": "end-of-stream@~0.1.3",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz",
+ "dependencies": {
+ "once": {
+ "version": "1.3.3",
+ "from": "once@~1.3.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "zip-stream": {
+ "version": "0.3.7",
+ "from": "zip-stream@~0.3.0",
+ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-0.3.7.tgz",
+ "dependencies": {
+ "crc32-stream": {
+ "version": "0.2.0",
+ "from": "crc32-stream@~0.2.0",
+ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-0.2.0.tgz"
+ },
+ "debug": {
+ "version": "1.0.4",
+ "from": "debug@~1.0.2",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.6.2",
+ "from": "ms@0.6.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
+ }
+ }
+ },
+ "deflate-crc32-stream": {
+ "version": "0.1.2",
+ "from": "deflate-crc32-stream@~0.1.0",
+ "resolved": "https://registry.npmjs.org/deflate-crc32-stream/-/deflate-crc32-stream-0.1.2.tgz"
+ }
+ }
+ },
+ "lazystream": {
+ "version": "0.1.0",
+ "from": "lazystream@~0.1.0",
+ "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-0.1.0.tgz"
+ },
+ "file-utils": {
+ "version": "0.1.5",
+ "from": "file-utils@~0.1.5",
+ "resolved": "https://registry.npmjs.org/file-utils/-/file-utils-0.1.5.tgz",
+ "dependencies": {
+ "lodash": {
+ "version": "2.1.0",
+ "from": "lodash@~2.1.0",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.1.0.tgz"
+ },
+ "iconv-lite": {
+ "version": "0.2.11",
+ "from": "iconv-lite@~0.2.11",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz"
+ },
+ "glob": {
+ "version": "3.2.11",
+ "from": "glob@~3.2.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "0.3.0",
+ "from": "minimatch@0.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "minimatch": {
+ "version": "0.2.14",
+ "from": "minimatch@~0.2.12",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ },
+ "findup-sync": {
+ "version": "0.1.3",
+ "from": "findup-sync@~0.1.2",
+ "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz",
+ "dependencies": {
+ "lodash": {
+ "version": "2.4.2",
+ "from": "lodash@~2.4.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+ }
+ }
+ },
+ "isbinaryfile": {
+ "version": "0.1.9",
+ "from": "isbinaryfile@~0.1.9",
+ "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-0.1.9.tgz"
+ }
+ }
+ },
+ "lodash": {
+ "version": "2.4.2",
+ "from": "lodash@~2.4.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+ }
+ }
+ },
+ "async": {
+ "version": "0.6.2",
+ "from": "async@0.6.2",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.6.2.tgz"
+ },
+ "base64-stream": {
+ "version": "0.1.3",
+ "from": "base64-stream@^0.1.2",
+ "resolved": "https://registry.npmjs.org/base64-stream/-/base64-stream-0.1.3.tgz",
+ "dependencies": {
+ "readable-stream": {
+ "version": "2.1.4",
+ "from": "readable-stream@^2.0.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+ "dependencies": {
+ "buffer-shims": {
+ "version": "1.0.0",
+ "from": "buffer-shims@^1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "from": "isarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "from": "process-nextick-args@~1.0.6",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "from": "util-deprecate@~1.0.1",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ }
+ }
+ }
+ }
+ },
+ "basic-auth-connect": {
+ "version": "1.0.0",
+ "from": "basic-auth-connect@^1.0.0",
+ "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz"
+ },
+ "bcrypt": {
+ "version": "0.8.3",
+ "from": "bcrypt@0.8.3",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-0.8.3.tgz",
+ "dependencies": {
+ "bindings": {
+ "version": "1.2.1",
+ "from": "bindings@1.2.1",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz"
+ },
+ "nan": {
+ "version": "1.8.4",
+ "from": "nan@1.8.4",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz"
+ }
+ }
+ },
+ "body-parser": {
+ "version": "1.15.1",
+ "from": "body-parser@^1.13.1",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.15.1.tgz",
+ "dependencies": {
+ "bytes": {
+ "version": "2.3.0",
+ "from": "bytes@2.3.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz"
+ },
+ "content-type": {
+ "version": "1.0.2",
+ "from": "content-type@~1.0.1",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@~2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "depd": {
+ "version": "1.1.0",
+ "from": "depd@~1.1.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz"
+ },
+ "http-errors": {
+ "version": "1.4.0",
+ "from": "http-errors@~1.4.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.4.0.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "statuses": {
+ "version": "1.3.0",
+ "from": "statuses@>= 1.2.1 < 2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz"
+ }
+ }
+ },
+ "iconv-lite": {
+ "version": "0.4.13",
+ "from": "iconv-lite@0.4.13",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+ },
+ "on-finished": {
+ "version": "2.3.0",
+ "from": "on-finished@~2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "dependencies": {
+ "ee-first": {
+ "version": "1.1.1",
+ "from": "ee-first@1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
+ }
+ }
+ },
+ "qs": {
+ "version": "6.1.0",
+ "from": "qs@6.1.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.1.0.tgz"
+ },
+ "raw-body": {
+ "version": "2.1.6",
+ "from": "raw-body@~2.1.6",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz",
+ "dependencies": {
+ "unpipe": {
+ "version": "1.0.0",
+ "from": "unpipe@1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
+ }
+ }
+ },
+ "type-is": {
+ "version": "1.6.13",
+ "from": "type-is@~1.6.12",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz",
+ "dependencies": {
+ "media-typer": {
+ "version": "0.3.0",
+ "from": "media-typer@0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+ },
+ "mime-types": {
+ "version": "2.1.11",
+ "from": "mime-types@~2.1.11",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+ "dependencies": {
+ "mime-db": {
+ "version": "1.23.0",
+ "from": "mime-db@~1.23.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "bufferedstream": {
+ "version": "1.6.0",
+ "from": "bufferedstream@1.6.0",
+ "resolved": "https://registry.npmjs.org/bufferedstream/-/bufferedstream-1.6.0.tgz"
+ },
+ "connect-redis": {
+ "version": "2.3.0",
+ "from": "connect-redis@2.3.0",
+ "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
+ "dependencies": {
+ "debug": {
+ "version": "1.0.4",
+ "from": "debug@^1.0.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.6.2",
+ "from": "ms@0.6.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
+ }
+ }
+ },
+ "redis": {
+ "version": "0.12.1",
+ "from": "redis@^0.12.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
+ }
+ }
+ },
+ "contentful": {
+ "version": "3.3.14",
+ "from": "contentful@^3.3.14",
+ "resolved": "https://registry.npmjs.org/contentful/-/contentful-3.3.14.tgz",
+ "dependencies": {
+ "babel-runtime": {
+ "version": "6.9.2",
+ "from": "babel-runtime@^6.3.19",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.9.2.tgz",
+ "dependencies": {
+ "core-js": {
+ "version": "2.4.0",
+ "from": "core-js@^2.4.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.0.tgz"
+ },
+ "regenerator-runtime": {
+ "version": "0.9.5",
+ "from": "regenerator-runtime@^0.9.5",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz"
+ }
+ }
+ },
+ "contentful-sdk-core": {
+ "version": "2.2.4",
+ "from": "contentful-sdk-core@^2.2.1",
+ "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-2.2.4.tgz",
+ "dependencies": {
+ "follow-redirects": {
+ "version": "0.0.7",
+ "from": "follow-redirects@0.0.7",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.7.tgz",
+ "dependencies": {
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@^2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "stream-consume": {
+ "version": "0.1.0",
+ "from": "stream-consume@^0.1.0",
+ "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz"
+ }
+ }
+ },
+ "qs": {
+ "version": "6.2.0",
+ "from": "qs@^6.1.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.0.tgz"
+ }
+ }
+ },
+ "json-stringify-safe": {
+ "version": "5.0.1",
+ "from": "json-stringify-safe@^5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
+ }
+ }
+ },
+ "cookie": {
+ "version": "0.2.4",
+ "from": "cookie@^0.2.3",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.2.4.tgz"
+ },
+ "cookie-parser": {
+ "version": "1.3.5",
+ "from": "cookie-parser@1.3.5",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.3.5.tgz",
+ "dependencies": {
+ "cookie": {
+ "version": "0.1.3",
+ "from": "cookie@0.1.3",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "from": "cookie-signature@1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+ }
+ }
+ },
+ "csurf": {
+ "version": "1.9.0",
+ "from": "csurf@^1.8.3",
+ "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.9.0.tgz",
+ "dependencies": {
+ "cookie": {
+ "version": "0.3.1",
+ "from": "cookie@0.3.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "from": "cookie-signature@1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+ },
+ "csrf": {
+ "version": "3.0.3",
+ "from": "csrf@~3.0.3",
+ "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.0.3.tgz",
+ "dependencies": {
+ "base64-url": {
+ "version": "1.2.2",
+ "from": "base64-url@1.2.2",
+ "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.2.tgz"
+ },
+ "rndm": {
+ "version": "1.2.0",
+ "from": "rndm@1.2.0",
+ "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz"
+ },
+ "tsscmp": {
+ "version": "1.0.5",
+ "from": "tsscmp@1.0.5",
+ "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz"
+ },
+ "uid-safe": {
+ "version": "2.1.1",
+ "from": "uid-safe@2.1.1",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.1.tgz",
+ "dependencies": {
+ "random-bytes": {
+ "version": "1.0.0",
+ "from": "random-bytes@~1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "http-errors": {
+ "version": "1.5.0",
+ "from": "http-errors@~1.5.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.0.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "setprototypeof": {
+ "version": "1.0.1",
+ "from": "setprototypeof@1.0.1",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.1.tgz"
+ },
+ "statuses": {
+ "version": "1.3.0",
+ "from": "statuses@>= 1.3.0 < 2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "dateformat": {
+ "version": "1.0.4-1.2.3",
+ "from": "dateformat@1.0.4-1.2.3",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.4-1.2.3.tgz"
+ },
+ "express": {
+ "version": "4.13.0",
+ "from": "express@4.13.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.13.0.tgz",
+ "dependencies": {
+ "accepts": {
+ "version": "1.2.13",
+ "from": "accepts@~1.2.9",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.2.13.tgz",
+ "dependencies": {
+ "mime-types": {
+ "version": "2.1.11",
+ "from": "mime-types@~2.1.11",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+ "dependencies": {
+ "mime-db": {
+ "version": "1.23.0",
+ "from": "mime-db@~1.23.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+ }
+ }
+ },
+ "negotiator": {
+ "version": "0.5.3",
+ "from": "negotiator@0.5.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.5.3.tgz"
+ }
+ }
+ },
+ "array-flatten": {
+ "version": "1.1.0",
+ "from": "array-flatten@1.1.0",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.0.tgz"
+ },
+ "content-disposition": {
+ "version": "0.5.0",
+ "from": "content-disposition@0.5.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.0.tgz"
+ },
+ "content-type": {
+ "version": "1.0.2",
+ "from": "content-type@~1.0.1",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz"
+ },
+ "cookie": {
+ "version": "0.1.3",
+ "from": "cookie@0.1.3",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "from": "cookie-signature@1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@~2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "depd": {
+ "version": "1.0.1",
+ "from": "depd@~1.0.1",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
+ },
+ "escape-html": {
+ "version": "1.0.2",
+ "from": "escape-html@1.0.2",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.2.tgz"
+ },
+ "etag": {
+ "version": "1.7.0",
+ "from": "etag@~1.7.0",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz"
+ },
+ "finalhandler": {
+ "version": "0.4.0",
+ "from": "finalhandler@0.4.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.4.0.tgz",
+ "dependencies": {
+ "unpipe": {
+ "version": "1.0.0",
+ "from": "unpipe@~1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
+ }
+ }
+ },
+ "fresh": {
+ "version": "0.3.0",
+ "from": "fresh@0.3.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz"
+ },
+ "merge-descriptors": {
+ "version": "1.0.0",
+ "from": "merge-descriptors@1.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.0.tgz"
+ },
+ "methods": {
+ "version": "1.1.2",
+ "from": "methods@~1.1.1",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
+ },
+ "on-finished": {
+ "version": "2.3.0",
+ "from": "on-finished@~2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "dependencies": {
+ "ee-first": {
+ "version": "1.1.1",
+ "from": "ee-first@1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
+ }
+ }
+ },
+ "parseurl": {
+ "version": "1.3.1",
+ "from": "parseurl@~1.3.0",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+ },
+ "path-to-regexp": {
+ "version": "0.1.6",
+ "from": "path-to-regexp@0.1.6",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.6.tgz"
+ },
+ "proxy-addr": {
+ "version": "1.0.10",
+ "from": "proxy-addr@~1.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.10.tgz",
+ "dependencies": {
+ "forwarded": {
+ "version": "0.1.0",
+ "from": "forwarded@~0.1.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz"
+ },
+ "ipaddr.js": {
+ "version": "1.0.5",
+ "from": "ipaddr.js@1.0.5",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.0.5.tgz"
+ }
+ }
+ },
+ "qs": {
+ "version": "2.4.2",
+ "from": "qs@2.4.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz"
+ },
+ "range-parser": {
+ "version": "1.0.3",
+ "from": "range-parser@~1.0.2",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.0.3.tgz"
+ },
+ "send": {
+ "version": "0.13.0",
+ "from": "send@0.13.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.13.0.tgz",
+ "dependencies": {
+ "destroy": {
+ "version": "1.0.3",
+ "from": "destroy@1.0.3",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.3.tgz"
+ },
+ "http-errors": {
+ "version": "1.3.1",
+ "from": "http-errors@~1.3.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "mime": {
+ "version": "1.3.4",
+ "from": "mime@1.3.4",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+ },
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ },
+ "statuses": {
+ "version": "1.2.1",
+ "from": "statuses@~1.2.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz"
+ }
+ }
+ },
+ "serve-static": {
+ "version": "1.10.3",
+ "from": "serve-static@~1.10.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.10.3.tgz",
+ "dependencies": {
+ "escape-html": {
+ "version": "1.0.3",
+ "from": "escape-html@~1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
+ },
+ "send": {
+ "version": "0.13.2",
+ "from": "send@0.13.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.13.2.tgz",
+ "dependencies": {
+ "depd": {
+ "version": "1.1.0",
+ "from": "depd@~1.1.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz"
+ },
+ "destroy": {
+ "version": "1.0.4",
+ "from": "destroy@~1.0.4",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz"
+ },
+ "http-errors": {
+ "version": "1.3.1",
+ "from": "http-errors@~1.3.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "mime": {
+ "version": "1.3.4",
+ "from": "mime@1.3.4",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+ },
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ },
+ "statuses": {
+ "version": "1.2.1",
+ "from": "statuses@~1.2.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "type-is": {
+ "version": "1.6.13",
+ "from": "type-is@~1.6.3",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz",
+ "dependencies": {
+ "media-typer": {
+ "version": "0.3.0",
+ "from": "media-typer@0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+ },
+ "mime-types": {
+ "version": "2.1.11",
+ "from": "mime-types@~2.1.11",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+ "dependencies": {
+ "mime-db": {
+ "version": "1.23.0",
+ "from": "mime-db@~1.23.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "vary": {
+ "version": "1.0.1",
+ "from": "vary@~1.0.0",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.0.1.tgz"
+ },
+ "utils-merge": {
+ "version": "1.0.0",
+ "from": "utils-merge@1.0.0",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
+ }
+ }
+ },
+ "express-session": {
+ "version": "1.11.3",
+ "from": "express-session@1.11.3",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
+ "dependencies": {
+ "cookie": {
+ "version": "0.1.3",
+ "from": "cookie@0.1.3",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "from": "cookie-signature@1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
+ },
+ "crc": {
+ "version": "3.3.0",
+ "from": "crc@3.3.0",
+ "resolved": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@~2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "depd": {
+ "version": "1.0.1",
+ "from": "depd@~1.0.1",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
+ },
+ "on-headers": {
+ "version": "1.0.1",
+ "from": "on-headers@~1.0.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz"
+ },
+ "parseurl": {
+ "version": "1.3.1",
+ "from": "parseurl@~1.3.0",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+ },
+ "uid-safe": {
+ "version": "2.0.0",
+ "from": "uid-safe@~2.0.0",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
+ "dependencies": {
+ "base64-url": {
+ "version": "1.2.1",
+ "from": "base64-url@1.2.1",
+ "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz"
+ }
+ }
+ },
+ "utils-merge": {
+ "version": "1.0.0",
+ "from": "utils-merge@1.0.0",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
+ }
+ }
+ },
+ "grunt": {
+ "version": "0.4.5",
+ "from": "grunt@^0.4.5",
+ "resolved": "https://registry.npmjs.org/grunt/-/grunt-0.4.5.tgz",
+ "dependencies": {
+ "async": {
+ "version": "0.1.22",
+ "from": "async@~0.1.22",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.1.22.tgz"
+ },
+ "coffee-script": {
+ "version": "1.3.3",
+ "from": "coffee-script@~1.3.3",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.3.3.tgz"
+ },
+ "colors": {
+ "version": "0.6.2",
+ "from": "colors@~0.6.2",
+ "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz"
+ },
+ "dateformat": {
+ "version": "1.0.2-1.2.3",
+ "from": "dateformat@1.0.2-1.2.3",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.2-1.2.3.tgz"
+ },
+ "eventemitter2": {
+ "version": "0.4.14",
+ "from": "eventemitter2@~0.4.13",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz"
+ },
+ "findup-sync": {
+ "version": "0.1.3",
+ "from": "findup-sync@~0.1.2",
+ "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.1.3.tgz",
+ "dependencies": {
+ "glob": {
+ "version": "3.2.11",
+ "from": "glob@~3.2.9",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "0.3.0",
+ "from": "minimatch@0.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "lodash": {
+ "version": "2.4.2",
+ "from": "lodash@~2.4.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+ }
+ }
+ },
+ "glob": {
+ "version": "3.1.21",
+ "from": "glob@~3.1.21",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz",
+ "dependencies": {
+ "graceful-fs": {
+ "version": "1.2.3",
+ "from": "graceful-fs@~1.2.0",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz"
+ },
+ "inherits": {
+ "version": "1.0.2",
+ "from": "inherits@1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz"
+ }
+ }
+ },
+ "hooker": {
+ "version": "0.2.3",
+ "from": "hooker@~0.2.3",
+ "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz"
+ },
+ "iconv-lite": {
+ "version": "0.2.11",
+ "from": "iconv-lite@~0.2.11",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.2.11.tgz"
+ },
+ "minimatch": {
+ "version": "0.2.14",
+ "from": "minimatch@~0.2.12",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ },
+ "nopt": {
+ "version": "1.0.10",
+ "from": "nopt@~1.0.10",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
+ "dependencies": {
+ "abbrev": {
+ "version": "1.0.7",
+ "from": "abbrev@1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz"
+ }
+ }
+ },
+ "rimraf": {
+ "version": "2.2.8",
+ "from": "rimraf@~2.2.8",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz"
+ },
+ "lodash": {
+ "version": "0.9.2",
+ "from": "lodash@~0.9.2",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-0.9.2.tgz"
+ },
+ "underscore.string": {
+ "version": "2.2.1",
+ "from": "underscore.string@~2.2.1",
+ "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.2.1.tgz"
+ },
+ "which": {
+ "version": "1.0.9",
+ "from": "which@~1.0.5",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.0.9.tgz"
+ },
+ "js-yaml": {
+ "version": "2.0.5",
+ "from": "js-yaml@~2.0.5",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-2.0.5.tgz",
+ "dependencies": {
+ "argparse": {
+ "version": "0.1.16",
+ "from": "argparse@~ 0.1.11",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz",
+ "dependencies": {
+ "underscore": {
+ "version": "1.7.0",
+ "from": "underscore@~1.7.0",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz"
+ },
+ "underscore.string": {
+ "version": "2.4.0",
+ "from": "underscore.string@~2.4.0",
+ "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz"
+ }
+ }
+ },
+ "esprima": {
+ "version": "1.0.4",
+ "from": "esprima@~ 1.0.2",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz"
+ }
+ }
+ },
+ "exit": {
+ "version": "0.1.2",
+ "from": "exit@~0.1.1",
+ "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz"
+ },
+ "getobject": {
+ "version": "0.1.0",
+ "from": "getobject@~0.1.0",
+ "resolved": "https://registry.npmjs.org/getobject/-/getobject-0.1.0.tgz"
+ },
+ "grunt-legacy-util": {
+ "version": "0.2.0",
+ "from": "grunt-legacy-util@~0.2.0",
+ "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-0.2.0.tgz"
+ },
+ "grunt-legacy-log": {
+ "version": "0.1.3",
+ "from": "grunt-legacy-log@~0.1.0",
+ "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-0.1.3.tgz",
+ "dependencies": {
+ "grunt-legacy-log-utils": {
+ "version": "0.1.1",
+ "from": "grunt-legacy-log-utils@~0.1.1",
+ "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-0.1.1.tgz"
+ },
+ "lodash": {
+ "version": "2.4.2",
+ "from": "lodash@~2.4.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+ },
+ "underscore.string": {
+ "version": "2.3.3",
+ "from": "underscore.string@~2.3.3",
+ "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.3.3.tgz"
+ }
+ }
+ }
+ }
+ },
+ "heapdump": {
+ "version": "0.3.7",
+ "from": "heapdump@^0.3.7",
+ "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.7.tgz"
+ },
+ "http-proxy": {
+ "version": "1.13.3",
+ "from": "http-proxy@^1.8.1",
+ "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.13.3.tgz",
+ "dependencies": {
+ "eventemitter3": {
+ "version": "1.2.0",
+ "from": "eventemitter3@1.x.x",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz"
+ },
+ "requires-port": {
+ "version": "1.0.0",
+ "from": "requires-port@1.x.x",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
+ }
+ }
+ },
+ "jade": {
+ "version": "1.3.1",
+ "from": "jade@~1.3.1",
+ "resolved": "https://registry.npmjs.org/jade/-/jade-1.3.1.tgz",
+ "dependencies": {
+ "commander": {
+ "version": "2.1.0",
+ "from": "commander@2.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz"
+ },
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@~0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ },
+ "transformers": {
+ "version": "2.1.0",
+ "from": "transformers@2.1.0",
+ "resolved": "https://registry.npmjs.org/transformers/-/transformers-2.1.0.tgz",
+ "dependencies": {
+ "promise": {
+ "version": "2.0.0",
+ "from": "promise@~2.0",
+ "resolved": "https://registry.npmjs.org/promise/-/promise-2.0.0.tgz",
+ "dependencies": {
+ "is-promise": {
+ "version": "1.0.1",
+ "from": "is-promise@~1",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz"
+ }
+ }
+ },
+ "css": {
+ "version": "1.0.8",
+ "from": "css@~1.0.8",
+ "resolved": "https://registry.npmjs.org/css/-/css-1.0.8.tgz",
+ "dependencies": {
+ "css-parse": {
+ "version": "1.0.4",
+ "from": "css-parse@1.0.4",
+ "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-1.0.4.tgz"
+ },
+ "css-stringify": {
+ "version": "1.0.5",
+ "from": "css-stringify@1.0.5",
+ "resolved": "https://registry.npmjs.org/css-stringify/-/css-stringify-1.0.5.tgz"
+ }
+ }
+ },
+ "uglify-js": {
+ "version": "2.2.5",
+ "from": "uglify-js@~2.2.5",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.2.5.tgz",
+ "dependencies": {
+ "source-map": {
+ "version": "0.1.43",
+ "from": "source-map@~0.1.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz",
+ "dependencies": {
+ "amdefine": {
+ "version": "1.0.0",
+ "from": "amdefine@>=0.0.4",
+ "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+ }
+ }
+ },
+ "optimist": {
+ "version": "0.3.7",
+ "from": "optimist@~0.3.5",
+ "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz",
+ "dependencies": {
+ "wordwrap": {
+ "version": "0.0.3",
+ "from": "wordwrap@~0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "character-parser": {
+ "version": "1.2.0",
+ "from": "character-parser@1.2.0",
+ "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-1.2.0.tgz"
+ },
+ "monocle": {
+ "version": "1.1.51",
+ "from": "monocle@1.1.51",
+ "resolved": "https://registry.npmjs.org/monocle/-/monocle-1.1.51.tgz",
+ "dependencies": {
+ "readdirp": {
+ "version": "0.2.5",
+ "from": "readdirp@~0.2.3",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-0.2.5.tgz",
+ "dependencies": {
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@>=0.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "with": {
+ "version": "3.0.1",
+ "from": "with@~3.0.0",
+ "resolved": "https://registry.npmjs.org/with/-/with-3.0.1.tgz",
+ "dependencies": {
+ "uglify-js": {
+ "version": "2.4.24",
+ "from": "uglify-js@~2.4.12",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.4.24.tgz",
+ "dependencies": {
+ "async": {
+ "version": "0.2.10",
+ "from": "async@~0.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
+ },
+ "source-map": {
+ "version": "0.1.34",
+ "from": "source-map@0.1.34",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.34.tgz",
+ "dependencies": {
+ "amdefine": {
+ "version": "1.0.0",
+ "from": "amdefine@>=0.0.4",
+ "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+ }
+ }
+ },
+ "uglify-to-browserify": {
+ "version": "1.0.2",
+ "from": "uglify-to-browserify@~1.0.0",
+ "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz"
+ },
+ "yargs": {
+ "version": "3.5.4",
+ "from": "yargs@~3.5.4",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz",
+ "dependencies": {
+ "camelcase": {
+ "version": "1.2.1",
+ "from": "camelcase@^1.0.2",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz"
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "from": "decamelize@^1.0.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
+ },
+ "window-size": {
+ "version": "0.1.0",
+ "from": "window-size@0.1.0",
+ "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz"
+ },
+ "wordwrap": {
+ "version": "0.0.2",
+ "from": "wordwrap@0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "constantinople": {
+ "version": "2.0.1",
+ "from": "constantinople@~2.0.0",
+ "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-2.0.1.tgz",
+ "dependencies": {
+ "uglify-js": {
+ "version": "2.4.24",
+ "from": "uglify-js@~2.4.0",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.4.24.tgz",
+ "dependencies": {
+ "async": {
+ "version": "0.2.10",
+ "from": "async@~0.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
+ },
+ "source-map": {
+ "version": "0.1.34",
+ "from": "source-map@0.1.34",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.34.tgz",
+ "dependencies": {
+ "amdefine": {
+ "version": "1.0.0",
+ "from": "amdefine@>=0.0.4",
+ "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz"
+ }
+ }
+ },
+ "uglify-to-browserify": {
+ "version": "1.0.2",
+ "from": "uglify-to-browserify@~1.0.0",
+ "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz"
+ },
+ "yargs": {
+ "version": "3.5.4",
+ "from": "yargs@~3.5.4",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz",
+ "dependencies": {
+ "camelcase": {
+ "version": "1.2.1",
+ "from": "camelcase@^1.0.2",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz"
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "from": "decamelize@^1.0.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
+ },
+ "window-size": {
+ "version": "0.1.0",
+ "from": "window-size@0.1.0",
+ "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz"
+ },
+ "wordwrap": {
+ "version": "0.0.2",
+ "from": "wordwrap@0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "ldapjs": {
+ "version": "1.0.0",
+ "from": "ldapjs@^1.0.0",
+ "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.0.tgz",
+ "dependencies": {
+ "asn1": {
+ "version": "0.2.3",
+ "from": "asn1@0.2.3",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz"
+ },
+ "assert-plus": {
+ "version": "0.1.5",
+ "from": "assert-plus@0.1.5",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz"
+ },
+ "bunyan": {
+ "version": "1.5.1",
+ "from": "bunyan@1.5.1",
+ "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.5.1.tgz",
+ "dependencies": {
+ "mv": {
+ "version": "2.1.1",
+ "from": "mv@~2",
+ "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+ "dependencies": {
+ "mkdirp": {
+ "version": "0.5.1",
+ "from": "mkdirp@~0.5.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "dependencies": {
+ "minimist": {
+ "version": "0.0.8",
+ "from": "minimist@0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+ }
+ }
+ },
+ "ncp": {
+ "version": "2.0.0",
+ "from": "ncp@~2.0.0",
+ "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
+ },
+ "rimraf": {
+ "version": "2.4.5",
+ "from": "rimraf@~2.4.0",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+ "dependencies": {
+ "glob": {
+ "version": "6.0.4",
+ "from": "glob@^6.0.1",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+ "dependencies": {
+ "inflight": {
+ "version": "1.0.5",
+ "from": "inflight@^1.0.4",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@2 || 3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.0",
+ "from": "path-is-absolute@^1.0.0",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "safe-json-stringify": {
+ "version": "1.0.3",
+ "from": "safe-json-stringify@~1",
+ "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.3.tgz"
+ }
+ }
+ },
+ "dashdash": {
+ "version": "1.10.1",
+ "from": "dashdash@1.10.1",
+ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.10.1.tgz"
+ },
+ "backoff": {
+ "version": "2.4.1",
+ "from": "backoff@2.4.1",
+ "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.4.1.tgz",
+ "dependencies": {
+ "precond": {
+ "version": "0.2.3",
+ "from": "precond@0.2",
+ "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz"
+ }
+ }
+ },
+ "ldap-filter": {
+ "version": "0.2.2",
+ "from": "ldap-filter@0.2.2",
+ "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz"
+ },
+ "once": {
+ "version": "1.3.2",
+ "from": "once@1.3.2",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.2.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "vasync": {
+ "version": "1.6.3",
+ "from": "vasync@1.6.3",
+ "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.3.tgz"
+ },
+ "verror": {
+ "version": "1.6.0",
+ "from": "verror@1.6.0",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz",
+ "dependencies": {
+ "extsprintf": {
+ "version": "1.2.0",
+ "from": "extsprintf@1.2.0",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz"
+ }
+ }
+ },
+ "dtrace-provider": {
+ "version": "0.6.0",
+ "from": "dtrace-provider@~0.6",
+ "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.6.0.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "2.3.5",
+ "from": "nan@^2.0.8",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+ }
+ }
+ }
+ }
+ },
+ "lodash": {
+ "version": "4.13.1",
+ "from": "lodash@^4.13.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.13.1.tgz"
+ },
+ "logger-sharelatex": {
+ "version": "1.3.1",
+ "from": "logger-sharelatex@git+https://github.com/sharelatex/logger-sharelatex.git#v1.3.1",
+ "resolved": "git+https://github.com/sharelatex/logger-sharelatex.git#bf413ec621a000cf0e08c939de38d5e24541a08c",
+ "dependencies": {
+ "bunyan": {
+ "version": "1.5.1",
+ "from": "bunyan@1.5.1",
+ "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.5.1.tgz",
+ "dependencies": {
+ "dtrace-provider": {
+ "version": "0.6.0",
+ "from": "dtrace-provider@~0.6",
+ "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.6.0.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "2.3.5",
+ "from": "nan@^2.0.8",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+ }
+ }
+ },
+ "mv": {
+ "version": "2.1.1",
+ "from": "mv@~2",
+ "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+ "dependencies": {
+ "mkdirp": {
+ "version": "0.5.1",
+ "from": "mkdirp@~0.5.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "dependencies": {
+ "minimist": {
+ "version": "0.0.8",
+ "from": "minimist@0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+ }
+ }
+ },
+ "ncp": {
+ "version": "2.0.0",
+ "from": "ncp@~2.0.0",
+ "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
+ },
+ "rimraf": {
+ "version": "2.4.5",
+ "from": "rimraf@~2.4.0",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+ "dependencies": {
+ "glob": {
+ "version": "6.0.4",
+ "from": "glob@^6.0.1",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+ "dependencies": {
+ "inflight": {
+ "version": "1.0.5",
+ "from": "inflight@^1.0.4",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@2 || 3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "once": {
+ "version": "1.3.3",
+ "from": "once@^1.3.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.0",
+ "from": "path-is-absolute@^1.0.0",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "safe-json-stringify": {
+ "version": "1.0.3",
+ "from": "safe-json-stringify@~1",
+ "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.3.tgz"
+ }
+ }
+ },
+ "coffee-script": {
+ "version": "1.4.0",
+ "from": "coffee-script@1.4.0",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.4.0.tgz"
+ },
+ "raven": {
+ "version": "0.8.1",
+ "from": "raven@^0.8.0",
+ "resolved": "https://registry.npmjs.org/raven/-/raven-0.8.1.tgz",
+ "dependencies": {
+ "cookie": {
+ "version": "0.1.0",
+ "from": "cookie@0.1.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.0.tgz"
+ },
+ "lsmod": {
+ "version": "0.0.3",
+ "from": "lsmod@~0.0.3",
+ "resolved": "https://registry.npmjs.org/lsmod/-/lsmod-0.0.3.tgz"
+ },
+ "stack-trace": {
+ "version": "0.0.7",
+ "from": "stack-trace@0.0.7",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.7.tgz"
+ }
+ }
+ }
+ }
+ },
+ "lynx": {
+ "version": "0.1.1",
+ "from": "lynx@0.1.1",
+ "resolved": "https://registry.npmjs.org/lynx/-/lynx-0.1.1.tgz",
+ "dependencies": {
+ "mersenne": {
+ "version": "0.0.3",
+ "from": "mersenne@~0.0.3",
+ "resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.3.tgz"
+ },
+ "statsd-parser": {
+ "version": "0.0.4",
+ "from": "statsd-parser@~0.0.4",
+ "resolved": "https://registry.npmjs.org/statsd-parser/-/statsd-parser-0.0.4.tgz"
+ }
+ }
+ },
+ "marked": {
+ "version": "0.3.5",
+ "from": "marked@^0.3.5",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.5.tgz"
+ },
+ "method-override": {
+ "version": "2.3.6",
+ "from": "method-override@^2.3.3",
+ "resolved": "https://registry.npmjs.org/method-override/-/method-override-2.3.6.tgz",
+ "dependencies": {
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@~2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "methods": {
+ "version": "1.1.2",
+ "from": "methods@~1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
+ },
+ "parseurl": {
+ "version": "1.3.1",
+ "from": "parseurl@~1.3.1",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
+ },
+ "vary": {
+ "version": "1.1.0",
+ "from": "vary@~1.1.0",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.0.tgz"
+ }
+ }
+ },
+ "metrics-sharelatex": {
+ "version": "1.3.0",
+ "from": "metrics-sharelatex@git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
+ "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#080c4aeb696edcd5d6d86f202f2c528f0661d7a6",
+ "dependencies": {
+ "coffee-script": {
+ "version": "1.6.0",
+ "from": "coffee-script@1.6.0",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz"
+ }
+ }
+ },
+ "mimelib": {
+ "version": "0.2.14",
+ "from": "mimelib@0.2.14",
+ "resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.2.14.tgz",
+ "dependencies": {
+ "encoding": {
+ "version": "0.1.12",
+ "from": "encoding@~0.1",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
+ "dependencies": {
+ "iconv-lite": {
+ "version": "0.4.13",
+ "from": "iconv-lite@~0.4.13",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+ }
+ }
+ },
+ "addressparser": {
+ "version": "0.2.1",
+ "from": "addressparser@~0.2.0",
+ "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.2.1.tgz"
+ }
+ }
+ },
+ "mocha": {
+ "version": "1.17.1",
+ "from": "mocha@1.17.1",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.17.1.tgz",
+ "dependencies": {
+ "commander": {
+ "version": "2.0.0",
+ "from": "commander@2.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz"
+ },
+ "growl": {
+ "version": "1.7.0",
+ "from": "growl@1.7.x",
+ "resolved": "https://registry.npmjs.org/growl/-/growl-1.7.0.tgz"
+ },
+ "jade": {
+ "version": "0.26.3",
+ "from": "jade@0.26.3",
+ "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz",
+ "dependencies": {
+ "commander": {
+ "version": "0.6.1",
+ "from": "commander@0.6.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz"
+ },
+ "mkdirp": {
+ "version": "0.3.0",
+ "from": "mkdirp@0.3.0",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
+ }
+ }
+ },
+ "diff": {
+ "version": "1.0.7",
+ "from": "diff@1.0.7",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-1.0.7.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@*",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ },
+ "glob": {
+ "version": "3.2.3",
+ "from": "glob@3.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz",
+ "dependencies": {
+ "minimatch": {
+ "version": "0.2.14",
+ "from": "minimatch@~0.2.11",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ },
+ "graceful-fs": {
+ "version": "2.0.3",
+ "from": "graceful-fs@~2.0.0",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "mongojs": {
+ "version": "0.18.2",
+ "from": "mongojs@0.18.2",
+ "resolved": "https://registry.npmjs.org/mongojs/-/mongojs-0.18.2.tgz",
+ "dependencies": {
+ "thunky": {
+ "version": "0.1.0",
+ "from": "thunky@~0.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz"
+ },
+ "readable-stream": {
+ "version": "1.1.14",
+ "from": "readable-stream@1.1.x",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "from": "isarray@0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "mongodb": {
+ "version": "1.4.32",
+ "from": "mongodb@1.4.32",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-1.4.32.tgz",
+ "dependencies": {
+ "bson": {
+ "version": "0.2.22",
+ "from": "bson@~0.2",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-0.2.22.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "1.8.4",
+ "from": "nan@~1.8",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz"
+ }
+ }
+ },
+ "kerberos": {
+ "version": "0.0.9",
+ "from": "kerberos@0.0.9",
+ "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.9.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "1.6.2",
+ "from": "nan@1.6.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-1.6.2.tgz"
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "2.1.4",
+ "from": "readable-stream@latest",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+ "dependencies": {
+ "buffer-shims": {
+ "version": "1.0.0",
+ "from": "buffer-shims@^1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "from": "isarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "from": "process-nextick-args@~1.0.6",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "from": "util-deprecate@~1.0.1",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "mongoose": {
+ "version": "4.1.0",
+ "from": "mongoose@4.1.0",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-4.1.0.tgz",
+ "dependencies": {
+ "async": {
+ "version": "0.9.0",
+ "from": "async@0.9.0",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz"
+ },
+ "bson": {
+ "version": "0.3.2",
+ "from": "bson@~0.3",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-0.3.2.tgz",
+ "dependencies": {
+ "bson-ext": {
+ "version": "0.1.13",
+ "from": "bson-ext@~0.1",
+ "resolved": "https://registry.npmjs.org/bson-ext/-/bson-ext-0.1.13.tgz",
+ "dependencies": {
+ "bindings": {
+ "version": "1.2.1",
+ "from": "bindings@^1.2.1",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz"
+ },
+ "nan": {
+ "version": "2.0.9",
+ "from": "nan@~2.0.9",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.0.9.tgz"
+ }
+ }
+ }
+ }
+ },
+ "hooks-fixed": {
+ "version": "1.1.0",
+ "from": "hooks-fixed@1.1.0",
+ "resolved": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-1.1.0.tgz"
+ },
+ "kareem": {
+ "version": "1.0.1",
+ "from": "kareem@1.0.1",
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-1.0.1.tgz"
+ },
+ "mongodb": {
+ "version": "2.0.34",
+ "from": "mongodb@2.0.34",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.0.34.tgz",
+ "dependencies": {
+ "mongodb-core": {
+ "version": "1.2.0",
+ "from": "mongodb-core@1.2.0",
+ "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-1.2.0.tgz",
+ "dependencies": {
+ "bson": {
+ "version": "0.4.23",
+ "from": "bson@~0.4",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-0.4.23.tgz"
+ },
+ "kerberos": {
+ "version": "0.0.21",
+ "from": "kerberos@~0.0",
+ "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.21.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "2.3.5",
+ "from": "nan@~2.3",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+ }
+ }
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "1.0.31",
+ "from": "readable-stream@1.0.31",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.31.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "from": "isarray@0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "mpath": {
+ "version": "0.1.1",
+ "from": "mpath@0.1.1",
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.1.1.tgz"
+ },
+ "mpromise": {
+ "version": "0.5.4",
+ "from": "mpromise@0.5.4",
+ "resolved": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.4.tgz"
+ },
+ "mquery": {
+ "version": "1.6.1",
+ "from": "mquery@1.6.1",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-1.6.1.tgz",
+ "dependencies": {
+ "bluebird": {
+ "version": "2.9.26",
+ "from": "bluebird@2.9.26",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.9.26.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "ms": {
+ "version": "0.1.0",
+ "from": "ms@0.1.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.1.0.tgz"
+ },
+ "muri": {
+ "version": "1.0.0",
+ "from": "muri@1.0.0",
+ "resolved": "https://registry.npmjs.org/muri/-/muri-1.0.0.tgz"
+ },
+ "regexp-clone": {
+ "version": "0.0.1",
+ "from": "regexp-clone@0.0.1",
+ "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz"
+ },
+ "sliced": {
+ "version": "0.0.5",
+ "from": "sliced@0.0.5",
+ "resolved": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz"
+ }
+ }
+ },
+ "multer": {
+ "version": "0.1.8",
+ "from": "multer@^0.1.8",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-0.1.8.tgz",
+ "dependencies": {
+ "busboy": {
+ "version": "0.2.13",
+ "from": "busboy@~0.2.9",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.13.tgz",
+ "dependencies": {
+ "dicer": {
+ "version": "0.2.5",
+ "from": "dicer@0.2.5",
+ "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
+ "dependencies": {
+ "streamsearch": {
+ "version": "0.1.2",
+ "from": "streamsearch@0.1.2",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz"
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "1.1.14",
+ "from": "readable-stream@1.1.x",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "from": "isarray@0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@~0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ },
+ "qs": {
+ "version": "1.2.2",
+ "from": "qs@~1.2.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-1.2.2.tgz"
+ },
+ "type-is": {
+ "version": "1.5.7",
+ "from": "type-is@~1.5.2",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.5.7.tgz",
+ "dependencies": {
+ "media-typer": {
+ "version": "0.3.0",
+ "from": "media-typer@0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
+ },
+ "mime-types": {
+ "version": "2.0.14",
+ "from": "mime-types@~2.0.9",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.0.14.tgz",
+ "dependencies": {
+ "mime-db": {
+ "version": "1.12.0",
+ "from": "mime-db@~1.12.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.12.0.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "node-uuid": {
+ "version": "1.4.1",
+ "from": "node-uuid@1.4.1",
+ "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.1.tgz"
+ },
+ "nodemailer": {
+ "version": "2.1.0",
+ "from": "nodemailer@2.1.0",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-2.1.0.tgz",
+ "dependencies": {
+ "libmime": {
+ "version": "2.0.0",
+ "from": "libmime@2.0.0",
+ "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.0.0.tgz",
+ "dependencies": {
+ "iconv-lite": {
+ "version": "0.4.13",
+ "from": "iconv-lite@0.4.13",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz"
+ },
+ "libbase64": {
+ "version": "0.1.0",
+ "from": "libbase64@0.1.0",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz"
+ },
+ "libqp": {
+ "version": "1.1.0",
+ "from": "libqp@1.1.0",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz"
+ }
+ }
+ },
+ "mailcomposer": {
+ "version": "3.3.2",
+ "from": "mailcomposer@3.3.2",
+ "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.3.2.tgz",
+ "dependencies": {
+ "buildmail": {
+ "version": "3.3.2",
+ "from": "buildmail@3.3.2",
+ "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-3.3.2.tgz",
+ "dependencies": {
+ "addressparser": {
+ "version": "1.0.0",
+ "from": "addressparser@1.0.0",
+ "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.0.tgz"
+ },
+ "libbase64": {
+ "version": "0.1.0",
+ "from": "libbase64@0.1.0",
+ "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz"
+ },
+ "libqp": {
+ "version": "1.1.0",
+ "from": "libqp@1.1.0",
+ "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz"
+ },
+ "nodemailer-fetch": {
+ "version": "1.2.1",
+ "from": "nodemailer-fetch@1.2.1",
+ "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.2.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "nodemailer-direct-transport": {
+ "version": "2.0.1",
+ "from": "nodemailer-direct-transport@2.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-2.0.1.tgz",
+ "dependencies": {
+ "smtp-connection": {
+ "version": "2.0.1",
+ "from": "smtp-connection@2.0.1",
+ "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+ }
+ }
+ },
+ "nodemailer-shared": {
+ "version": "1.0.3",
+ "from": "nodemailer-shared@1.0.3",
+ "resolved": "https://registry.npmjs.org/nodemailer-shared/-/nodemailer-shared-1.0.3.tgz",
+ "dependencies": {
+ "nodemailer-fetch": {
+ "version": "1.2.1",
+ "from": "nodemailer-fetch@1.2.1",
+ "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.2.1.tgz"
+ }
+ }
+ },
+ "nodemailer-smtp-pool": {
+ "version": "2.1.0",
+ "from": "nodemailer-smtp-pool@2.1.0",
+ "resolved": "https://registry.npmjs.org/nodemailer-smtp-pool/-/nodemailer-smtp-pool-2.1.0.tgz",
+ "dependencies": {
+ "clone": {
+ "version": "1.0.2",
+ "from": "clone@1.0.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz"
+ },
+ "nodemailer-wellknown": {
+ "version": "0.1.7",
+ "from": "nodemailer-wellknown@0.1.7",
+ "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.7.tgz"
+ },
+ "smtp-connection": {
+ "version": "2.0.1",
+ "from": "smtp-connection@2.0.1",
+ "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+ }
+ }
+ },
+ "nodemailer-smtp-transport": {
+ "version": "2.0.1",
+ "from": "nodemailer-smtp-transport@2.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-2.0.1.tgz",
+ "dependencies": {
+ "clone": {
+ "version": "1.0.2",
+ "from": "clone@1.0.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz"
+ },
+ "nodemailer-wellknown": {
+ "version": "0.1.7",
+ "from": "nodemailer-wellknown@0.1.7",
+ "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.7.tgz"
+ },
+ "smtp-connection": {
+ "version": "2.0.1",
+ "from": "smtp-connection@2.0.1",
+ "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "nodemailer-sendgrid-transport": {
+ "version": "0.2.0",
+ "from": "nodemailer-sendgrid-transport@^0.2.0",
+ "resolved": "https://registry.npmjs.org/nodemailer-sendgrid-transport/-/nodemailer-sendgrid-transport-0.2.0.tgz",
+ "dependencies": {
+ "sendgrid": {
+ "version": "1.9.2",
+ "from": "sendgrid@^1.8.0",
+ "resolved": "https://registry.npmjs.org/sendgrid/-/sendgrid-1.9.2.tgz",
+ "dependencies": {
+ "mime": {
+ "version": "1.3.4",
+ "from": "mime@^1.2.9",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz"
+ },
+ "lodash": {
+ "version": "3.10.1",
+ "from": "lodash@^3.0.1 || ^2.0.0",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz"
+ },
+ "smtpapi": {
+ "version": "1.2.0",
+ "from": "smtpapi@^1.2.0",
+ "resolved": "https://registry.npmjs.org/smtpapi/-/smtpapi-1.2.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "nodemailer-ses-transport": {
+ "version": "1.3.1",
+ "from": "nodemailer-ses-transport@^1.3.0",
+ "resolved": "https://registry.npmjs.org/nodemailer-ses-transport/-/nodemailer-ses-transport-1.3.1.tgz",
+ "dependencies": {
+ "aws-sdk": {
+ "version": "2.4.0",
+ "from": "aws-sdk@^2.3.11",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.4.0.tgz",
+ "dependencies": {
+ "sax": {
+ "version": "1.1.5",
+ "from": "sax@1.1.5",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.5.tgz"
+ },
+ "xml2js": {
+ "version": "0.4.15",
+ "from": "xml2js@0.4.15",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.15.tgz"
+ },
+ "xmlbuilder": {
+ "version": "2.6.2",
+ "from": "xmlbuilder@2.6.2",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.6.2.tgz",
+ "dependencies": {
+ "lodash": {
+ "version": "3.5.0",
+ "from": "lodash@~3.5.0",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.5.0.tgz"
+ }
+ }
+ },
+ "jmespath": {
+ "version": "0.15.0",
+ "from": "jmespath@0.15.0",
+ "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "optimist": {
+ "version": "0.6.1",
+ "from": "optimist@0.6.1",
+ "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
+ "dependencies": {
+ "wordwrap": {
+ "version": "0.0.3",
+ "from": "wordwrap@~0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz"
+ },
+ "minimist": {
+ "version": "0.0.10",
+ "from": "minimist@~0.0.1",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz"
+ }
+ }
+ },
+ "redback": {
+ "version": "0.4.0",
+ "from": "redback@0.4.0",
+ "resolved": "https://registry.npmjs.org/redback/-/redback-0.4.0.tgz"
+ },
+ "redis": {
+ "version": "0.10.1",
+ "from": "redis@0.10.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-0.10.1.tgz"
+ },
+ "redis-sharelatex": {
+ "version": "0.0.9",
+ "from": "redis-sharelatex@0.0.9",
+ "resolved": "https://registry.npmjs.org/redis-sharelatex/-/redis-sharelatex-0.0.9.tgz",
+ "dependencies": {
+ "chai": {
+ "version": "1.9.1",
+ "from": "chai@1.9.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-1.9.1.tgz",
+ "dependencies": {
+ "assertion-error": {
+ "version": "1.0.0",
+ "from": "assertion-error@1.0.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.0.tgz"
+ },
+ "deep-eql": {
+ "version": "0.1.3",
+ "from": "deep-eql@0.1.3",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz",
+ "dependencies": {
+ "type-detect": {
+ "version": "0.1.1",
+ "from": "type-detect@0.1.1",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "coffee-script": {
+ "version": "1.8.0",
+ "from": "coffee-script@1.8.0",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.8.0.tgz",
+ "dependencies": {
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@~0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ }
+ }
+ },
+ "grunt-contrib-coffee": {
+ "version": "0.11.1",
+ "from": "grunt-contrib-coffee@0.11.1",
+ "resolved": "https://registry.npmjs.org/grunt-contrib-coffee/-/grunt-contrib-coffee-0.11.1.tgz",
+ "dependencies": {
+ "coffee-script": {
+ "version": "1.7.1",
+ "from": "coffee-script@~1.7.0",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.7.1.tgz",
+ "dependencies": {
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@~0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ }
+ }
+ },
+ "chalk": {
+ "version": "0.5.1",
+ "from": "chalk@~0.5.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz",
+ "dependencies": {
+ "ansi-styles": {
+ "version": "1.1.0",
+ "from": "ansi-styles@^1.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz"
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "from": "escape-string-regexp@^1.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
+ },
+ "has-ansi": {
+ "version": "0.1.0",
+ "from": "has-ansi@^0.1.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz",
+ "dependencies": {
+ "ansi-regex": {
+ "version": "0.2.1",
+ "from": "ansi-regex@^0.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
+ }
+ }
+ },
+ "strip-ansi": {
+ "version": "0.3.0",
+ "from": "strip-ansi@^0.3.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz",
+ "dependencies": {
+ "ansi-regex": {
+ "version": "0.2.1",
+ "from": "ansi-regex@^0.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
+ }
+ }
+ },
+ "supports-color": {
+ "version": "0.2.0",
+ "from": "supports-color@^0.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz"
+ }
+ }
+ },
+ "lodash": {
+ "version": "2.4.2",
+ "from": "lodash@~2.4.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
+ }
+ }
+ },
+ "grunt-mocha-test": {
+ "version": "0.12.0",
+ "from": "grunt-mocha-test@0.12.0",
+ "resolved": "https://registry.npmjs.org/grunt-mocha-test/-/grunt-mocha-test-0.12.0.tgz",
+ "dependencies": {
+ "hooker": {
+ "version": "0.2.3",
+ "from": "hooker@~0.2.3",
+ "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz"
+ },
+ "fs-extra": {
+ "version": "0.11.1",
+ "from": "fs-extra@~0.11.1",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.11.1.tgz",
+ "dependencies": {
+ "ncp": {
+ "version": "0.6.0",
+ "from": "ncp@^0.6.0",
+ "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.6.0.tgz"
+ },
+ "mkdirp": {
+ "version": "0.5.1",
+ "from": "mkdirp@^0.5.0",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "dependencies": {
+ "minimist": {
+ "version": "0.0.8",
+ "from": "minimist@0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+ }
+ }
+ },
+ "jsonfile": {
+ "version": "2.3.1",
+ "from": "jsonfile@^2.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.3.1.tgz"
+ },
+ "rimraf": {
+ "version": "2.5.2",
+ "from": "rimraf@^2.2.8",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz",
+ "dependencies": {
+ "glob": {
+ "version": "7.0.3",
+ "from": "glob@^7.0.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz",
+ "dependencies": {
+ "inflight": {
+ "version": "1.0.5",
+ "from": "inflight@^1.0.4",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@2 || 3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "once": {
+ "version": "1.3.3",
+ "from": "once@^1.3.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.0",
+ "from": "path-is-absolute@^1.0.0",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "mocha": {
+ "version": "1.21.4",
+ "from": "mocha@1.21.4",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-1.21.4.tgz",
+ "dependencies": {
+ "commander": {
+ "version": "2.0.0",
+ "from": "commander@2.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.0.0.tgz"
+ },
+ "growl": {
+ "version": "1.8.1",
+ "from": "growl@1.8.x",
+ "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz"
+ },
+ "jade": {
+ "version": "0.26.3",
+ "from": "jade@0.26.3",
+ "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz",
+ "dependencies": {
+ "commander": {
+ "version": "0.6.1",
+ "from": "commander@0.6.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz"
+ },
+ "mkdirp": {
+ "version": "0.3.0",
+ "from": "mkdirp@0.3.0",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
+ }
+ }
+ },
+ "diff": {
+ "version": "1.0.7",
+ "from": "diff@1.0.7",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-1.0.7.tgz"
+ },
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@*",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "mkdirp": {
+ "version": "0.3.5",
+ "from": "mkdirp@0.3.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz"
+ },
+ "glob": {
+ "version": "3.2.3",
+ "from": "glob@3.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz",
+ "dependencies": {
+ "minimatch": {
+ "version": "0.2.14",
+ "from": "minimatch@~0.2.11",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+ "dependencies": {
+ "lru-cache": {
+ "version": "2.7.3",
+ "from": "lru-cache@2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz"
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "from": "sigmund@~1.0.0",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz"
+ }
+ }
+ },
+ "graceful-fs": {
+ "version": "2.0.3",
+ "from": "graceful-fs@~2.0.0",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "redis": {
+ "version": "0.12.1",
+ "from": "redis@0.12.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
+ },
+ "redis-sentinel": {
+ "version": "0.1.1",
+ "from": "redis-sentinel@0.1.1",
+ "resolved": "https://registry.npmjs.org/redis-sentinel/-/redis-sentinel-0.1.1.tgz",
+ "dependencies": {
+ "redis": {
+ "version": "0.11.0",
+ "from": "redis@0.11.x",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-0.11.0.tgz"
+ },
+ "q": {
+ "version": "0.9.2",
+ "from": "q@0.9.2",
+ "resolved": "https://registry.npmjs.org/q/-/q-0.9.2.tgz"
+ }
+ }
+ },
+ "sandboxed-module": {
+ "version": "1.0.1",
+ "from": "sandboxed-module@1.0.1",
+ "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-1.0.1.tgz",
+ "dependencies": {
+ "require-like": {
+ "version": "0.1.2",
+ "from": "require-like@0.1.2",
+ "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz"
+ },
+ "stack-trace": {
+ "version": "0.0.9",
+ "from": "stack-trace@0.0.9",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz"
+ }
+ }
+ },
+ "sinon": {
+ "version": "1.10.3",
+ "from": "sinon@1.10.3",
+ "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.10.3.tgz",
+ "dependencies": {
+ "formatio": {
+ "version": "1.0.2",
+ "from": "formatio@~1.0",
+ "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.0.2.tgz",
+ "dependencies": {
+ "samsam": {
+ "version": "1.1.3",
+ "from": "samsam@~1.1",
+ "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.3.tgz"
+ }
+ }
+ },
+ "util": {
+ "version": "0.10.3",
+ "from": "util@>=0.10.3 <1",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "underscore": {
+ "version": "1.7.0",
+ "from": "underscore@1.7.0",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz"
+ }
+ }
+ },
+ "request": {
+ "version": "2.72.0",
+ "from": "request@^2.69.0",
+ "resolved": "https://registry.npmjs.org/request/-/request-2.72.0.tgz",
+ "dependencies": {
+ "aws-sign2": {
+ "version": "0.6.0",
+ "from": "aws-sign2@~0.6.0",
+ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz"
+ },
+ "aws4": {
+ "version": "1.4.1",
+ "from": "aws4@^1.2.1",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.4.1.tgz"
+ },
+ "bl": {
+ "version": "1.1.2",
+ "from": "bl@~1.1.2",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz",
+ "dependencies": {
+ "readable-stream": {
+ "version": "2.0.6",
+ "from": "readable-stream@~2.0.5",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "from": "isarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "from": "process-nextick-args@~1.0.6",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "from": "util-deprecate@~1.0.1",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ }
+ }
+ }
+ }
+ },
+ "caseless": {
+ "version": "0.11.0",
+ "from": "caseless@~0.11.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz"
+ },
+ "combined-stream": {
+ "version": "1.0.5",
+ "from": "combined-stream@~1.0.5",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
+ "dependencies": {
+ "delayed-stream": {
+ "version": "1.0.0",
+ "from": "delayed-stream@~1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
+ }
+ }
+ },
+ "extend": {
+ "version": "3.0.0",
+ "from": "extend@~3.0.0",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.0.tgz"
+ },
+ "forever-agent": {
+ "version": "0.6.1",
+ "from": "forever-agent@~0.6.1",
+ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz"
+ },
+ "form-data": {
+ "version": "1.0.0-rc4",
+ "from": "form-data@~1.0.0-rc3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.0-rc4.tgz",
+ "dependencies": {
+ "async": {
+ "version": "1.5.2",
+ "from": "async@^1.5.2",
+ "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz"
+ }
+ }
+ },
+ "har-validator": {
+ "version": "2.0.6",
+ "from": "har-validator@~2.0.6",
+ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz",
+ "dependencies": {
+ "chalk": {
+ "version": "1.1.3",
+ "from": "chalk@^1.1.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "from": "ansi-styles@^2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz"
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "from": "escape-string-regexp@^1.0.2",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
+ },
+ "has-ansi": {
+ "version": "2.0.0",
+ "from": "has-ansi@^2.0.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+ "dependencies": {
+ "ansi-regex": {
+ "version": "2.0.0",
+ "from": "ansi-regex@^2.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz"
+ }
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "from": "strip-ansi@^3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "dependencies": {
+ "ansi-regex": {
+ "version": "2.0.0",
+ "from": "ansi-regex@^2.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz"
+ }
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "from": "supports-color@^2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz"
+ }
+ }
+ },
+ "commander": {
+ "version": "2.9.0",
+ "from": "commander@^2.9.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
+ "dependencies": {
+ "graceful-readlink": {
+ "version": "1.0.1",
+ "from": "graceful-readlink@>= 1.0.0",
+ "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
+ }
+ }
+ },
+ "is-my-json-valid": {
+ "version": "2.13.1",
+ "from": "is-my-json-valid@^2.12.4",
+ "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.13.1.tgz",
+ "dependencies": {
+ "generate-function": {
+ "version": "2.0.0",
+ "from": "generate-function@^2.0.0",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz"
+ },
+ "generate-object-property": {
+ "version": "1.2.0",
+ "from": "generate-object-property@^1.1.0",
+ "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz",
+ "dependencies": {
+ "is-property": {
+ "version": "1.0.2",
+ "from": "is-property@^1.0.0",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz"
+ }
+ }
+ },
+ "jsonpointer": {
+ "version": "2.0.0",
+ "from": "jsonpointer@2.0.0",
+ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-2.0.0.tgz"
+ },
+ "xtend": {
+ "version": "4.0.1",
+ "from": "xtend@^4.0.0",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz"
+ }
+ }
+ },
+ "pinkie-promise": {
+ "version": "2.0.1",
+ "from": "pinkie-promise@^2.0.0",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "dependencies": {
+ "pinkie": {
+ "version": "2.0.4",
+ "from": "pinkie@^2.0.0",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz"
+ }
+ }
+ }
+ }
+ },
+ "hawk": {
+ "version": "3.1.3",
+ "from": "hawk@~3.1.3",
+ "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
+ "dependencies": {
+ "hoek": {
+ "version": "2.16.3",
+ "from": "hoek@2.x.x",
+ "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
+ },
+ "boom": {
+ "version": "2.10.1",
+ "from": "boom@2.x.x",
+ "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz"
+ },
+ "cryptiles": {
+ "version": "2.0.5",
+ "from": "cryptiles@2.x.x",
+ "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz"
+ },
+ "sntp": {
+ "version": "1.0.9",
+ "from": "sntp@1.x.x",
+ "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz"
+ }
+ }
+ },
+ "http-signature": {
+ "version": "1.1.1",
+ "from": "http-signature@~1.1.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz",
+ "dependencies": {
+ "assert-plus": {
+ "version": "0.2.0",
+ "from": "assert-plus@^0.2.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz"
+ },
+ "jsprim": {
+ "version": "1.2.2",
+ "from": "jsprim@^1.2.2",
+ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.2.2.tgz",
+ "dependencies": {
+ "extsprintf": {
+ "version": "1.0.2",
+ "from": "extsprintf@1.0.2",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz"
+ },
+ "json-schema": {
+ "version": "0.2.2",
+ "from": "json-schema@0.2.2",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.2.tgz"
+ },
+ "verror": {
+ "version": "1.3.6",
+ "from": "verror@1.3.6",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz"
+ }
+ }
+ },
+ "sshpk": {
+ "version": "1.8.3",
+ "from": "sshpk@^1.7.0",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.8.3.tgz",
+ "dependencies": {
+ "asn1": {
+ "version": "0.2.3",
+ "from": "asn1@~0.2.3",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz"
+ },
+ "assert-plus": {
+ "version": "1.0.0",
+ "from": "assert-plus@^1.0.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz"
+ },
+ "dashdash": {
+ "version": "1.14.0",
+ "from": "dashdash@^1.12.0",
+ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.0.tgz"
+ },
+ "getpass": {
+ "version": "0.1.6",
+ "from": "getpass@^0.1.1",
+ "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.6.tgz"
+ },
+ "jsbn": {
+ "version": "0.1.0",
+ "from": "jsbn@~0.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.0.tgz"
+ },
+ "tweetnacl": {
+ "version": "0.13.3",
+ "from": "tweetnacl@~0.13.0",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.13.3.tgz"
+ },
+ "jodid25519": {
+ "version": "1.0.2",
+ "from": "jodid25519@^1.0.0",
+ "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz"
+ },
+ "ecc-jsbn": {
+ "version": "0.1.1",
+ "from": "ecc-jsbn@~0.1.1",
+ "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "is-typedarray": {
+ "version": "1.0.0",
+ "from": "is-typedarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz"
+ },
+ "isstream": {
+ "version": "0.1.2",
+ "from": "isstream@~0.1.2",
+ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
+ },
+ "json-stringify-safe": {
+ "version": "5.0.1",
+ "from": "json-stringify-safe@~5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
+ },
+ "mime-types": {
+ "version": "2.1.11",
+ "from": "mime-types@~2.1.7",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz",
+ "dependencies": {
+ "mime-db": {
+ "version": "1.23.0",
+ "from": "mime-db@~1.23.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz"
+ }
+ }
+ },
+ "node-uuid": {
+ "version": "1.4.7",
+ "from": "node-uuid@~1.4.7",
+ "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.7.tgz"
+ },
+ "oauth-sign": {
+ "version": "0.8.2",
+ "from": "oauth-sign@~0.8.1",
+ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz"
+ },
+ "qs": {
+ "version": "6.1.0",
+ "from": "qs@~6.1.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.1.0.tgz"
+ },
+ "stringstream": {
+ "version": "0.0.5",
+ "from": "stringstream@~0.0.4",
+ "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz"
+ },
+ "tough-cookie": {
+ "version": "2.2.2",
+ "from": "tough-cookie@~2.2.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.2.2.tgz"
+ },
+ "tunnel-agent": {
+ "version": "0.4.3",
+ "from": "tunnel-agent@~0.4.1",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz"
+ }
+ }
+ },
+ "requests": {
+ "version": "0.1.7",
+ "from": "requests@^0.1.7",
+ "resolved": "https://registry.npmjs.org/requests/-/requests-0.1.7.tgz",
+ "dependencies": {
+ "axo": {
+ "version": "0.0.1",
+ "from": "axo@0.0.x",
+ "resolved": "https://registry.npmjs.org/axo/-/axo-0.0.1.tgz"
+ },
+ "eventemitter3": {
+ "version": "1.1.1",
+ "from": "eventemitter3@1.1.x",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.1.1.tgz"
+ },
+ "extendible": {
+ "version": "0.1.1",
+ "from": "extendible@0.1.x",
+ "resolved": "https://registry.npmjs.org/extendible/-/extendible-0.1.1.tgz"
+ },
+ "hang": {
+ "version": "1.0.0",
+ "from": "hang@1.0.x",
+ "resolved": "https://registry.npmjs.org/hang/-/hang-1.0.0.tgz"
+ },
+ "loads": {
+ "version": "0.0.4",
+ "from": "loads@0.0.x",
+ "resolved": "https://registry.npmjs.org/loads/-/loads-0.0.4.tgz",
+ "dependencies": {
+ "failure": {
+ "version": "1.1.1",
+ "from": "failure@1.1.x",
+ "resolved": "https://registry.npmjs.org/failure/-/failure-1.1.1.tgz"
+ },
+ "one-time": {
+ "version": "0.0.4",
+ "from": "one-time@0.0.x",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz"
+ },
+ "xhr-response": {
+ "version": "1.0.1",
+ "from": "xhr-response@1.0.x",
+ "resolved": "https://registry.npmjs.org/xhr-response/-/xhr-response-1.0.1.tgz"
+ },
+ "xhr-status": {
+ "version": "1.0.0",
+ "from": "xhr-status@1.0.x",
+ "resolved": "https://registry.npmjs.org/xhr-status/-/xhr-status-1.0.0.tgz"
+ }
+ }
+ },
+ "xhr-send": {
+ "version": "1.0.0",
+ "from": "xhr-send@1.0.x",
+ "resolved": "https://registry.npmjs.org/xhr-send/-/xhr-send-1.0.0.tgz"
+ }
+ }
+ },
+ "rimraf": {
+ "version": "2.2.6",
+ "from": "rimraf@2.2.6",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.6.tgz"
+ },
+ "sanitizer": {
+ "version": "0.1.1",
+ "from": "sanitizer@0.1.1",
+ "resolved": "https://registry.npmjs.org/sanitizer/-/sanitizer-0.1.1.tgz"
+ },
+ "settings-sharelatex": {
+ "version": "1.0.0",
+ "from": "settings-sharelatex@git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
+ "resolved": "git+https://github.com/sharelatex/settings-sharelatex.git#cbc5e41c1dbe6789721a14b3fdae05bf22546559",
+ "dependencies": {
+ "coffee-script": {
+ "version": "1.6.0",
+ "from": "coffee-script@1.6.0",
+ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz"
+ }
+ }
+ },
+ "sixpack-client": {
+ "version": "1.0.0",
+ "from": "sixpack-client@^1.0.0",
+ "resolved": "https://registry.npmjs.org/sixpack-client/-/sixpack-client-1.0.0.tgz"
+ },
+ "temp": {
+ "version": "0.8.3",
+ "from": "temp@^0.8.3",
+ "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz",
+ "dependencies": {
+ "os-tmpdir": {
+ "version": "1.0.1",
+ "from": "os-tmpdir@^1.0.0",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.1.tgz"
+ }
+ }
+ },
+ "underscore": {
+ "version": "1.6.0",
+ "from": "underscore@1.6.0",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz"
+ },
+ "v8-profiler": {
+ "version": "5.6.5",
+ "from": "v8-profiler@^5.2.3",
+ "resolved": "https://registry.npmjs.org/v8-profiler/-/v8-profiler-5.6.5.tgz",
+ "dependencies": {
+ "nan": {
+ "version": "2.3.5",
+ "from": "nan@^2.3.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.3.5.tgz"
+ },
+ "node-pre-gyp": {
+ "version": "0.6.28",
+ "from": "node-pre-gyp@^0.6.5",
+ "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.28.tgz",
+ "dependencies": {
+ "mkdirp": {
+ "version": "0.5.1",
+ "from": "mkdirp@~0.5.0",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "dependencies": {
+ "minimist": {
+ "version": "0.0.8",
+ "from": "minimist@0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
+ }
+ }
+ },
+ "nopt": {
+ "version": "3.0.6",
+ "from": "nopt@~3.0.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
+ "dependencies": {
+ "abbrev": {
+ "version": "1.0.7",
+ "from": "abbrev@1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz"
+ }
+ }
+ },
+ "npmlog": {
+ "version": "2.0.4",
+ "from": "npmlog@~2.0.0",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-2.0.4.tgz",
+ "dependencies": {
+ "ansi": {
+ "version": "0.3.1",
+ "from": "ansi@~0.3.1",
+ "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz"
+ },
+ "are-we-there-yet": {
+ "version": "1.1.2",
+ "from": "are-we-there-yet@~1.1.2",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz",
+ "dependencies": {
+ "delegates": {
+ "version": "1.0.0",
+ "from": "delegates@^1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz"
+ },
+ "readable-stream": {
+ "version": "2.1.4",
+ "from": "readable-stream@^2.0.0 || ^1.1.13",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.4.tgz",
+ "dependencies": {
+ "buffer-shims": {
+ "version": "1.0.0",
+ "from": "buffer-shims@^1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "from": "isarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "from": "process-nextick-args@~1.0.6",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "from": "util-deprecate@~1.0.1",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ }
+ }
+ }
+ }
+ },
+ "gauge": {
+ "version": "1.2.7",
+ "from": "gauge@~1.2.5",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz",
+ "dependencies": {
+ "has-unicode": {
+ "version": "2.0.0",
+ "from": "has-unicode@^2.0.0",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.0.tgz"
+ },
+ "lodash.pad": {
+ "version": "4.4.0",
+ "from": "lodash.pad@^4.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.4.0.tgz",
+ "dependencies": {
+ "lodash._baseslice": {
+ "version": "4.0.0",
+ "from": "lodash._baseslice@~4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+ },
+ "lodash._basetostring": {
+ "version": "4.12.0",
+ "from": "lodash._basetostring@~4.12.0",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+ },
+ "lodash.tostring": {
+ "version": "4.1.3",
+ "from": "lodash.tostring@^4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+ }
+ }
+ },
+ "lodash.padend": {
+ "version": "4.5.0",
+ "from": "lodash.padend@^4.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.5.0.tgz",
+ "dependencies": {
+ "lodash._baseslice": {
+ "version": "4.0.0",
+ "from": "lodash._baseslice@~4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+ },
+ "lodash._basetostring": {
+ "version": "4.12.0",
+ "from": "lodash._basetostring@~4.12.0",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+ },
+ "lodash.tostring": {
+ "version": "4.1.3",
+ "from": "lodash.tostring@^4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+ }
+ }
+ },
+ "lodash.padstart": {
+ "version": "4.5.0",
+ "from": "lodash.padstart@^4.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.5.0.tgz",
+ "dependencies": {
+ "lodash._baseslice": {
+ "version": "4.0.0",
+ "from": "lodash._baseslice@~4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseslice/-/lodash._baseslice-4.0.0.tgz"
+ },
+ "lodash._basetostring": {
+ "version": "4.12.0",
+ "from": "lodash._basetostring@~4.12.0",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+ },
+ "lodash.tostring": {
+ "version": "4.1.3",
+ "from": "lodash.tostring@^4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash.tostring/-/lodash.tostring-4.1.3.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "rc": {
+ "version": "1.1.6",
+ "from": "rc@~1.1.0",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.1.6.tgz",
+ "dependencies": {
+ "deep-extend": {
+ "version": "0.4.1",
+ "from": "deep-extend@~0.4.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.1.tgz"
+ },
+ "ini": {
+ "version": "1.3.4",
+ "from": "ini@~1.3.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz"
+ },
+ "minimist": {
+ "version": "1.2.0",
+ "from": "minimist@^1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz"
+ },
+ "strip-json-comments": {
+ "version": "1.0.4",
+ "from": "strip-json-comments@~1.0.4",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz"
+ }
+ }
+ },
+ "rimraf": {
+ "version": "2.5.2",
+ "from": "rimraf@~2.5.0",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz",
+ "dependencies": {
+ "glob": {
+ "version": "7.0.3",
+ "from": "glob@^7.0.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz",
+ "dependencies": {
+ "inflight": {
+ "version": "1.0.5",
+ "from": "inflight@^1.0.4",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@2 || 3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ },
+ "once": {
+ "version": "1.3.3",
+ "from": "once@^1.3.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.0",
+ "from": "path-is-absolute@^1.0.0",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz"
+ }
+ }
+ }
+ }
+ },
+ "semver": {
+ "version": "5.1.0",
+ "from": "semver@~5.1.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.1.0.tgz"
+ },
+ "tar": {
+ "version": "2.2.1",
+ "from": "tar@~2.2.0",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
+ "dependencies": {
+ "block-stream": {
+ "version": "0.0.9",
+ "from": "block-stream@*",
+ "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz"
+ },
+ "fstream": {
+ "version": "1.0.9",
+ "from": "fstream@^1.0.2",
+ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz",
+ "dependencies": {
+ "graceful-fs": {
+ "version": "4.1.4",
+ "from": "graceful-fs@^4.1.2",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz"
+ }
+ }
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "tar-pack": {
+ "version": "3.1.3",
+ "from": "tar-pack@~3.1.0",
+ "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.1.3.tgz",
+ "dependencies": {
+ "debug": {
+ "version": "2.2.0",
+ "from": "debug@~2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "dependencies": {
+ "ms": {
+ "version": "0.7.1",
+ "from": "ms@0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
+ }
+ }
+ },
+ "fstream": {
+ "version": "1.0.9",
+ "from": "fstream@~1.0.8",
+ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz",
+ "dependencies": {
+ "graceful-fs": {
+ "version": "4.1.4",
+ "from": "graceful-fs@^4.1.2",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.0",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ }
+ }
+ },
+ "fstream-ignore": {
+ "version": "1.0.5",
+ "from": "fstream-ignore@~1.0.3",
+ "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz",
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "minimatch": {
+ "version": "3.0.0",
+ "from": "minimatch@^3.0.0",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.0.tgz",
+ "dependencies": {
+ "brace-expansion": {
+ "version": "1.1.4",
+ "from": "brace-expansion@^1.0.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz",
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.1",
+ "from": "balanced-match@^0.4.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "from": "concat-map@0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "once": {
+ "version": "1.3.3",
+ "from": "once@~1.3.3",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "dependencies": {
+ "wrappy": {
+ "version": "1.0.2",
+ "from": "wrappy@1",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "2.0.6",
+ "from": "readable-stream@~2.0.4",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+ "dependencies": {
+ "core-util-is": {
+ "version": "1.0.2",
+ "from": "core-util-is@~1.0.0",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
+ },
+ "inherits": {
+ "version": "2.0.1",
+ "from": "inherits@~2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz"
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "from": "isarray@~1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "from": "process-nextick-args@~1.0.6",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz"
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "from": "string_decoder@~0.10.x",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "from": "util-deprecate@~1.0.1",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ }
+ }
+ },
+ "uid-number": {
+ "version": "0.0.6",
+ "from": "uid-number@~0.0.6",
+ "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "xml2js": {
+ "version": "0.2.0",
+ "from": "xml2js@0.2.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.0.tgz",
+ "dependencies": {
+ "sax": {
+ "version": "1.2.1",
+ "from": "sax@>=0.1.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz"
+ }
+ }
+ }
+ }
+}
diff --git a/services/web/package.json b/services/web/package.json
index 754182a632..0c0c4bb436 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -18,6 +18,7 @@
"body-parser": "^1.13.1",
"bufferedstream": "1.6.0",
"connect-redis": "2.3.0",
+ "contentful": "^3.3.14",
"cookie": "^0.2.3",
"cookie-parser": "1.3.5",
"csurf": "^1.8.3",
@@ -30,8 +31,9 @@
"jade": "~1.3.1",
"ldapjs": "^0.7.1",
"logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
+ "lodash": "^4.13.1",
"lynx": "0.1.1",
- "marked": "^0.3.3",
+ "marked": "^0.3.5",
"method-override": "^2.3.3",
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
"mimelib": "0.2.14",
@@ -54,32 +56,35 @@
"settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
"sixpack-client": "^1.0.0",
"temp": "^0.8.3",
- "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
"underscore": "1.6.0",
- "underscore.string": "^3.0.2",
"v8-profiler": "^5.2.3",
"xml2js": "0.2.0"
},
"devDependencies": {
"bunyan": "0.22.1",
+ "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
"chai": "",
"chai-spies": "",
"grunt": "0.4.1",
"grunt-available-tasks": "0.4.1",
"grunt-bunyan": "0.5.0",
- "grunt-concurrent": "0.4.3",
"grunt-contrib-clean": "0.5.0",
"grunt-contrib-coffee": "0.10.0",
"grunt-contrib-less": "0.9.0",
"grunt-contrib-requirejs": "0.4.1",
- "grunt-execute": "0.1.5",
+ "grunt-contrib-watch": "^1.0.0",
+ "grunt-env": "0.4.4",
+ "clean-css": "^3.4.18",
+ "grunt-exec": "^0.4.7",
+ "grunt-execute": "^0.2.2",
"grunt-file-append": "0.0.6",
"grunt-git-rev-parse": "^0.1.4",
"grunt-mocha-test": "0.9.0",
+ "grunt-newer": "^1.2.0",
+ "grunt-parallel": "^0.5.1",
"grunt-sed": "^0.1.1",
"sandboxed-module": "0.2.0",
"sinon": "",
- "timekeeper": "",
- "grunt-env": "0.4.4"
+ "timekeeper": ""
}
}
diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee
index ceebadac46..4c75ab5d77 100644
--- a/services/web/public/coffee/ide.coffee
+++ b/services/web/public/coffee/ide.coffee
@@ -42,7 +42,7 @@ define [
ReferencesManager
) ->
- App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
+ App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
# Don't freak out if we're already in an apply callback
$scope.$originalApply = $scope.$apply
$scope.$apply = (fn = () ->) ->
@@ -69,6 +69,17 @@ define [
$scope.chat = {}
+ # Tracking code.
+ $scope.$watch "ui.view", (newView, oldView) ->
+ if newView? and newView != "editor" and newView != "pdf"
+ event_tracking.sendCountlyOnce "ide-open-view-#{ newView }-once"
+
+ $scope.$watch "ui.chatOpen", (isOpen) ->
+ event_tracking.sendCountlyOnce "ide-open-chat-once" if isOpen
+
+ $scope.$watch "ui.leftMenuShown", (isOpen) ->
+ event_tracking.sendCountlyOnce "ide-open-left-menu-once" if isOpen
+ # End of tracking code.
window._ide = ide
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
index 596f350812..800d6bd194 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
@@ -74,15 +74,17 @@ define [
editor.commands.removeCommand "foldall"
# For European keyboards, the / is above 7 so needs Shift pressing.
- # This comes through as Ctrl-Shift-/ which is mapped to toggleBlockComment.
+ # This comes through as Command-Shift-/ on OS X, which is mapped to
+ # toggleBlockComment.
# This doesn't do anything for LaTeX, so remap this to togglecomment to
# work for European keyboards as normal.
+ # On Windows, the key combo comes as Ctrl-Shift-7.
editor.commands.removeCommand "toggleBlockComment"
editor.commands.removeCommand "togglecomment"
editor.commands.addCommand {
name: "togglecomment",
- bindKey: { win: "Ctrl-/|Ctrl-Shift-/", mac: "Command-/|Command-Shift-/" },
+ bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" },
exec: (editor) -> editor.toggleCommentLines(),
multiSelectAction: "forEachLine",
scrollIntoView: "selectionPart"
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
index 75e00a37f6..c5b37d1f3b 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
@@ -134,6 +134,8 @@ define [
editor.completer.showPopup(editor)
editor.completer.cancelContextMenu()
$(editor.completer.popup?.container).css({'font-size': @$scope.fontSize + 'px'})
+ if editor.completer?.completions?.filtered?.length == 0
+ editor.completer.detach()
bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
}
diff --git a/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee b/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
index 4d0adef4fa..36eaa5d555 100644
--- a/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
+++ b/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
@@ -2,8 +2,10 @@ define [
"base"
"ace/ace"
], (App) ->
- App.controller "HotkeysController", ($scope, $modal) ->
+ App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
$scope.openHotkeysModal = ->
+ event_tracking.sendCountly "ide-open-hotkeys-modal"
+
$modal.open {
templateUrl: "hotkeysModalTemplate"
controller: "HotkeysModalController"
diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
new file mode 100644
index 0000000000..747ca1ad08
--- /dev/null
+++ b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
@@ -0,0 +1,20 @@
+define [
+ "libs/latex-log-parser"
+ "ide/human-readable-logs/HumanReadableLogsRules"
+], (LogParser, ruleset) ->
+ parse : (rawLog, options) ->
+ parsedLogEntries = LogParser.parse(rawLog, options)
+
+ _getRule = (logMessage) ->
+ return rule for rule in ruleset when rule.regexToMatch.test logMessage
+
+ for entry in parsedLogEntries.all
+ ruleDetails = _getRule entry.message
+
+ if (ruleDetails?)
+ entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
+
+ entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
+ entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
+
+ return parsedLogEntries
diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
new file mode 100644
index 0000000000..f123515deb
--- /dev/null
+++ b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
@@ -0,0 +1,91 @@
+define -> [
+ regexToMatch: /Misplaced alignment tab character \&/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Misplaced_alignment_tab_character_%26"
+ humanReadableHint: """
+ You have placed an alignment tab character '&' in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} \u2026 \\end{align}, \\begin{tabular} \u2026 \\end{tabular}, etc. If you want to write an ampersand '&' in text, you must write \\& instead.
+ """
+ ,
+ regexToMatch: /Extra alignment tab has been changed to \\cr/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr"
+ humanReadableHint: """
+ You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your table.
+ """
+ ,
+ regexToMatch: /Display math should end with \$\$/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$"
+ humanReadableHint: """
+ You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
+ """
+ ,
+ regexToMatch: /Missing [{$] inserted./
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Missing_$_inserted"
+ humanReadableHint: """
+ Check that your $'s match around math expressions. If they do, then you've probably used a symbol in normal text that needs to be in math mode. Symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ), and modifiers (\\vec{x}, \\tilde{x} ) must be written in math mode. See the full list here.If you intended to use mathematics mode, then use $ \u2026 $ for 'inline math mode', $$ \u2026 $$ for 'display math mode' or alternatively \begin{math} \u2026 \end{math}.
+ """
+ ,
+ regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references"
+ humanReadableHint: """
+ You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
+ """
+ ,
+ regexToMatch: /Citation .+ on page .+ undefined on input line .+/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX"
+ humanReadableHint: """
+ You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
+ """
+ ,
+ regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels"
+ humanReadableHint: """
+ You have used the same label more than once. Check that each \\label{...} labels only one item.
+ """
+ ,
+ regexToMatch: /`!?h' float specifier changed to `!?ht'/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27"
+ humanReadableHint: """
+ The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the float package.
+ """
+ ,
+ regexToMatch: /No positions in optional float specifier/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
+ humanReadableHint: """
+ You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers here.
+ """
+ ,
+ regexToMatch: /Undefined control sequence/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence"
+ humanReadableHint: """
+ The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
+ """
+ ,
+ regexToMatch: /File .+ not found/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX"
+ humanReadableHint: """
+ The compiler cannot find the file you want to include. Make sure that you have uploaded the file and specified the file location correctly.
+ """
+ ,
+ regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX"
+ humanReadableHint: """
+ The compiler does not recognise the file type of one of your images. Make sure you are using a supported image format for your choice of compiler, and check that there are no periods (.) in the name of your image.
+ """
+ ,
+ regexToMatch: /LaTeX Error: Unknown float option `H'/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27"
+ humanReadableHint: """
+ The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
+ """
+ ,
+ regexToMatch: /LaTeX Error: Unknown float option `q'/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27"
+ humanReadableHint: """
+ You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures here.
+ """
+ ,
+ regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
+ extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode"
+ humanReadableHint: """
+ You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
+ """
+]
diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
index 05d99124c7..0f8134258c 100644
--- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
+++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
@@ -1,15 +1,67 @@
define [
"base"
- "libs/latex-log-parser"
+ "ace/ace"
+ "ide/human-readable-logs/HumanReadableLogs"
"libs/bib-log-parser"
-], (App, LogParser, BibLogParser) ->
- App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
+ "services/log-hints-feedback"
+], (App, Ace, HumanReadableLogs, BibLogParser) ->
+ App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, logHintsFeedback, localStorage) ->
+ # enable per-user containers by default
+ perUserCompile = true
autoCompile = true
# pdf.view = uncompiled | pdf | errors
$scope.pdf.view = if $scope?.pdf?.url then 'pdf' else 'uncompiled'
$scope.shouldShowLogs = false
+ $scope.wikiEnabled = window.wikiEnabled;
+
+ # view logic to check whether the files dropdown should "drop up" or "drop down"
+ $scope.shouldDropUp = false
+
+ logsContainerEl = document.querySelector ".pdf-logs"
+ filesDropdownEl = logsContainerEl?.querySelector ".files-dropdown"
+
+ # get the top coordinate of the files dropdown as a ratio (to the logs container height)
+ # logs container supports scrollable content, so it's possible that ratio > 1.
+ getFilesDropdownTopCoordAsRatio = () ->
+ filesDropdownEl?.getBoundingClientRect().top / logsContainerEl?.getBoundingClientRect().height
+
+ $scope.$watch "shouldShowLogs", (shouldShow) ->
+ if shouldShow
+ $scope.$applyAsync () ->
+ $scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
+
+ # log hints tracking
+ $scope.logHintsNegFeedbackValues = logHintsFeedback.feedbackOpts
+
+ $scope.trackLogHintsLearnMore = () ->
+ event_tracking.sendCountly "logs-hints-learn-more"
+
+ trackLogHintsFeedback = (isPositive, hintId) ->
+ event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
+ event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
+
+ $scope.trackLogHintsNegFeedbackDetails = (hintId, feedbackOpt, feedbackOtherVal) ->
+ logHintsFeedback.submitFeedback hintId, feedbackOpt, feedbackOtherVal
+
+ $scope.trackLogHintsPositiveFeedback = (hintId) -> trackLogHintsFeedback true, hintId
+ $scope.trackLogHintsNegativeFeedback = (hintId) -> trackLogHintsFeedback false, hintId
+
+ if ace.require("ace/lib/useragent").isMac
+ $scope.modifierKey = "Cmd"
+ else
+ $scope.modifierKey = "Ctrl"
+
+ # utility for making a query string from a hash, could use jquery $.param
+ createQueryString = (args) ->
+ qs_args = ("#{k}=#{v}" for k, v of args)
+ if qs_args.length then "?" + qs_args.join("&") else ""
+
+ $scope.stripHTMLFromString = (htmlStr) ->
+ tmp = document.createElement("DIV")
+ tmp.innerHTML = htmlStr
+ return tmp.textContent || tmp.innerText || ""
$scope.$on "project:joined", () ->
return if !autoCompile
@@ -28,13 +80,14 @@ define [
sendCompileRequest = (options = {}) ->
url = "/project/#{$scope.project_id}/compile"
+ params = {}
if options.isAutoCompile
- url += "?auto_compile=true"
+ params["auto_compile"]=true
return $http.post url, {
rootDoc_id: options.rootDocOverride_id or null
draft: $scope.draft
_csrf: window.csrfToken
- }
+ }, {params: params}
parseCompileResponse = (response) ->
@@ -42,20 +95,36 @@ define [
$scope.pdf.error = false
$scope.pdf.timedout = false
$scope.pdf.failure = false
- $scope.pdf.projectTooLarge = false
$scope.pdf.url = null
$scope.pdf.clsiMaintenance = false
$scope.pdf.tooRecentlyCompiled = false
$scope.pdf.renderingError = false
+ $scope.pdf.projectTooLarge = false
+ $scope.pdf.compileTerminated = false
# make a cache to look up files by name
fileByPath = {}
- for file in response.outputFiles
- fileByPath[file.path] = file
+ if response?.outputFiles?
+ for file in response?.outputFiles
+ fileByPath[file.path] = file
+
+ # prepare query string
+ qs = {}
+ # add a query string parameter for the compile group
+ if response.compileGroup?
+ ide.compileGroup = qs.compileGroup = response.compileGroup
+ # add a query string parameter for the clsi server id
+ if response.clsiServerId?
+ ide.clsiServerId = qs.clsiserverid = response.clsiServerId
if response.status == "timedout"
$scope.pdf.view = 'errors'
$scope.pdf.timedout = true
+ fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
+ else if response.status == "terminated"
+ $scope.pdf.view = 'errors'
+ $scope.pdf.compileTerminated = true
+ fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
else if response.status == "autocompile-backoff"
$scope.pdf.view = 'uncompiled'
else if response.status == "project-too-large"
@@ -72,12 +141,13 @@ define [
else if response.status == "too-recently-compiled"
$scope.pdf.view = 'errors'
$scope.pdf.tooRecentlyCompiled = true
+ else if response.status == "validation-problems"
+ $scope.pdf.view = "validation-problems"
+ $scope.pdf.validation = response.validationProblems
else if response.status == "success"
$scope.pdf.view = 'pdf'
$scope.shouldShowLogs = false
- # prepare query string
- qs = {}
# define the base url. if the pdf file has a build number, pass it to the clsi in the url
if fileByPath['output.pdf']?.url?
$scope.pdf.url = fileByPath['output.pdf'].url
@@ -89,17 +159,11 @@ define [
# check if we need to bust cache (build id is unique so don't need it in that case)
if not fileByPath['output.pdf']?.build?
qs.cache_bust = "#{Date.now()}"
- # add a query string parameter for the compile group
- if response.compileGroup?
- $scope.pdf.compileGroup = response.compileGroup
- qs.compileGroup = "#{$scope.pdf.compileGroup}"
- if response.clsiServerId?
- qs.clsiserverid = response.clsiServerId
- ide.clsiServerId = response.clsiServerId
# convert the qs hash into a query string and append it
- qs_args = ("#{k}=#{v}" for k, v of qs)
- $scope.pdf.qs = if qs_args.length then "?" + qs_args.join("&") else ""
- $scope.pdf.url += $scope.pdf.qs
+ $scope.pdf.url += createQueryString qs
+ # Save all downloads as files
+ qs.popupDownload = true
+ $scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
fetchLogs(fileByPath['output.log'], fileByPath['output.blg'])
@@ -108,17 +172,18 @@ define [
if !response.outputFiles?
return
+
+ # prepare list of output files for download dropdown
+ qs = {}
+ if response.clsiServerId?
+ qs.clsiserverid = response.clsiServerId
for file in response.outputFiles
if IGNORE_FILES.indexOf(file.path) == -1
- # Turn 'output.blg' into 'blg file'.
- if file.path.match(/^output\./)
- file.name = "#{file.path.replace(/^output\./, "")} file"
- else
- file.name = file.path
- file.url = "/project/#{project_id}/output/#{file.path}"
- if response.clsiServerId?
- file.url = file.url + "?clsiserverid=#{response.clsiServerId}"
- $scope.pdf.outputFiles.push file
+ $scope.pdf.outputFiles.push {
+ # Turn 'output.blg' into 'blg file'.
+ name: if file.path.match(/^output\./) then "#{file.path.replace(/^output\./, "")} file" else file.path
+ url: "/project/#{project_id}/output/#{file.path}" + createQueryString qs
+ }
fetchLogs = (logFile, blgFile) ->
@@ -127,14 +192,17 @@ define [
opts =
method:"GET"
params:
- build:file.build
+ compileGroup:ide.compileGroup
clsiserverid:ide.clsiServerId
- if file.url? # FIXME clean this up when we have file.urls out consistently
+ if file?.url? # FIXME clean this up when we have file.urls out consistently
opts.url = file.url
else if file?.build?
opts.url = "/project/#{$scope.project_id}/build/#{file.build}/output/#{name}"
else
opts.url = "/project/#{$scope.project_id}/output/#{name}"
+ # check if we need to bust cache (build id is unique so don't need it in that case)
+ if not file?.build?
+ opts.params.cache_bust = "#{Date.now()}"
return $http(opts)
# accumulate the log entries
@@ -150,7 +218,7 @@ define [
# use the parsers for each file type
processLog = (log) ->
$scope.pdf.rawLog = log
- {errors, warnings, typesetting} = LogParser.parse(log, ignoreDuplicates: true)
+ {errors, warnings, typesetting} = HumanReadableLogs.parse(log, ignoreDuplicates: true)
all = [].concat errors, warnings, typesetting
accumulateResults {all, errors, warnings}
@@ -204,7 +272,7 @@ define [
return null
normalizeFilePath = (path) ->
- path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
+ path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/, "")
path = path.replace(/^\/compile\//, "")
rootDocDirname = ide.fileTreeManager.getRootDocDirname()
@@ -215,6 +283,9 @@ define [
$scope.recompile = (options = {}) ->
return if $scope.pdf.compiling
+
+ event_tracking.sendCountlySampled "editor-recompile-sampled", options
+
$scope.pdf.compiling = true
ide.$scope.$broadcast("flush-changes")
@@ -234,6 +305,21 @@ define [
# This needs to be public.
ide.$scope.recompile = $scope.recompile
+ # This method is a simply wrapper and exists only for tracking purposes.
+ ide.$scope.recompileViaKey = () ->
+ $scope.recompile { keyShortcut: true }
+
+ $scope.stop = () ->
+ return if !$scope.pdf.compiling
+
+ $http {
+ url: "/project/#{$scope.project_id}/compile/stop"
+ method: "POST"
+ params:
+ clsiserverid:ide.clsiServerId
+ headers:
+ "X-Csrf-Token": window.csrfToken
+ }
$scope.clearCache = () ->
$http {
@@ -247,6 +333,7 @@ define [
$scope.toggleLogs = () ->
$scope.shouldShowLogs = !$scope.shouldShowLogs
+ event_tracking.sendCountlyOnce "ide-open-logs-once" if $scope.shouldShowLogs
$scope.showPdf = () ->
$scope.pdf.view = "pdf"
@@ -254,6 +341,7 @@ define [
$scope.toggleRawLog = () ->
$scope.pdf.showRawLog = !$scope.pdf.showRawLog
+ event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
$scope.openClearCacheModal = () ->
modalInstance = $modal.open(
@@ -287,10 +375,16 @@ define [
$scope.startFreeTrial = (source) ->
ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
+
+ event_tracking.sendCountly "subscription-start-trial", { source }
+
window.open("/user/subscription/new?planCode=student_free_trial_7_days")
$scope.startedFreeTrial = true
App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
+ # enable per-user containers by default
+ perUserCompile = true
+
synctex =
syncToPdf: (cursorPosition) ->
deferred = $q.defer()
@@ -339,18 +433,35 @@ define [
deferred.reject()
return deferred.promise
+ # FIXME: this actually works better if it's halfway across the
+ # page (or the visible part of the page). Synctex doesn't
+ # always find the right place in the file when the point is at
+ # the edge of the page, it sometimes returns the start of the
+ # next paragraph instead.
+ h = position.offset.left
+
+ # Compute the vertical position to pass to synctex, which
+ # works with coordinates increasing from the top of the page
+ # down. This matches the browser's DOM coordinate of the
+ # click point, but the pdf position is measured from the
+ # bottom of the page so we need to invert it.
+ if options.fromPdfPosition and position.pageSize?.height?
+ v = (position.pageSize.height - position.offset.top) or 0 # measure from pdf point (inverted)
+ else
+ v = position.offset.top or 0 # measure from html click position
+
# It's not clear exactly where we should sync to if it wasn't directly
# clicked on, but a little bit down from the very top seems best.
if options.includeVisualOffset
- position.offset.top = position.offset.top + 80
+ v += 72 # use the same value as in pdfViewer highlighting visual offset
$http({
url: "/project/#{ide.project_id}/sync/pdf",
method: "GET",
params: {
page: position.page + 1
- h: position.offset.left.toFixed(2)
- v: position.offset.top.toFixed(2)
+ h: h.toFixed(2)
+ v: v.toFixed(2)
clsiserverid:ide.clsiServerId
}
})
@@ -380,14 +491,15 @@ define [
$scope.syncToCode = () ->
synctex
- .syncToCode($scope.pdf.position, includeVisualOffset: true)
+ .syncToCode($scope.pdf.position, includeVisualOffset: true, fromPdfPosition: true)
.then (data) ->
{doc, line} = data
ide.editorManager.openDoc(doc, gotoLine: line)
]
- App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
+ App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
$scope.openInEditor = (entry) ->
+ event_tracking.sendCountlyOnce "logs-jump-to-location-once"
entity = ide.fileTreeManager.findEntityByPath(entry.file)
return if !entity? or entity.type != "doc"
if entry.line?
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee
index 2df89bc35f..f1c161ffc6 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee
+++ b/services/web/public/coffee/ide/pdfng/directives/pdfJs.coffee
@@ -1,23 +1,14 @@
define [
"base"
"ide/pdfng/directives/pdfViewer"
- "text!libs/pdfListView/TextLayer.css"
- "text!libs/pdfListView/AnnotationsLayer.css"
- "text!libs/pdfListView/HighlightsLayer.css"
], (
App
pdfViewer
- textLayerCss
- annotationsLayerCss
- highlightsLayerCss
+
) ->
if PDFJS?
PDFJS.workerSrc = window.pdfJsWorkerPath
- style = $("")
- style.text(textLayerCss + "\n" + annotationsLayerCss + "\n" + highlightsLayerCss)
- $("body").append(style)
-
App.directive "pdfng", ["$timeout", "localStorage", ($timeout, localStorage) ->
return {
scope: {
@@ -27,15 +18,6 @@ define [
"dblClickCallback": "="
}
link: (scope, element, attrs) ->
- # pdfListView = new PDFListView element.find(".pdfjs-viewer")[0],
- # textLayerBuilder: TextLayerBuilder
- # annotationsLayerBuilder: AnnotationsLayerBuilder
- # highlightsLayerBuilder: HighlightsLayerBuilder
- # ondblclick: (e) -> onDoubleClick(e)
- # # logLevel: PDFListView.Logger.DEBUG
- # pdfListView.listView.pageWidthOffset = 20
- # pdfListView.listView.pageHeightOffset = 20
-
scope.loading = false
scope.pleaseJumpTo = null
scope.scale = null
@@ -50,9 +32,11 @@ define [
scope.scale = { scaleMode: 'scale_mode_fit_width' }
if (position = localStorage("pdf.position.#{attrs.key}"))
- scope.position = { page: +position.page, offset: { "top": +position.offset.top, "left": +position.offset.left } }
-
- #scope.position = pdfListView.getPdfPosition(true)
+ scope.position =
+ page: +position.page,
+ offset:
+ "top": +position.offset.top
+ "left": +position.offset.left
scope.$on "$destroy", () =>
localStorage "pdf.scale", scope.scale
@@ -78,56 +62,18 @@ define [
scope.$watch "pdfSrc", (url) ->
if url
scope.loading = true
- # console.log 'pdfSrc =', url
+ scope.loaded = false
+ scope.progress = 1
initializePosition()
flashControls()
- # pdfListView
- # .loadPdf(url, onProgress)
- # .then () ->
- # scope.$apply () ->
- # scope.loading = false
- # delete scope.progress
- # initializePosition()
- # flashControls()
-
+
scope.$on "loaded", () ->
+ scope.loaded = true
scope.progress = 100
- scope.$apply()
$timeout () ->
scope.loading = false
delete scope.progress
- , 250
-
- #scope.$watch "highlights", (areas) ->
- # console.log 'got HIGHLIGHTS in pdfJS', areas
-
- # return if !areas?
- # highlights = for area in areas or []
- # {
- # page: area.page
- # highlight:
- # left: area.h
- # top: area.v
- # height: area.height
- # width: area.width
- # }
-
- # if highlights.length > 0
- # first = highlights[0]
- # position = {
- # page: first.page
- # offset:
- # left: first.highlight.left
- # top: first.highlight.top - 80
- # }
- # console.log 'position is', position, 'in highlights'
- # scope.pleaseJumpTo = position
- # pdfListView.clearHighlights()
- # pdfListView.setHighlights(highlights, true)
-
- # setTimeout () =>
- # pdfListView.clearHighlights()
- # , 1000
+ , 500
scope.fitToHeight = () ->
scale = angular.copy (scope.scale)
@@ -155,10 +101,10 @@ define [
for event in attrs.resizeOn.split(",")
scope.$on event, (e) ->
#console.log 'got a resize event', event, e
- #
scope.$on 'progress', (event, progress) ->
scope.$apply () ->
+ return if scope.loaded
scope.progress = Math.floor(progress.loaded/progress.total*100)
scope.progress = 100 if scope.progress > 100
scope.progress = 0 if scope.progress < 0
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee
index d52ef60256..7009c87892 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee
+++ b/services/web/public/coffee/ide/pdfng/directives/pdfPage.coffee
@@ -3,7 +3,7 @@ define [
], (App) ->
# App = angular.module 'pdfPage', ['pdfHighlights']
- App.directive 'pdfPage', ['$timeout', 'pdfHighlights', ($timeout, pdfHighlights) ->
+ App.directive 'pdfPage', ['$timeout', 'pdfHighlights', 'pdfSpinner', ($timeout, pdfHighlights, pdfSpinner) ->
{
require: '^pdfViewer',
template: '''
@@ -82,7 +82,7 @@ define [
if scope.timeoutHandler
$timeout.cancel(scope.timeoutHandler)
highlightsLayer.clearHighlights()
- scope.timeoutHandler
+ scope.timeoutHandler = null
# console.log 'got highlight watch in pdfPage', scope.page
pageHighlights = (h for h in highlights when h.page == scope.page.pageNum)
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
index 5639d670f0..d0f2f8a3ae 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
+++ b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
@@ -8,11 +8,13 @@ define [
class PDFRenderer
JOB_QUEUE_INTERVAL: 25
PAGE_LOAD_TIMEOUT: 60*1000
- PAGE_RENDER_TIMEOUT: 60*1000
+ INDICATOR_DELAY1: 100 # time to delay before showing the indicator
+ INDICATOR_DELAY2: 250 # time until the indicator starts animating
constructor: (@url, @options) ->
# PDFJS.disableFontFace = true # avoids repaints, uses worker more
- # PDFJS.disableAutoFetch = true # enable this to prevent loading whole file
+ if @options.disableAutoFetch
+ PDFJS.disableAutoFetch = true # prevent loading whole file
# PDFJS.disableStream
# PDFJS.disableRange
@scale = @options.scale || 1
@@ -28,15 +30,22 @@ define [
@errorCallback = @options.errorCallback
@pageSizeChangeCallback = @options.pageSizeChangeCallback
@pdfjs.promise.catch (exception) =>
- # console.log 'ERROR in get document', exception
+ # error getting document
@errorCallback(exception)
resetState: () ->
- @complete = []
- @timeout = []
- @pageLoad = []
- @renderTask = []
@renderQueue = []
+ clearTimeout @queueTimer if @queueTimer?
+ # clear any existing timers, render tasks
+ for timer in @spinTimer or []
+ clearTimeout timer
+ for page in @pageState or []
+ page?.loadTask?.cancel()
+ page?.renderTask?.cancel()
+ # initialise/reset the state
+ @pageState = []
+ @spinTimer = [] # timers for starting the spinners (to avoid jitter)
+ @spinTimerDone = [] # array of pages where the spinner has activated
@jobs = 0
getNumPages: () ->
@@ -45,7 +54,6 @@ define [
getPage: (pageNum) ->
@document.then (pdfDocument) ->
- # console.log 'got pdf document, now getting Page', pageNum
pdfDocument.getPage(pageNum)
getPdfViewport: (pageNum, scale) ->
@@ -80,14 +88,14 @@ define [
@resetState()
triggerRenderQueue: (interval = @JOB_QUEUE_INTERVAL) ->
+ if @queueTimer?
+ clearTimeout @queueTimer
@queueTimer = setTimeout () =>
@queueTimer = null
@processRenderQueue()
, interval
- removeCompletedJob: (taskRef, pagenum) ->
- # may need to clean up deferred object here
- delete taskRef[pagenum]
+ removeCompletedJob: (pagenum) ->
@jobs = @jobs - 1
@triggerRenderQueue(0)
@@ -109,69 +117,129 @@ define [
@renderQueue.push current
@processRenderQueue()
+ getPageDetails: (page) ->
+ return [page.element.canvas, page.pagenum]
+
+ # handle the loading indicators for each page
+
+ startIndicators: () ->
+ # make an array of the pages in the queue
+ @queuedPages = []
+ @queuedPages[page.pagenum] = true for page in @renderQueue
+ # clear any unfinished spinner timers on pages that aren't in the queue any more
+ for pagenum of @spinTimer when not @queuedPages[pagenum]
+ clearTimeout @spinTimer[pagenum]
+ delete @spinTimer[pagenum]
+ # add indicators for any new pages in the current queue
+ for page in @renderQueue when not @spinTimer[page.pagenum] and not @spinTimerDone[page.pagenum]
+ @startIndicator page
+
+ startIndicator: (page) ->
+ [canvas, pagenum] = @getPageDetails page
+ canvas.addClass('pdfng-loading')
+ @spinTimer[pagenum] = setTimeout () =>
+ for queuedPage in @renderQueue
+ if pagenum == queuedPage.pagenum
+ @spinner.add(canvas, {static:true})
+ @spinTimerDone[pagenum] = true
+ break
+ delete @spinTimer[pagenum]
+ , @INDICATOR_DELAY1
+
+ updateIndicator: (page) ->
+ [canvas, pagenum] = @getPageDetails page
+ # did the spinner insert itself already?
+ if @spinTimerDone[pagenum]
+ @spinTimer[pagenum] = setTimeout () =>
+ @spinner.start(canvas)
+ delete @spinTimer[pagenum]
+ , @INDICATOR_DELAY2
+ else
+ # stop the existing spin timer
+ clearTimeout @spinTimer[pagenum]
+ # start a new one which will also start spinning
+ @spinTimer[pagenum] = setTimeout () =>
+ @spinner.add(canvas, {static:true})
+ @spinTimerDone[pagenum] = true
+ @spinTimer[pagenum] = setTimeout () =>
+ @spinner.start(canvas)
+ delete @spinTimer[pagenum]
+ , @INDICATOR_DELAY2
+ , @INDICATOR_DELAY1
+
+ clearIndicator: (page) ->
+ [canvas, pagenum] = @getPageDetails page
+ @spinner.stop(canvas)
+ clearTimeout @spinTimer[pagenum]
+ delete @spinTimer[pagenum]
+ @spinTimerDone[pagenum] = true
+
+ # handle the queue of pages to be rendered
+
processRenderQueue: () ->
return if @shuttingDown
+ # mark all pages in the queue as loading
+ @startIndicators()
+ # bail out if there is already a render job running
return if @jobs > 0
- current = @renderQueue.shift()
- return unless current?
- [element, pagenum] = [current.element, current.pagenum]
- # if task is underway or complete, go to the next entry in the
- # render queue
- # console.log 'processing renderq', pagenum, @renderTask[pagenum], @complete[pagenum]
- if @pageLoad[pagenum] or @renderTask[pagenum] or @complete[pagenum]
- @processRenderQueue()
- return
+ # take the first page in the queue
+ page = @renderQueue.shift()
+ # check if it is in action already
+ while page? and @pageState[page.pagenum]?
+ page = @renderQueue.shift()
+ return unless page?
+ [element, pagenum] = [page.element, page.pagenum]
@jobs = @jobs + 1
- element.canvas.addClass('pdfng-loading')
- spinTimer = setTimeout () =>
- @spinner.add(element.canvas)
- , 100
-
- completeRef = @complete
- renderTaskRef = @renderTask
- # console.log 'started page load', pagenum
+ # update the spinner to make it spinning (signifies loading has begun)
+ @updateIndicator page
timedOut = false
- timer = $timeout () =>
- Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
- # console.log 'page load timed out', pagenum
+ timer = $timeout () => # page load timed out
+ return if loadTask.cancelled # return from cancelled page load
+ Raven?.captureMessage?('pdfng page load timed out after ' + @PAGE_LOAD_TIMEOUT + 'ms')
timedOut = true
- clearTimeout(spinTimer)
- @spinner.stop(element.canvas)
+ @clearIndicator page
# @jobs = @jobs - 1
# @triggerRenderQueue(0)
@errorCallback?('timeout')
, @PAGE_LOAD_TIMEOUT
- @pageLoad[pagenum] = @getPage(pagenum)
+ loadTask = @getPage(pagenum)
- @pageLoad[pagenum].then (pageObject) =>
- # console.log 'in page load success', pagenum
+ loadTask.cancel = () ->
+ @cancelled = true
+
+ @pageState[pagenum] = pageState = { loadTask: loadTask }
+
+ loadTask.then (pageObject) =>
+ # page load success
$timeout.cancel(timer)
- clearTimeout(spinTimer)
- @renderTask[pagenum] = @doRender element, pagenum, pageObject
- @renderTask[pagenum].then () =>
- # complete
- # console.log 'render task success', pagenum
- completeRef[pagenum] = true
- @removeCompletedJob renderTaskRef, pagenum
+ return if loadTask.cancelled # return from cancelled page load
+ pageState.renderTask = @doRender element, pagenum, pageObject
+ pageState.renderTask.then () =>
+ # render task success
+ @clearIndicator page
+ pageState.complete = true
+ delete pageState.renderTask
+ @removeCompletedJob pagenum
, () =>
- # console.log 'render task failed', pagenum
- # rejected
- @removeCompletedJob renderTaskRef, pagenum
- .catch (error) ->
- # console.log 'in page load error', pagenum, 'timedOut=', timedOut
+ # render task failed
+ # could display an error icon
+ pageState.complete = false
+ delete pageState.renderTask
+ @removeCompletedJob pagenum
+ .catch (error) =>
+ # page load error
$timeout.cancel(timer)
- clearTimeout(spinTimer)
- # console.log 'ERROR', error
+ @clearIndicator page
doRender: (element, pagenum, page) ->
self = this
scale = @scale
if (not scale?)
- # console.log 'scale is undefined, returning'
+ # scale is undefined, returning
return
canvas = $('')
@@ -220,28 +288,15 @@ define [
navigateFn: @navigateFn
})
- element.canvas.replaceWith(canvas)
-
- # console.log 'staring page render', pagenum
-
result = page.render {
canvasContext: ctx
viewport: viewport
transform: [pixelRatio, 0, 0, pixelRatio, 0, 0]
}
- timedOut = false
-
- timer = $timeout () =>
- Raven?.captureMessage?('pdfng page render timed out after ' + @PAGE_RENDER_TIMEOUT + 'ms (1% sample)') if Math.random() < 0.01
- # console.log 'page render timed out', pagenum
- timedOut = true
- result.cancel()
- , @PAGE_RENDER_TIMEOUT
-
result.then () ->
- # console.log 'page rendered', pagenum
- $timeout.cancel(timer)
+ # page render success
+ element.canvas.replaceWith(canvas)
canvas.removeClass('pdfng-rendering')
page.getTextContent().then (textContent) ->
textLayer.setTextContent textContent
@@ -252,24 +307,17 @@ define [
, (error) ->
self.errorCallback?(error)
.catch (error) ->
- # console.log 'page render failed', pagenum, error
- $timeout.cancel(timer)
- if timedOut
- # console.log 'calling ERROR callback - was timeout'
- self.errorCallback?('timeout')
- else if error != 'cancelled'
- # console.log 'calling ERROR callback'
+ # page render failed
+ if error is 'cancelled'
+ return # do nothing when cancelled
+ else
self.errorCallback?(error)
return result
destroy: () ->
- # console.log 'in pdf renderer destroy', @renderQueue
@shuttingDown = true
- clearTimeout @queueTimer if @queueTimer?
- @renderQueue = []
- for task in @renderTask
- task.cancel() if task?
+ @resetState()
@pdfjs.then (document) ->
document.cleanup()
document.destroy()
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee
index da764995ad..c17ebe6b32 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee
+++ b/services/web/public/coffee/ide/pdfng/directives/pdfSpinner.coffee
@@ -8,14 +8,15 @@ define [
constructor: () ->
# handler for spinners
- add: (element) ->
- h = element.height()
- w = element.width()
+ add: (element, options) ->
size = 64
- spinner = $('
')
+ spinner = $('
')
spinner.css({'font-size' : size + 'px'})
element.append(spinner)
+ start: (element) ->
+ element.find('.fa-spinner').addClass('fa-spin')
+
stop: (element) ->
element.find('.fa-spinner').removeClass('fa-spin')
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
index f0ec8d61b1..80306771df 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
+++ b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
@@ -27,8 +27,15 @@ define [
$scope.document.destroy() if $scope.document?
$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
# TODO need a proper url manipulation library to add to query string
- $scope.document = new PDFRenderer($scope.pdfSrc + '&pdfng=true' , {
+ url = $scope.pdfSrc
+ # add 'pdfng=true' to show that we are using the angular pdfjs viewer
+ queryStringExists = url.match(/\?/)
+ url = url + (if not queryStringExists then '?' else '&') + 'pdfng=true'
+ # for isolated compiles, load the pdf on-demand because nobody will overwrite it
+ onDemandLoading = true
+ $scope.document = new PDFRenderer(url, {
scale: 1,
+ disableAutoFetch: if onDemandLoading then true else undefined
navigateFn: (ref) ->
# this function captures clicks on the annotation links
$scope.navigateTo = ref
@@ -38,7 +45,7 @@ define [
loadedCallback: () ->
$scope.$emit 'loaded'
errorCallback: (error) ->
- Raven?.captureMessage?('pdfng error ' + error + ' (1% sample)') if Math.random() < 0.01
+ Raven?.captureMessage?('pdfng error ' + error)
$scope.$emit 'pdf:error', error
pageSizeChangeCallback: (pageNum, deltaH) ->
$scope.$broadcast 'pdf:page:size-change', pageNum, deltaH
@@ -57,6 +64,7 @@ define [
result.pdfViewport.width
]
# console.log 'resolved q.all, page size is', result
+ $scope.$emit 'loaded'
$scope.numPages = result.numPages
.catch (error) ->
$scope.$emit 'pdf:error', error
@@ -187,7 +195,8 @@ define [
# console.log 'converted to offset = ', pdfOffset
newPosition = {
"page": topPageIdx,
- "offset" : { "top" : pdfOffset[1], "left": 0 }
+ "offset" : { "top" : pdfOffset[1], "left": 0}
+ "pageSize": { "height": viewport.viewBox[3], "width": viewport.viewBox[2] }
}
return newPosition
@@ -208,6 +217,8 @@ define [
return $scope.document.getPdfViewport(page.pageNum).then (viewport) ->
page.viewport = viewport
pageOffset = viewport.convertToViewportPoint(offset.left, offset.top)
+ # if the passed-in position doesn't have the page height/width add them now
+ position.pageSize ?= {"height": viewport.viewBox[3], "width": viewport.viewBox[2]}
# console.log 'addition offset =', pageOffset
# console.log 'total', pageTop + pageOffset[1]
Math.round(pageTop + pageOffset[1] + currentScroll) ## 10 is margin
@@ -246,8 +257,8 @@ define [
# console.log 'layoutReady was resolved'
renderVisiblePages = () ->
- pages = getVisiblePages()
- # pages = getExtraPages visiblePages
+ visiblePages = getVisiblePages()
+ pages = getExtraPages visiblePages
scope.document.renderPages(pages)
getVisiblePages = () ->
@@ -264,20 +275,21 @@ define [
getExtraPages = (visiblePages) ->
extra = []
- firstVisiblePage = visiblePages[0].pageNum
- firstVisiblePageIdx = firstVisiblePage - 1
- len = visiblePages.length
- lastVisiblePage = visiblePages[len-1].pageNum
- lastVisiblePageIdx = lastVisiblePage - 1
- # first page after
- if lastVisiblePageIdx + 1 < scope.pages.length
- extra.push scope.pages[lastVisiblePageIdx + 1]
- # page before
- if firstVisiblePageIdx > 0
- extra.push scope.pages[firstVisiblePageIdx - 1]
- # second page after
- if lastVisiblePageIdx + 2 < scope.pages.length
- extra.push scope.pages[lastVisiblePageIdx + 2]
+ if visiblePages.length > 0
+ firstVisiblePage = visiblePages[0].pageNum
+ firstVisiblePageIdx = firstVisiblePage - 1
+ len = visiblePages.length
+ lastVisiblePage = visiblePages[len-1].pageNum
+ lastVisiblePageIdx = lastVisiblePage - 1
+ # first page after
+ if lastVisiblePageIdx + 1 < scope.pages.length
+ extra.push scope.pages[lastVisiblePageIdx + 1]
+ # page before
+ if firstVisiblePageIdx > 0
+ extra.push scope.pages[firstVisiblePageIdx - 1]
+ # second page after
+ if lastVisiblePageIdx + 2 < scope.pages.length
+ extra.push scope.pages[lastVisiblePageIdx + 2]
return visiblePages.concat extra
rescaleTimer = null
@@ -513,8 +525,17 @@ define [
first = highlights[0]
- pageNum = scope.pages[first.page].pageNum
+ # switching between split and full pdf views can cause
+ # highlights to appear before rendering
+ if !scope.pages
+ return # ignore highlight scroll if still rendering
+ pageNum = scope.pages[first.page]?.pageNum
+
+ if !pageNum?
+ return # ignore highlight scroll if page not found
+
+ # use a visual offset of 72pt to match the offset in PdfController syncToCode
scope.document.getPdfViewport(pageNum).then (viewport) ->
position = {
page: first.page
diff --git a/services/web/public/coffee/ide/settings/services/settings.coffee b/services/web/public/coffee/ide/settings/services/settings.coffee
index 4e6bbcea3d..14dfe92f56 100644
--- a/services/web/public/coffee/ide/settings/services/settings.coffee
+++ b/services/web/public/coffee/ide/settings/services/settings.coffee
@@ -1,19 +1,43 @@
define [
"base"
], (App) ->
- App.factory "settings", ["ide", (ide) ->
+ App.factory "settings", ["ide", "event_tracking", (ide, event_tracking) ->
return {
saveSettings: (data) ->
+ # Tracking code.
+ for key in Object.keys(data)
+ changedSetting = key
+ changedSettingVal = data[key]
+ event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
+ # End of tracking code.
+
data._csrf = window.csrfToken
ide.$http.post "/user/settings", data
+
saveProjectSettings: (data) ->
+ # Tracking code.
+ for key in Object.keys(data)
+ changedSetting = key
+ changedSettingVal = data[key]
+ event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
+ # End of tracking code.
+
data._csrf = window.csrfToken
ide.$http.post "/project/#{ide.project_id}/settings", data
+
saveProjectAdminSettings: (data) ->
+ # Tracking code.
+ for key in Object.keys(data)
+ changedSetting = key
+ changedSettingVal = data[key]
+ event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
+ # End of tracking code.
+
data._csrf = window.csrfToken
ide.$http.post "/project/#{ide.project_id}/settings/admin", data
+
}
]
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/share/controllers/ShareController.coffee b/services/web/public/coffee/ide/share/controllers/ShareController.coffee
index d76bd07965..2378391974 100644
--- a/services/web/public/coffee/ide/share/controllers/ShareController.coffee
+++ b/services/web/public/coffee/ide/share/controllers/ShareController.coffee
@@ -1,8 +1,10 @@
define [
"base"
], (App) ->
- App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
+ App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
$scope.openShareProjectModal = () ->
+ event_tracking.sendCountlyOnce "ide-open-share-modal-once"
+
$modal.open(
templateUrl: "shareProjectModalTemplate"
controller: "ShareProjectModalController"
diff --git a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee b/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
index e880a25eef..2aa4efb505 100644
--- a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
+++ b/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
@@ -4,7 +4,7 @@ define [
App.controller 'WordCountModalController', ($scope, $modalInstance, ide, $http) ->
$scope.status =
loading:true
-
+
opts =
url:"/project/#{ide.project_id}/wordcount"
method:"GET"
@@ -18,4 +18,4 @@ define [
$scope.status.error = true
$scope.cancel = () ->
- $modalInstance.dismiss('cancel')
\ No newline at end of file
+ $modalInstance.dismiss('cancel')
diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee
index fd1b5d0c91..d41170391c 100644
--- a/services/web/public/coffee/libs.coffee
+++ b/services/web/public/coffee/libs.coffee
@@ -16,3 +16,4 @@ define [
"libs/angular-sixpack"
"libs/ng-tags-input-3.0.0"
], () ->
+
diff --git a/services/web/public/coffee/main/account-upgrade.coffee b/services/web/public/coffee/main/account-upgrade.coffee
index b212f65fc1..956c340d14 100644
--- a/services/web/public/coffee/main/account-upgrade.coffee
+++ b/services/web/public/coffee/main/account-upgrade.coffee
@@ -1,11 +1,13 @@
define [
"base"
], (App) ->
- App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack)->
+ App.controller "FreeTrialModalController", ($scope, abTestManager, sixpack, event_tracking)->
$scope.buttonClass = "btn-primary"
$scope.startFreeTrial = (source, couponCode) ->
+ event_tracking.sendCountly "subscription-start-trial", { source }
+
w = window.open()
sixpack.convert "track-changes-discount", ->
sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->
diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee
index 08bf5bfa45..0f722df369 100644
--- a/services/web/public/coffee/main/contact-us.coffee
+++ b/services/web/public/coffee/main/contact-us.coffee
@@ -1,9 +1,8 @@
define [
"base"
"libs/platform"
+ "services/algolia-search"
], (App, platform) ->
-
-
App.controller 'ContactModal', ($scope, $modal) ->
$scope.contactUsModal = () ->
modalInstance = $modal.open(
@@ -11,10 +10,25 @@ define [
controller: "SupportModalController"
)
- App.controller 'SupportModalController', ($scope, $modalInstance) ->
+ 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.sendCountly "contact-form-suggestions-shown" if results.hits.length
+
+ $scope.$applyAsync () ->
+ $scope.suggestions = suggestions
+
$scope.contactUs = ->
if !$scope.form.email?
console.log "email not set"
@@ -36,6 +50,18 @@ define [
$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.sendCountly "contact-form-suggestions-clicked", { url }
+
$scope.close = () ->
$modalInstance.close()
diff --git a/services/web/public/coffee/main/event.coffee b/services/web/public/coffee/main/event.coffee
index c6e9aed1da..371bd1004e 100644
--- a/services/web/public/coffee/main/event.coffee
+++ b/services/web/public/coffee/main/event.coffee
@@ -1,17 +1,61 @@
define [
"base"
+ "modules/localStorage"
], (App) ->
+ CACHE_KEY = "countlyEvents"
+
+ App.factory "event_tracking", (localStorage) ->
+ _getEventCache = () ->
+ eventCache = localStorage CACHE_KEY
+
+ # Initialize as an empy object if the event cache is still empty.
+ if !eventCache?
+ eventCache = {}
+ localStorage CACHE_KEY, eventCache
+
+ return eventCache
+
+ _eventInCache = (key) ->
+ curCache = _getEventCache()
+ curCache[key] || false
+
+ _addEventToCache = (key) ->
+ curCache = _getEventCache()
+ curCache[key] = true
+
+ localStorage CACHE_KEY, curCache
- App.factory "event_tracking", ->
return {
send: (category, action, label, value)->
ga('send', 'event', category, action, label, value)
+
+ sendCountly: (key, segmentation) ->
+ eventData = { key }
+ eventData.segmentation = segmentation if segmentation?
+ Countly?.q.push([ "add_event", eventData ])
+
+ sendCountlySampled: (key, segmentation) ->
+ @sendCountly key, segmentation if Math.random() < .01
+
+ sendCountlyOnce: (key, segmentation) ->
+ if ! _eventInCache(key)
+ _addEventToCache(key)
+ @sendCountly key, segmentation
}
+ # App.directive "countlyTrack", () ->
+ # return {
+ # restrict: "A"
+ # scope: false,
+ # link: (scope, el, attrs) ->
+ # eventKey = attrs.countlyTrack
+ # if (eventKey?)
+ # el.on "click", () ->
+ # console.log eventKey
+ # }
#header
$('.navbar a').on "click", (e)->
href = $(e.target).attr("href")
if href?
ga('send', 'event', 'navigation', 'top menu bar', href)
-
diff --git a/services/web/public/coffee/main/learn.coffee b/services/web/public/coffee/main/learn.coffee
index 28026be508..eeae4219b8 100644
--- a/services/web/public/coffee/main/learn.coffee
+++ b/services/web/public/coffee/main/learn.coffee
@@ -1,15 +1,9 @@
define [
"base"
+ "services/algolia-search"
], (App) ->
-
- App.factory "algoliawiki", ->
- if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
- client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
- index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
- return index
- App.controller "SearchWikiController", ($scope, algoliawiki, _, $modal) ->
- algolia = algoliawiki
+ App.controller "SearchWikiController", ($scope, algoliaSearch, _, $modal) ->
$scope.hits = []
$scope.clearSearchText = ->
@@ -54,7 +48,7 @@ define [
updateHits []
return
- algolia.search query, (err, response)->
+ algoliaSearch.searchWiki query, (err, response)->
if response.hits.length == 0
updateHits []
else
diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee
index 404b512e7b..bdae2b219e 100644
--- a/services/web/public/coffee/main/new-subscription.coffee
+++ b/services/web/public/coffee/main/new-subscription.coffee
@@ -2,7 +2,7 @@ define [
"base"
], (App)->
- App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack)->
+ App.controller "NewSubscriptionController", ($scope, MultiCurrencyPricing, abTestManager, $http, sixpack, event_tracking)->
throw new Error("Recurly API Library Missing.") if typeof recurly is "undefined"
$scope.currencyCode = MultiCurrencyPricing.currencyCode
@@ -51,6 +51,8 @@ define [
.done()
pricing.on "change", =>
+ event_tracking.sendCountly "subscription-form", { plan : pricing.items.plan.code }
+
$scope.planName = pricing.items.plan.name
$scope.price = pricing.price
$scope.trialLength = pricing.items.plan.trial?.length
@@ -115,9 +117,25 @@ define [
currencyCode:pricing.items.currency
plan_code:pricing.items.plan.code
coupon_code:pricing.items?.coupon?.code || ""
+ isPaypal: $scope.paymentMethod == 'paypal'
+ address:
+ address1: $scope.data.address1
+ address2: $scope.data.address2
+ country: $scope.data.country
+ state: $scope.data.state
+ postal_code: $scope.data.postal_code
+
+ event_tracking.sendCountly "subscription-form-submitted", {
+ currencyCode : postData.subscriptionDetails.currencyCode,
+ plan_code : postData.subscriptionDetails.plan_code,
+ coupon_code : postData.subscriptionDetails.coupon_code,
+ isPaypal : postData.subscriptionDetails.isPaypal
+ }
+
$http.post("/user/subscription/create", postData)
.success (data, status, headers)->
sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)->
+ event_tracking.sendCountly "subscription-submission-success"
window.location.href = "/user/subscription/thank-you"
.error (data, status, headers)->
$scope.processing = false
diff --git a/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee b/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee
index 1811c2c64f..5a96bde5c1 100644
--- a/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee
+++ b/services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee
@@ -3,8 +3,7 @@ define [
], (App) ->
App.controller 'LeftHandMenuPromoController', ($scope) ->
-
- $scope.showDatajoy = Math.random() < 0.5
+
$scope.hasProjects = window.data.projects.length > 0
- $scope.userHasSubscription = window.userHasSubscription
- $scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]
\ No newline at end of file
+ $scope.userHasNoSubscription = window.userHasNoSubscription
+ $scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]
diff --git a/services/web/public/coffee/main/templates.coffee b/services/web/public/coffee/main/templates.coffee
index 3d05d4ea0f..45919d623a 100644
--- a/services/web/public/coffee/main/templates.coffee
+++ b/services/web/public/coffee/main/templates.coffee
@@ -1,15 +1,9 @@
define [
"base"
+ "services/algolia-search"
], (App) ->
-
- App.factory "algoliawiki", ->
- if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
- client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
- index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
- return index
- App.controller "SearchWikiController", ($scope, algoliawiki, _) ->
- algolia = algoliawiki
+ App.controller "SearchWikiController", ($scope, algoliaSearch, _) ->
$scope.hits = []
$scope.clearSearchText = ->
@@ -54,7 +48,7 @@ define [
updateHits []
return
- algolia.search query, (err, response)->
+ algoliaSearch.searchWiki query, (err, response)->
if response.hits.length == 0
updateHits []
else
diff --git a/services/web/public/coffee/services/algolia-search.coffee b/services/web/public/coffee/services/algolia-search.coffee
new file mode 100644
index 0000000000..d62bc6389d
--- /dev/null
+++ b/services/web/public/coffee/services/algolia-search.coffee
@@ -0,0 +1,14 @@
+define [
+ "base"
+], (App) ->
+ App.factory "algoliaSearch", ->
+ if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki?
+ client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key)
+ wikiIdx = client.initIndex(window.sharelatex.algolia?.indexes?.wiki)
+ kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
+
+ service =
+ searchWiki: wikiIdx.search.bind(wikiIdx)
+ searchKB: kbIdx.search.bind(kbIdx)
+
+ return service
\ No newline at end of file
diff --git a/services/web/public/coffee/services/log-hints-feedback.coffee b/services/web/public/coffee/services/log-hints-feedback.coffee
new file mode 100644
index 0000000000..104313e016
--- /dev/null
+++ b/services/web/public/coffee/services/log-hints-feedback.coffee
@@ -0,0 +1,58 @@
+define [
+ "base"
+], (App) ->
+ App.factory "logHintsFeedback", ($http, $q) ->
+ hintsFeedbackFormAPIHash = "rl4xgvr1v5t64a"
+ idStampVal = "OPkEWEFHUFAm7hKlraQMhiOXQabafWo8NipRvLT397w="
+ hintFieldAPIId = "3"
+ reasonFieldAPIId = "1"
+ reasonOtherFieldAPIId = "1_other_other"
+ submitEndpoint = "https://sharelatex.wufoo.eu/forms/#{ hintsFeedbackFormAPIHash }/#public"
+
+ feedbackOpts =
+ DIDNT_UNDERSTAND: "didnt_understand"
+ NOT_APPLICABLE: "not_applicable"
+ INCORRECT: "incorrect"
+ OTHER: "other"
+
+ createRequest = (hintId, feedbackOpt, feedbackOtherVal = "") ->
+ formData = new FormData()
+
+ formData.append "Field#{ hintFieldAPIId }", hintId
+ formData.append "Field#{ reasonFieldAPIId }", feedbackOpt
+ formData.append "idstamp", idStampVal
+
+ # Allow user to specify "other" without any extra details; an empty string
+ # would trigger an error submitting.
+ if feedbackOpt == feedbackOpts.OTHER and feedbackOtherVal == ""
+ formData.append "Field#{ reasonOtherFieldAPIId }", "#{ feedbackOpts.OTHER } empty"
+ else
+ formData.append "Field#{ reasonOtherFieldAPIId }", feedbackOtherVal
+
+ req =
+ method: 'POST'
+ url: submitEndpoint
+ # This will effectively disable Angular's default serialization mechanisms,
+ # forcing the XHR to be done with whatever data we provide (in this case,
+ # form data). Without this, Angular will forcefully try to serialize data
+ # to JSON.
+ transformRequest: angular.identity
+ data: formData
+ headers :
+ # This will tell Angular to use the browser-provided value, which is
+ # computed according to the data being sent (in this case, multipart
+ # form + browser-specific multipart boundary). Without this, Angular
+ # will set JSON.
+ "Content-Type": undefined
+
+ return req
+
+ submitFeedback = (hintId, feedbackOpt, feedbackOtherVal = "") ->
+ submitRequest = createRequest hintId, feedbackOpt, feedbackOtherVal
+ $http(submitRequest)
+
+ service =
+ feedbackOpts: feedbackOpts
+ submitFeedback: submitFeedback
+
+ return service
\ No newline at end of file
diff --git a/services/web/public/img/about/brian_gough.jpg b/services/web/public/img/about/brian_gough.jpg
index bfe3fe6d5c..5b972aecd0 100644
Binary files a/services/web/public/img/about/brian_gough.jpg and b/services/web/public/img/about/brian_gough.jpg differ
diff --git a/services/web/public/img/about/geri.jpg b/services/web/public/img/about/geri.jpg
index abf3af8e81..0de2f9a20e 100644
Binary files a/services/web/public/img/about/geri.jpg and b/services/web/public/img/about/geri.jpg differ
diff --git a/services/web/public/img/about/henry_oswald.jpg b/services/web/public/img/about/henry_oswald.jpg
index d4fd9c739e..234fe214cf 100644
Binary files a/services/web/public/img/about/henry_oswald.jpg and b/services/web/public/img/about/henry_oswald.jpg differ
diff --git a/services/web/public/img/about/james_allen.jpg b/services/web/public/img/about/james_allen.jpg
index 85b8ff0c2b..5a2595c2ed 100644
Binary files a/services/web/public/img/about/james_allen.jpg and b/services/web/public/img/about/james_allen.jpg differ
diff --git a/services/web/public/img/about/kiri_channon.jpg b/services/web/public/img/about/kiri_channon.jpg
index 81f83d2d18..2846989efb 100644
Binary files a/services/web/public/img/about/kiri_channon.jpg and b/services/web/public/img/about/kiri_channon.jpg differ
diff --git a/services/web/public/img/about/michael.jpg b/services/web/public/img/about/michael.jpg
index afb6022a64..a663c704a2 100644
Binary files a/services/web/public/img/about/michael.jpg and b/services/web/public/img/about/michael.jpg differ
diff --git a/services/web/public/img/about/paulo_reis.jpg b/services/web/public/img/about/paulo_reis.jpg
new file mode 100644
index 0000000000..3ab83a30f0
Binary files /dev/null and b/services/web/public/img/about/paulo_reis.jpg differ
diff --git a/services/web/public/img/about/shane_kilkelly.jpg b/services/web/public/img/about/shane_kilkelly.jpg
index b353b2b6ad..081d94aa32 100644
Binary files a/services/web/public/img/about/shane_kilkelly.jpg and b/services/web/public/img/about/shane_kilkelly.jpg differ
diff --git a/services/web/public/img/crests/cambridge.png b/services/web/public/img/crests/cambridge.png
index 515e00a65e..2d052e9d79 100644
Binary files a/services/web/public/img/crests/cambridge.png and b/services/web/public/img/crests/cambridge.png differ
diff --git a/services/web/public/img/crests/durham.png b/services/web/public/img/crests/durham.png
index c3d2ce434f..13ab59ff4c 100644
Binary files a/services/web/public/img/crests/durham.png and b/services/web/public/img/crests/durham.png differ
diff --git a/services/web/public/img/crests/icl.png b/services/web/public/img/crests/icl.png
index 816a782706..9d58a1f94a 100644
Binary files a/services/web/public/img/crests/icl.png and b/services/web/public/img/crests/icl.png differ
diff --git a/services/web/public/img/crests/liverpool.jpg b/services/web/public/img/crests/liverpool.jpg
index 80a9925d7c..b61de4d5fb 100644
Binary files a/services/web/public/img/crests/liverpool.jpg and b/services/web/public/img/crests/liverpool.jpg differ
diff --git a/services/web/public/img/crests/mit.gif b/services/web/public/img/crests/mit.gif
index 9300c107d1..845aaa750d 100644
Binary files a/services/web/public/img/crests/mit.gif and b/services/web/public/img/crests/mit.gif differ
diff --git a/services/web/public/img/crests/nasa.png b/services/web/public/img/crests/nasa.png
index 5b3f7de0da..e2db4e837a 100644
Binary files a/services/web/public/img/crests/nasa.png and b/services/web/public/img/crests/nasa.png differ
diff --git a/services/web/public/img/crests/oxford.gif b/services/web/public/img/crests/oxford.gif
index 4fc9b68689..3124b1ba20 100644
Binary files a/services/web/public/img/crests/oxford.gif and b/services/web/public/img/crests/oxford.gif differ
diff --git a/services/web/public/img/crests/stanford.png b/services/web/public/img/crests/stanford.png
index 23e0f09792..514ecb2176 100644
Binary files a/services/web/public/img/crests/stanford.png and b/services/web/public/img/crests/stanford.png differ
diff --git a/services/web/public/img/crests/tokyo.png b/services/web/public/img/crests/tokyo.png
index 7ace3b4c87..87eee0263a 100644
Binary files a/services/web/public/img/crests/tokyo.png and b/services/web/public/img/crests/tokyo.png differ
diff --git a/services/web/public/img/crests/toronto.gif b/services/web/public/img/crests/toronto.gif
index e41e87a9d5..a4eb9a42bb 100644
Binary files a/services/web/public/img/crests/toronto.gif and b/services/web/public/img/crests/toronto.gif differ
diff --git a/services/web/public/img/crests/yale.png b/services/web/public/img/crests/yale.png
index 53878511a6..78db969673 100644
Binary files a/services/web/public/img/crests/yale.png and b/services/web/public/img/crests/yale.png differ
diff --git a/services/web/public/img/dropbox/document_updated_modal.png b/services/web/public/img/dropbox/document_updated_modal.png
index 7efcdd467a..720263bb5a 100644
Binary files a/services/web/public/img/dropbox/document_updated_modal.png and b/services/web/public/img/dropbox/document_updated_modal.png differ
diff --git a/services/web/public/img/dropbox/dropbox_banner.png b/services/web/public/img/dropbox/dropbox_banner.png
index 64f800f049..a29b9cc407 100644
Binary files a/services/web/public/img/dropbox/dropbox_banner.png and b/services/web/public/img/dropbox/dropbox_banner.png differ
diff --git a/services/web/public/img/dropbox/dropbox_banner_tall.png b/services/web/public/img/dropbox/dropbox_banner_tall.png
index 5a6ae5286d..2045f2d83c 100644
Binary files a/services/web/public/img/dropbox/dropbox_banner_tall.png and b/services/web/public/img/dropbox/dropbox_banner_tall.png differ
diff --git a/services/web/public/img/dropbox/dropbox_logo.png b/services/web/public/img/dropbox/dropbox_logo.png
index ec3c56beca..27a57ad82d 100644
Binary files a/services/web/public/img/dropbox/dropbox_logo.png and b/services/web/public/img/dropbox/dropbox_logo.png differ
diff --git a/services/web/public/img/dropbox/dropbox_progress_bar.png b/services/web/public/img/dropbox/dropbox_progress_bar.png
index 736faf05c1..b72506752e 100644
Binary files a/services/web/public/img/dropbox/dropbox_progress_bar.png and b/services/web/public/img/dropbox/dropbox_progress_bar.png differ
diff --git a/services/web/public/img/dropbox/history_diff.png b/services/web/public/img/dropbox/history_diff.png
index 45ef7dbceb..27629b1628 100644
Binary files a/services/web/public/img/dropbox/history_diff.png and b/services/web/public/img/dropbox/history_diff.png differ
diff --git a/services/web/public/img/dropbox/share_dropbox_folder.png b/services/web/public/img/dropbox/share_dropbox_folder.png
index 1f7acff471..368743fb23 100644
Binary files a/services/web/public/img/dropbox/share_dropbox_folder.png and b/services/web/public/img/dropbox/share_dropbox_folder.png differ
diff --git a/services/web/public/img/dropbox/simple_logo.png b/services/web/public/img/dropbox/simple_logo.png
index 34d5dcd264..998c996fd1 100644
Binary files a/services/web/public/img/dropbox/simple_logo.png and b/services/web/public/img/dropbox/simple_logo.png differ
diff --git a/services/web/public/img/enago.png b/services/web/public/img/enago.png
index 879dd216c0..c8b3a349eb 100644
Binary files a/services/web/public/img/enago.png and b/services/web/public/img/enago.png differ
diff --git a/services/web/public/img/faileupload.png b/services/web/public/img/faileupload.png
index baaf090d94..36c5ef6faf 100644
Binary files a/services/web/public/img/faileupload.png and b/services/web/public/img/faileupload.png differ
diff --git a/services/web/public/img/favicon.png b/services/web/public/img/favicon.png
index cd7357a8bb..6788f68a24 100644
Binary files a/services/web/public/img/favicon.png and b/services/web/public/img/favicon.png differ
diff --git a/services/web/public/img/favicon@2x.png b/services/web/public/img/favicon@2x.png
index f44949c184..65313b7353 100644
Binary files a/services/web/public/img/favicon@2x.png and b/services/web/public/img/favicon@2x.png differ
diff --git a/services/web/public/img/flags/24/en.png b/services/web/public/img/flags/24/en.png
index df8b2a874b..719c69fa67 100644
Binary files a/services/web/public/img/flags/24/en.png and b/services/web/public/img/flags/24/en.png differ
diff --git a/services/web/public/img/flags/24/pt.png b/services/web/public/img/flags/24/pt.png
index 385203aa99..2c3f5df290 100644
Binary files a/services/web/public/img/flags/24/pt.png and b/services/web/public/img/flags/24/pt.png differ
diff --git a/services/web/public/img/flags/32/en.png b/services/web/public/img/flags/32/en.png
index df8b2a874b..719c69fa67 100644
Binary files a/services/web/public/img/flags/32/en.png and b/services/web/public/img/flags/32/en.png differ
diff --git a/services/web/public/img/flags/32/ja.png b/services/web/public/img/flags/32/ja.png
index 69cca05ee8..54d0e5ab88 100644
Binary files a/services/web/public/img/flags/32/ja.png and b/services/web/public/img/flags/32/ja.png differ
diff --git a/services/web/public/img/flags/32/ko.png b/services/web/public/img/flags/32/ko.png
index 2a3a7ee7ae..050ebed117 100644
Binary files a/services/web/public/img/flags/32/ko.png and b/services/web/public/img/flags/32/ko.png differ
diff --git a/services/web/public/img/github/octocat.jpg b/services/web/public/img/github/octocat.jpg
index 38e3a18d3b..0d0509656b 100644
Binary files a/services/web/public/img/github/octocat.jpg and b/services/web/public/img/github/octocat.jpg differ
diff --git a/services/web/public/img/lion-128.png b/services/web/public/img/lion-128.png
index 8660218b7b..5c67094b18 100644
Binary files a/services/web/public/img/lion-128.png and b/services/web/public/img/lion-128.png differ
diff --git a/services/web/public/img/lion-sad-128.png b/services/web/public/img/lion-sad-128.png
index e5e58c42b0..7edf83ada9 100644
Binary files a/services/web/public/img/lion-sad-128.png and b/services/web/public/img/lion-sad-128.png differ
diff --git a/services/web/public/img/logo.png b/services/web/public/img/logo.png
index 3c25641143..7552a994c0 100644
Binary files a/services/web/public/img/logo.png and b/services/web/public/img/logo.png differ
diff --git a/services/web/public/img/logo@2x.png b/services/web/public/img/logo@2x.png
index cf9969c627..aea32a35be 100644
Binary files a/services/web/public/img/logo@2x.png and b/services/web/public/img/logo@2x.png differ
diff --git a/services/web/public/img/pattern-home.png b/services/web/public/img/pattern-home.png
index 30331a07b9..e9782053e7 100644
Binary files a/services/web/public/img/pattern-home.png and b/services/web/public/img/pattern-home.png differ
diff --git a/services/web/public/img/references-search/search_example.gif b/services/web/public/img/references-search/search_example.gif
index 7543e71e3d..397e41ce3f 100644
Binary files a/services/web/public/img/references-search/search_example.gif and b/services/web/public/img/references-search/search_example.gif differ
diff --git a/services/web/public/img/screen.png b/services/web/public/img/screen.png
index c4542a6999..9bfeabfd8b 100644
Binary files a/services/web/public/img/screen.png and b/services/web/public/img/screen.png differ
diff --git a/services/web/public/img/screen@2x.png b/services/web/public/img/screen@2x.png
index 512c21a426..be51af0fd7 100644
Binary files a/services/web/public/img/screen@2x.png and b/services/web/public/img/screen@2x.png differ
diff --git a/services/web/public/img/social/facebook-32.png b/services/web/public/img/social/facebook-32.png
index 0d8753d8bd..5b085e2b32 100644
Binary files a/services/web/public/img/social/facebook-32.png and b/services/web/public/img/social/facebook-32.png differ
diff --git a/services/web/public/img/social/link-32.png b/services/web/public/img/social/link-32.png
index 10d2ed4f47..0c64abc6a1 100644
Binary files a/services/web/public/img/social/link-32.png and b/services/web/public/img/social/link-32.png differ
diff --git a/services/web/public/img/social/mail-32.png b/services/web/public/img/social/mail-32.png
index dc3b9dd3a6..5a2f52dd2b 100644
Binary files a/services/web/public/img/social/mail-32.png and b/services/web/public/img/social/mail-32.png differ
diff --git a/services/web/public/img/social/twitter-32.png b/services/web/public/img/social/twitter-32.png
index aa11c75395..d0b55b9a6e 100644
Binary files a/services/web/public/img/social/twitter-32.png and b/services/web/public/img/social/twitter-32.png differ
diff --git a/services/web/public/img/spellcheck-underline.png b/services/web/public/img/spellcheck-underline.png
index 9b1f719120..07d7c5ce0e 100644
Binary files a/services/web/public/img/spellcheck-underline.png and b/services/web/public/img/spellcheck-underline.png differ
diff --git a/services/web/public/img/spinner.gif b/services/web/public/img/spinner.gif
index 22220a2214..dd95fb6350 100644
Binary files a/services/web/public/img/spinner.gif and b/services/web/public/img/spinner.gif differ
diff --git a/services/web/public/img/sprite.png b/services/web/public/img/sprite.png
new file mode 100644
index 0000000000..7a982e9a25
Binary files /dev/null and b/services/web/public/img/sprite.png differ
diff --git a/services/web/public/js/libs/algolia-2.5.2.js b/services/web/public/js/libs/algolia-2.5.2.js
index fead438491..5b0e30b297 100644
--- a/services/web/public/js/libs/algolia-2.5.2.js
+++ b/services/web/public/js/libs/algolia-2.5.2.js
@@ -1,2039 +1,7 @@
-/*
- * Copyright (c) 2013 Algolia
- * http://www.algolia.com/
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
+/*!
+ * algoliasearch 2.5.2
+ * https://github.com/algolia/algoliasearch-client-js
+ * Copyright 2014 Algolia SAS; Licensed MIT
*/
-var ALGOLIA_VERSION = '2.5.2';
-
-/*
- * Copyright (c) 2013 Algolia
- * http://www.algolia.com/
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
-/*
- * Algolia Search library initialization
- * @param applicationID the application ID you have in your admin interface
- * @param apiKey a valid API key for the service
- * @param method specify if the protocol used is http or https (http by default to make the first search query faster).
- * You need to use https is you are doing something else than just search queries.
- * @param resolveDNS let you disable first empty query that is launch to warmup the service
- * @param hostsArray (optionnal) the list of hosts that you have received for the service
- */
-var AlgoliaSearch = function(applicationID, apiKey, method, resolveDNS, hostsArray) {
- var self = this;
- this.applicationID = applicationID;
- this.apiKey = apiKey;
-
- if (this._isUndefined(hostsArray)) {
- hostsArray = [applicationID + '-1.algolia.io',
- applicationID + '-2.algolia.io',
- applicationID + '-3.algolia.io'];
- }
- this.hosts = [];
- // Add hosts in random order
- for (var i = 0; i < hostsArray.length; ++i) {
- if (Math.random() > 0.5) {
- this.hosts.reverse();
- }
- if (this._isUndefined(method) || method == null) {
- this.hosts.push(('https:' == document.location.protocol ? 'https' : 'http') + '://' + hostsArray[i]);
- } else if (method === 'https' || method === 'HTTPS') {
- this.hosts.push('https://' + hostsArray[i]);
- } else {
- this.hosts.push('http://' + hostsArray[i]);
- }
- }
- if (Math.random() > 0.5) {
- this.hosts.reverse();
- }
-
- // resolve DNS + check CORS support (JSONP fallback)
- this.jsonp = null;
- this.jsonpWait = 0;
- this._jsonRequest({
- method: 'GET',
- url: '/1/isalive',
- callback: function(success, content) {
- self.jsonp = !success;
- }
- });
- this.extraHeaders = [];
-};
-
-function AlgoliaExplainResults(hit, titleAttribute, otherAttributes) {
-
- function _getHitExplanationForOneAttr_recurse(obj, foundWords) {
- var res = [];
- if (typeof obj === 'object' && 'matchedWords' in obj && 'value' in obj) {
- var match = false;
- for (var j = 0; j < obj.matchedWords.length; ++j) {
- var word = obj.matchedWords[j];
- if (!(word in foundWords)) {
- foundWords[word] = 1;
- match = true;
- }
- }
- if (match) {
- res.push(obj.value);
- }
- } else if (Object.prototype.toString.call(obj) === '[object Array]') {
- for (var i = 0; i < obj.length; ++i) {
- var array = _getHitExplanationForOneAttr_recurse(obj[i], foundWords);
- res = res.concat(array);
- }
- } else if (typeof obj === 'object') {
- for (var prop in obj) {
- if (obj.hasOwnProperty(prop)){
- res = res.concat(_getHitExplanationForOneAttr_recurse(obj[prop], foundWords));
- }
- }
- }
- return res;
- }
-
- function _getHitExplanationForOneAttr(hit, foundWords, attr) {
- var base = hit._highlightResult || hit;
- if (attr.indexOf('.') === -1) {
- if (attr in base) {
- return _getHitExplanationForOneAttr_recurse(base[attr], foundWords);
- }
- return [];
- }
- var array = attr.split('.');
- var obj = base;
- for (var i = 0; i < array.length; ++i) {
- if (Object.prototype.toString.call(obj) === '[object Array]') {
- var res = [];
- for (var j = 0; j < obj.length; ++j) {
- res = res.concat(_getHitExplanationForOneAttr(obj[j], foundWords, array.slice(i).join('.')));
- }
- return res;
- }
- if (array[i] in obj) {
- obj = obj[array[i]];
- } else {
- return [];
- }
- }
- return _getHitExplanationForOneAttr_recurse(obj, foundWords);
- }
-
- var res = {};
- var foundWords = {};
- var title = _getHitExplanationForOneAttr(hit, foundWords, titleAttribute);
- res.title = (title.length > 0) ? title[0] : '';
- res.subtitles = [];
-
- if (typeof otherAttributes !== 'undefined') {
- for (var i = 0; i < otherAttributes.length; ++i) {
- var attr = _getHitExplanationForOneAttr(hit, foundWords, otherAttributes[i]);
- for (var j = 0; j < attr.length; ++j) {
- res.subtitles.push({ attr: otherAttributes[i], value: attr[j] });
- }
- }
- }
- return res;
-}
-
-
-AlgoliaSearch.prototype = {
- /*
- * Delete an index
- *
- * @param indexName the name of index to delete
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains the task ID
- */
- deleteIndex: function(indexName, callback) {
- this._jsonRequest({ method: 'DELETE',
- url: '/1/indexes/' + encodeURIComponent(indexName),
- callback: callback });
- },
- /**
- * Move an existing index.
- * @param srcIndexName the name of index to copy.
- * @param dstIndexName the new index name that will contains a copy of srcIndexName (destination will be overriten if it already exist).
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains the task ID
- */
- moveIndex: function(srcIndexName, dstIndexName, callback) {
- var postObj = {operation: 'move', destination: dstIndexName};
- this._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(srcIndexName) + '/operation',
- body: postObj,
- callback: callback });
-
- },
- /**
- * Copy an existing index.
- * @param srcIndexName the name of index to copy.
- * @param dstIndexName the new index name that will contains a copy of srcIndexName (destination will be overriten if it already exist).
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains the task ID
- */
- copyIndex: function(srcIndexName, dstIndexName, callback) {
- var postObj = {operation: 'copy', destination: dstIndexName};
- this._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(srcIndexName) + '/operation',
- body: postObj,
- callback: callback });
- },
- /**
- * Return last log entries.
- * @param offset Specify the first entry to retrieve (0-based, 0 is the most recent log entry).
- * @param length Specify the maximum number of entries to retrieve starting at offset. Maximum allowed value: 1000.
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains the task ID
- */
- getLogs: function(callback, offset, length) {
- if (this._isUndefined(offset)) {
- offset = 0;
- }
- if (this._isUndefined(length)) {
- length = 10;
- }
-
- this._jsonRequest({ method: 'GET',
- url: '/1/logs?offset=' + offset + '&length=' + length,
- callback: callback });
- },
- /*
- * List all existing indexes
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with index list or error description if success is false.
- */
- listIndexes: function(callback) {
- this._jsonRequest({ method: 'GET',
- url: '/1/indexes',
- callback: callback });
- },
-
- /*
- * Get the index object initialized
- *
- * @param indexName the name of index
- * @param callback the result callback with one argument (the Index instance)
- */
- initIndex: function(indexName) {
- return new this.Index(this, indexName);
- },
- /*
- * List all existing user keys with their associated ACLs
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- listUserKeys: function(callback) {
- this._jsonRequest({ method: 'GET',
- url: '/1/keys',
- callback: callback });
- },
- /*
- * Get ACL of a user key
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- getUserKeyACL: function(key, callback) {
- this._jsonRequest({ method: 'GET',
- url: '/1/keys/' + key,
- callback: callback });
- },
- /*
- * Delete an existing user key
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- deleteUserKey: function(key, callback) {
- this._jsonRequest({ method: 'DELETE',
- url: '/1/keys/' + key,
- callback: callback });
- },
- /*
- * Add an existing user key
- *
- * @param acls the list of ACL for this key. Defined by an array of strings that
- * can contains the following values:
- * - search: allow to search (https and http)
- * - addObject: allows to add/update an object in the index (https only)
- * - deleteObject : allows to delete an existing object (https only)
- * - deleteIndex : allows to delete index content (https only)
- * - settings : allows to get index settings (https only)
- * - editSettings : allows to change index settings (https only)
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- addUserKey: function(acls, callback) {
- var aclsObject = {};
- aclsObject.acl = acls;
- this._jsonRequest({ method: 'POST',
- url: '/1/keys',
- body: aclsObject,
- callback: callback });
- },
- /*
- * Add an existing user key
- *
- * @param acls the list of ACL for this key. Defined by an array of strings that
- * can contains the following values:
- * - search: allow to search (https and http)
- * - addObject: allows to add/update an object in the index (https only)
- * - deleteObject : allows to delete an existing object (https only)
- * - deleteIndex : allows to delete index content (https only)
- * - settings : allows to get index settings (https only)
- * - editSettings : allows to change index settings (https only)
- * @param validity the number of seconds after which the key will be automatically removed (0 means no time limit for this key)
- * @param maxQueriesPerIPPerHour Specify the maximum number of API calls allowed from an IP address per hour.
- * @param maxHitsPerQuery Specify the maximum number of hits this API key can retrieve in one call.
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- addUserKeyWithValidity: function(acls, validity, maxQueriesPerIPPerHour, maxHitsPerQuery, callback) {
- var indexObj = this;
- var aclsObject = {};
- aclsObject.acl = acls;
- aclsObject.validity = validity;
- aclsObject.maxQueriesPerIPPerHour = maxQueriesPerIPPerHour;
- aclsObject.maxHitsPerQuery = maxHitsPerQuery;
- this._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + indexObj.indexName + '/keys',
- body: aclsObject,
- callback: callback });
- },
-
- /**
- * Set the extra security tagFilters header
- * @param {string|array} tags The list of tags defining the current security filters
- */
- setSecurityTags: function(tags) {
- if (Object.prototype.toString.call(tags) === '[object Array]') {
- var strTags = [];
- for (var i = 0; i < tags.length; ++i) {
- if (Object.prototype.toString.call(tags[i]) === '[object Array]') {
- var oredTags = [];
- for (var j = 0; j < tags[i].length; ++j) {
- oredTags.push(tags[i][j]);
- }
- strTags.push('(' + oredTags.join(',') + ')');
- } else {
- strTags.push(tags[i]);
- }
- }
- tags = strTags.join(',');
- }
- this.tagFilters = tags;
- },
-
- /**
- * Set the extra user token header
- * @param {string} userToken The token identifying a uniq user (used to apply rate limits)
- */
- setUserToken: function(userToken) {
- this.userToken = userToken;
- },
-
- /*
- * Initialize a new batch of search queries
- */
- startQueriesBatch: function() {
- this.batch = [];
- },
- /*
- * Add a search query in the batch
- *
- * @param query the full text query
- * @param args (optional) if set, contains an object with query parameters:
- * - attributes: an array of object attribute names to retrieve
- * (if not set all attributes are retrieve)
- * - attributesToHighlight: an array of object attribute names to highlight
- * (if not set indexed attributes are highlighted)
- * - minWordSizefor1Typo: the minimum number of characters to accept one typo.
- * Defaults to 3.
- * - minWordSizefor2Typos: the minimum number of characters to accept two typos.
- * Defaults to 7.
- * - getRankingInfo: if set, the result hits will contain ranking information in
- * _rankingInfo attribute
- * - page: (pagination parameter) page to retrieve (zero base). Defaults to 0.
- * - hitsPerPage: (pagination parameter) number of hits per page. Defaults to 10.
- */
- addQueryInBatch: function(indexName, query, args) {
- var params = 'query=' + encodeURIComponent(query);
- if (!this._isUndefined(args) && args != null) {
- params = this._getSearchParams(args, params);
- }
- this.batch.push({ indexName: indexName, params: params });
- },
- /*
- * Clear all queries in cache
- */
- clearCache: function() {
- this.cache = {};
- },
- /*
- * Launch the batch of queries using XMLHttpRequest.
- * (Optimized for browser using a POST query to minimize number of OPTIONS queries)
- *
- * @param callback the function that will receive results
- * @param delay (optional) if set, wait for this delay (in ms) and only send the batch if there was no other in the meantime.
- */
- sendQueriesBatch: function(callback, delay) {
- var as = this;
- var params = {requests: [], apiKey: this.apiKey, appID: this.applicationID};
- if (this.userToken) {
- params['X-Algolia-UserToken'] = this.userToken;
- }
- if (this.tagFilters) {
- params['X-Algolia-TagFilters'] = this.tagFilters;
- }
- for (var i = 0; i < as.batch.length; ++i) {
- params.requests.push(as.batch[i]);
- }
- window.clearTimeout(as.onDelayTrigger);
- if (!this._isUndefined(delay) && delay != null && delay > 0) {
- var onDelayTrigger = window.setTimeout( function() {
- as._sendQueriesBatch(params, callback);
- }, delay);
- as.onDelayTrigger = onDelayTrigger;
- } else {
- this._sendQueriesBatch(params, callback);
- }
- },
- /*
- * Index class constructor.
- * You should not use this method directly but use initIndex() function
- */
- Index: function(algoliasearch, indexName) {
- this.indexName = indexName;
- this.as = algoliasearch;
- this.typeAheadArgs = null;
- this.typeAheadValueOption = null;
- },
-
- setExtraHeader: function(key, value) {
- this.extraHeaders.push({ key: key, value: value});
- },
-
- _sendQueriesBatch: function(params, callback) {
- if (this.jsonp == null) {
- var self = this;
- this._waitReady(function() { self._sendQueriesBatch(params, callback); });
- return;
- }
- if (this.jsonp) {
- var jsonpParams = '';
- for (var i = 0; i < params.requests.length; ++i) {
- var q = '/1/indexes/' + encodeURIComponent(params.requests[i].indexName) + '?' + params.requests[i].params;
- jsonpParams += i + '=' + encodeURIComponent(q) + '&';
- }
- this._jsonRequest({ cache: this.cache,
- method: 'GET', jsonp: true,
- url: '/1/indexes/*',
- body: { params: jsonpParams },
- callback: callback });
- } else {
- this._jsonRequest({ cache: this.cache,
- method: 'POST',
- url: '/1/indexes/*/queries',
- body: params,
- callback: callback });
- }
- },
- /*
- * Wrapper that try all hosts to maximize the quality of service
- */
- _jsonRequest: function(opts) {
- var self = this;
- var callback = opts.callback;
- var cache = null;
- var cacheID = opts.url;
- if (!this._isUndefined(opts.body)) {
- cacheID = opts.url + '_body_' + JSON.stringify(opts.body);
- }
- if (!this._isUndefined(opts.cache)) {
- cache = opts.cache;
- if (!this._isUndefined(cache[cacheID])) {
- if (!this._isUndefined(callback)) {
- setTimeout(function () { callback(true, cache[cacheID]); }, 1);
- }
- return;
- }
- }
-
- var impl = function(position) {
- var idx = 0;
- if (!self._isUndefined(position)) {
- idx = position;
- }
- if (self.hosts.length <= idx) {
- if (!self._isUndefined(callback)) {
- callback(false, { message: 'Cannot contact server'});
- }
- return;
- }
- opts.callback = function(retry, success, res, body) {
- if (!success && !self._isUndefined(body)) {
- if (window.console) { console.log('Error: ' + body.message); }
- }
- if (success && !self._isUndefined(opts.cache)) {
- cache[cacheID] = body;
- }
- if (!success && retry && (idx + 1) < self.hosts.length) {
- impl(idx + 1);
- } else {
- if (!self._isUndefined(callback)) {
- callback(success, body);
- }
- }
- };
- opts.hostname = self.hosts[idx];
- self._jsonRequestByHost(opts);
- };
- impl();
- },
-
- _jsonRequestByHost: function(opts) {
- var self = this;
- var url = opts.hostname + opts.url;
-
- if (this.jsonp) {
- if (!opts.jsonp) {
- opts.callback(true, false, null, { 'message': 'Method ' + opts.method + ' ' + url + ' is not supported by JSONP.' });
- return;
- }
- this.jsonpCounter = this.jsonpCounter || 0;
- this.jsonpCounter += 1;
- var cb = 'algoliaJSONP_' + this.jsonpCounter;
- window[cb] = function(data) {
- opts.callback(false, true, null, data);
- try { delete window[cb]; } catch (e) { window[cb] = undefined; }
- };
- var script = document.createElement('script');
- script.type = 'text/javascript';
- script.src = url + '?callback=' + cb + ',' + this.applicationID + ',' + this.apiKey;
- if (opts['X-Algolia-TagFilters']) {
- script.src += '&X-Algolia-TagFilters=' + opts['X-Algolia-TagFilters'];
- }
- if (opts['X-Algolia-UserToken']) {
- script.src += '&X-Algolia-UserToken=' + opts['X-Algolia-UserToken'];
- }
- if (opts.body && opts.body.params) {
- script.src += '&' + opts.body.params;
- }
- var head = document.getElementsByTagName('head')[0];
- script.onerror = function() {
- opts.callback(true, false, null, { 'message': 'Failed to load JSONP script.' });
- head.removeChild(script);
- try { delete window[cb]; } catch (e) { window[cb] = undefined; }
- };
- var done = false;
- script.onload = script.onreadystatechange = function() {
- if (!done && (!this.readyState || this.readyState == 'loaded' || this.readyState == 'complete')) {
- done = true;
- if (typeof window[cb + '_loaded'] === 'undefined') {
- opts.callback(true, false, null, { 'message': 'Failed to load JSONP script.' });
- try { delete window[cb]; } catch (e) { window[cb] = undefined; }
- } else {
- try { delete window[cb + '_loaded']; } catch (e) { window[cb + '_loaded'] = undefined; }
- }
- script.onload = script.onreadystatechange = null; // Handle memory leak in IE
- head.removeChild(script);
- }
- };
- head.appendChild(script);
- } else {
- var body = null;
- if (!this._isUndefined(opts.body)) {
- body = JSON.stringify(opts.body);
- }
- var xmlHttp = window.XMLHttpRequest ? new XMLHttpRequest() : {};
- if ('withCredentials' in xmlHttp) {
- xmlHttp.open(opts.method, url , true);
- xmlHttp.setRequestHeader('X-Algolia-API-Key', this.apiKey);
- xmlHttp.setRequestHeader('X-Algolia-Application-Id', this.applicationID);
- for (var i = 0; i < this.extraHeaders.length; ++i) {
- xmlHttp.setRequestHeader(this.extraHeaders[i].key, this.extraHeaders[i].value);
- }
- if (body != null) {
- xmlHttp.setRequestHeader('Content-type', 'application/json');
- }
- } else if (typeof XDomainRequest != 'undefined') {
- // Handle IE8/IE9
- // XDomainRequest only exists in IE, and is IE's way of making CORS requests.
- xmlHttp = new XDomainRequest();
- xmlHttp.open(opts.method, url);
- } else {
- // very old browser, not supported
- if (window.console) { console.log('Your browser is too old to support CORS requests'); }
- opts.callback(false, false, null, { 'message': 'CORS not supported' });
- return;
- }
- xmlHttp.send(body);
- xmlHttp.onload = function(event) {
- if (!self._isUndefined(event) && event.target != null) {
- var retry = (event.target.status === 0 || event.target.status === 503);
- var success = (event.target.status === 200 || event.target.status === 201);
- opts.callback(retry, success, event.target, event.target.response != null ? JSON.parse(event.target.response) : null);
- } else {
- opts.callback(false, true, event, JSON.parse(xmlHttp.responseText));
- }
- };
- xmlHttp.onerror = function(event) {
- opts.callback(true, false, null, { 'message': 'Could not connect to host', 'error': event } );
- };
- }
- },
-
- /**
- * Wait until JSONP flag has been set to perform the first query
- */
- _waitReady: function(cb) {
- if (this.jsonp == null) {
- this.jsonpWait += 100;
- if (this.jsonpWait > 2000) {
- this.jsonp = true;
- }
- setTimeout(cb, 100);
- }
- },
-
- /*
- * Transform search param object in query string
- */
- _getSearchParams: function(args, params) {
- if (this._isUndefined(args) || args == null) {
- return params;
- }
- for (var key in args) {
- if (key != null && args.hasOwnProperty(key)) {
- params += (params.length === 0) ? '?' : '&';
- params += key + '=' + encodeURIComponent(Object.prototype.toString.call(args[key]) === '[object Array]' ? JSON.stringify(args[key]) : args[key]);
- }
- }
- return params;
- },
- _isUndefined: function(obj) {
- return obj === void 0;
- },
-
- /// internal attributes
- applicationID: null,
- apiKey: null,
- tagFilters: null,
- userToken: null,
- hosts: [],
- cache: {},
- extraHeaders: []
-};
-
-/*
- * Contains all the functions related to one index
- * You should use AlgoliaSearch.initIndex(indexName) to retrieve this object
- */
-AlgoliaSearch.prototype.Index.prototype = {
- /*
- * Clear all queries in cache
- */
- clearCache: function() {
- this.cache = {};
- },
- /*
- * Add an object in this index
- *
- * @param content contains the javascript object to add inside the index
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains 3 elements: createAt, taskId and objectID
- * @param objectID (optional) an objectID you want to attribute to this object
- * (if the attribute already exist the old object will be overwrite)
- */
- addObject: function(content, callback, objectID) {
- var indexObj = this;
- if (this.as._isUndefined(objectID)) {
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName),
- body: content,
- callback: callback });
- } else {
- this.as._jsonRequest({ method: 'PUT',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/' + encodeURIComponent(objectID),
- body: content,
- callback: callback });
- }
-
- },
- /*
- * Add several objects
- *
- * @param objects contains an array of objects to add
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that updateAt and taskID
- */
- addObjects: function(objects, callback) {
- var indexObj = this;
- var postObj = {requests:[]};
- for (var i = 0; i < objects.length; ++i) {
- var request = { action: 'addObject',
- body: objects[i] };
- postObj.requests.push(request);
- }
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/batch',
- body: postObj,
- callback: callback });
- },
- /*
- * Get an object from this index
- *
- * @param objectID the unique identifier of the object to retrieve
- * @param callback (optional) the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the object to retrieve or the error message if a failure occured
- * @param attributes (optional) if set, contains the array of attribute names to retrieve
- */
- getObject: function(objectID, callback, attributes) {
- if (this.as.jsonp == null) {
- var self = this;
- this.as._waitReady(function() { self.getObject(objectID, callback, attributes); });
- return;
- }
- var indexObj = this;
- var params = '';
- if (!this.as._isUndefined(attributes)) {
- params = '?attributes=';
- for (var i = 0; i < attributes.length; ++i) {
- if (i !== 0) {
- params += ',';
- }
- params += attributes[i];
- }
- }
- this.as._jsonRequest({ method: 'GET', jsonp: true,
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/' + encodeURIComponent(objectID) + params,
- callback: callback });
- },
-
- /*
- * Update partially an object (only update attributes passed in argument)
- *
- * @param partialObject contains the javascript attributes to override, the
- * object must contains an objectID attribute
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains 3 elements: createAt, taskId and objectID
- */
- partialUpdateObject: function(partialObject, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/' + encodeURIComponent(partialObject.objectID) + '/partial',
- body: partialObject,
- callback: callback });
- },
- /*
- * Partially Override the content of several objects
- *
- * @param objects contains an array of objects to update (each object must contains a objectID attribute)
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that updateAt and taskID
- */
- partialUpdateObjects: function(objects, callback) {
- var indexObj = this;
- var postObj = {requests:[]};
- for (var i = 0; i < objects.length; ++i) {
- var request = { action: 'partialUpdateObject',
- objectID: objects[i].objectID,
- body: objects[i] };
- postObj.requests.push(request);
- }
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/batch',
- body: postObj,
- callback: callback });
- },
- /*
- * Override the content of object
- *
- * @param object contains the javascript object to save, the object must contains an objectID attribute
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that updateAt and taskID
- */
- saveObject: function(object, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'PUT',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/' + encodeURIComponent(object.objectID),
- body: object,
- callback: callback });
- },
- /*
- * Override the content of several objects
- *
- * @param objects contains an array of objects to update (each object must contains a objectID attribute)
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that updateAt and taskID
- */
- saveObjects: function(objects, callback) {
- var indexObj = this;
- var postObj = {requests:[]};
- for (var i = 0; i < objects.length; ++i) {
- var request = { action: 'updateObject',
- objectID: objects[i].objectID,
- body: objects[i] };
- postObj.requests.push(request);
- }
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/batch',
- body: postObj,
- callback: callback });
- },
- /*
- * Delete an object from the index
- *
- * @param objectID the unique identifier of object to delete
- * @param callback (optional) the result callback with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains 3 elements: createAt, taskId and objectID
- */
- deleteObject: function(objectID, callback) {
- if (objectID == null || objectID.length === 0) {
- callback(false, { message: 'empty objectID'});
- return;
- }
- var indexObj = this;
- this.as._jsonRequest({ method: 'DELETE',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/' + encodeURIComponent(objectID),
- callback: callback });
- },
- /*
- * Search inside the index using XMLHttpRequest request (Using a POST query to
- * minimize number of OPTIONS queries: Cross-Origin Resource Sharing).
- *
- * @param query the full text query
- * @param callback the result callback with two arguments:
- * success: boolean set to true if the request was successfull. If false, the content contains the error.
- * content: the server answer that contains the list of results.
- * @param args (optional) if set, contains an object with query parameters:
- * - page: (integer) Pagination parameter used to select the page to retrieve.
- * Page is zero-based and defaults to 0. Thus, to retrieve the 10th page you need to set page=9
- * - hitsPerPage: (integer) Pagination parameter used to select the number of hits per page. Defaults to 20.
- * - attributesToRetrieve: a string that contains the list of object attributes you want to retrieve (let you minimize the answer size).
- * Attributes are separated with a comma (for example "name,address").
- * You can also use a string array encoding (for example ["name","address"]).
- * By default, all attributes are retrieved. You can also use '*' to retrieve all values when an attributesToRetrieve setting is specified for your index.
- * - attributesToHighlight: a string that contains the list of attributes you want to highlight according to the query.
- * Attributes are separated by a comma. You can also use a string array encoding (for example ["name","address"]).
- * If an attribute has no match for the query, the raw value is returned. By default all indexed text attributes are highlighted.
- * You can use `*` if you want to highlight all textual attributes. Numerical attributes are not highlighted.
- * A matchLevel is returned for each highlighted attribute and can contain:
- * - full: if all the query terms were found in the attribute,
- * - partial: if only some of the query terms were found,
- * - none: if none of the query terms were found.
- * - attributesToSnippet: a string that contains the list of attributes to snippet alongside the number of words to return (syntax is `attributeName:nbWords`).
- * Attributes are separated by a comma (Example: attributesToSnippet=name:10,content:10).
- * You can also use a string array encoding (Example: attributesToSnippet: ["name:10","content:10"]). By default no snippet is computed.
- * - minWordSizefor1Typo: the minimum number of characters in a query word to accept one typo in this word. Defaults to 3.
- * - minWordSizefor2Typos: the minimum number of characters in a query word to accept two typos in this word. Defaults to 7.
- * - getRankingInfo: if set to 1, the result hits will contain ranking information in _rankingInfo attribute.
- * - aroundLatLng: search for entries around a given latitude/longitude (specified as two floats separated by a comma).
- * For example aroundLatLng=47.316669,5.016670).
- * You can specify the maximum distance in meters with the aroundRadius parameter (in meters) and the precision for ranking with aroundPrecision
- * (for example if you set aroundPrecision=100, two objects that are distant of less than 100m will be considered as identical for "geo" ranking parameter).
- * At indexing, you should specify geoloc of an object with the _geoloc attribute (in the form {"_geoloc":{"lat":48.853409, "lng":2.348800}})
- * - insideBoundingBox: search entries inside a given area defined by the two extreme points of a rectangle (defined by 4 floats: p1Lat,p1Lng,p2Lat,p2Lng).
- * For example insideBoundingBox=47.3165,4.9665,47.3424,5.0201).
- * At indexing, you should specify geoloc of an object with the _geoloc attribute (in the form {"_geoloc":{"lat":48.853409, "lng":2.348800}})
- * - numericFilters: a string that contains the list of numeric filters you want to apply separated by a comma.
- * The syntax of one filter is `attributeName` followed by `operand` followed by `value`. Supported operands are `<`, `<=`, `=`, `>` and `>=`.
- * You can have multiple conditions on one attribute like for example numericFilters=price>100,price<1000.
- * You can also use a string array encoding (for example numericFilters: ["price>100","price<1000"]).
- * - tagFilters: filter the query by a set of tags. You can AND tags by separating them by commas.
- * To OR tags, you must add parentheses. For example, tags=tag1,(tag2,tag3) means tag1 AND (tag2 OR tag3).
- * You can also use a string array encoding, for example tagFilters: ["tag1",["tag2","tag3"]] means tag1 AND (tag2 OR tag3).
- * At indexing, tags should be added in the _tags** attribute of objects (for example {"_tags":["tag1","tag2"]}).
- * - facetFilters: filter the query by a list of facets.
- * Facets are separated by commas and each facet is encoded as `attributeName:value`.
- * For example: `facetFilters=category:Book,author:John%20Doe`.
- * You can also use a string array encoding (for example `["category:Book","author:John%20Doe"]`).
- * - facets: List of object attributes that you want to use for faceting.
- * Attributes are separated with a comma (for example `"category,author"` ).
- * You can also use a JSON string array encoding (for example ["category","author"]).
- * Only attributes that have been added in **attributesForFaceting** index setting can be used in this parameter.
- * You can also use `*` to perform faceting on all attributes specified in **attributesForFaceting**.
- * - queryType: select how the query words are interpreted, it can be one of the following value:
- * - prefixAll: all query words are interpreted as prefixes,
- * - prefixLast: only the last word is interpreted as a prefix (default behavior),
- * - prefixNone: no query word is interpreted as a prefix. This option is not recommended.
- * - optionalWords: a string that contains the list of words that should be considered as optional when found in the query.
- * The list of words is comma separated.
- * - distinct: If set to 1, enable the distinct feature (disabled by default) if the attributeForDistinct index setting is set.
- * This feature is similar to the SQL "distinct" keyword: when enabled in a query with the distinct=1 parameter,
- * all hits containing a duplicate value for the attributeForDistinct attribute are removed from results.
- * For example, if the chosen attribute is show_name and several hits have the same value for show_name, then only the best
- * one is kept and others are removed.
- * @param delay (optional) if set, wait for this delay (in ms) and only send the query if there was no other in the meantime.
- */
- search: function(query, callback, args, delay) {
- var indexObj = this;
- var params = 'query=' + encodeURIComponent(query);
- if (!this.as._isUndefined(args) && args != null) {
- params = this.as._getSearchParams(args, params);
- }
- window.clearTimeout(indexObj.onDelayTrigger);
- if (!this.as._isUndefined(delay) && delay != null && delay > 0) {
- var onDelayTrigger = window.setTimeout( function() {
- indexObj._search(params, callback);
- }, delay);
- indexObj.onDelayTrigger = onDelayTrigger;
- } else {
- this._search(params, callback);
- }
- },
-
- /*
- * Browse all index content
- *
- * @param page Pagination parameter used to select the page to retrieve.
- * Page is zero-based and defaults to 0. Thus, to retrieve the 10th page you need to set page=9
- * @param hitsPerPage: Pagination parameter used to select the number of hits per page. Defaults to 1000.
- */
- browse: function(page, callback, hitsPerPage) {
- var indexObj = this;
- var params = '?page=' + page;
- if (!_.isUndefined(hitsPerPage)) {
- params += '&hitsPerPage=' + hitsPerPage;
- }
- this.as._jsonRequest({ method: 'GET',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/browse' + params,
- callback: callback });
- },
-
- /*
- * Get a Typeahead.js adapter
- * @param searchParams contains an object with query parameters (see search for details)
- */
- ttAdapter: function(params) {
- var self = this;
- return function(query, cb) {
- self.search(query, function(success, content) {
- if (success) {
- cb(content.hits);
- }
- }, params);
- };
- },
-
- /*
- * Wait the publication of a task on the server.
- * All server task are asynchronous and you can check with this method that the task is published.
- *
- * @param taskID the id of the task returned by server
- * @param callback the result callback with with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the server answer that contains the list of results
- */
- waitTask: function(taskID, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'GET',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/task/' + taskID,
- callback: function(success, body) {
- if (success) {
- if (body.status === 'published') {
- callback(true, body);
- } else {
- setTimeout(function() { indexObj.waitTask(taskID, callback); }, 100);
- }
- } else {
- callback(false, body);
- }
- }});
- },
-
- /*
- * This function deletes the index content. Settings and index specific API keys are kept untouched.
- *
- * @param callback (optional) the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the settings object or the error message if a failure occured
- */
- clearIndex: function(callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/clear',
- callback: callback });
- },
- /*
- * Get settings of this index
- *
- * @param callback (optional) the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the settings object or the error message if a failure occured
- */
- getSettings: function(callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'GET',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/settings',
- callback: callback });
- },
-
- /*
- * Set settings for this index
- *
- * @param settigns the settings object that can contains :
- * - minWordSizefor1Typo: (integer) the minimum number of characters to accept one typo (default = 3).
- * - minWordSizefor2Typos: (integer) the minimum number of characters to accept two typos (default = 7).
- * - hitsPerPage: (integer) the number of hits per page (default = 10).
- * - attributesToRetrieve: (array of strings) default list of attributes to retrieve in objects.
- * If set to null, all attributes are retrieved.
- * - attributesToHighlight: (array of strings) default list of attributes to highlight.
- * If set to null, all indexed attributes are highlighted.
- * - attributesToSnippet**: (array of strings) default list of attributes to snippet alongside the number of words to return (syntax is attributeName:nbWords).
- * By default no snippet is computed. If set to null, no snippet is computed.
- * - attributesToIndex: (array of strings) the list of fields you want to index.
- * If set to null, all textual and numerical attributes of your objects are indexed, but you should update it to get optimal results.
- * This parameter has two important uses:
- * - Limit the attributes to index: For example if you store a binary image in base64, you want to store it and be able to
- * retrieve it but you don't want to search in the base64 string.
- * - Control part of the ranking*: (see the ranking parameter for full explanation) Matches in attributes at the beginning of
- * the list will be considered more important than matches in attributes further down the list.
- * In one attribute, matching text at the beginning of the attribute will be considered more important than text after, you can disable
- * this behavior if you add your attribute inside `unordered(AttributeName)`, for example attributesToIndex: ["title", "unordered(text)"].
- * - attributesForFaceting: (array of strings) The list of fields you want to use for faceting.
- * All strings in the attribute selected for faceting are extracted and added as a facet. If set to null, no attribute is used for faceting.
- * - attributeForDistinct: (string) The attribute name used for the Distinct feature. This feature is similar to the SQL "distinct" keyword: when enabled
- * in query with the distinct=1 parameter, all hits containing a duplicate value for this attribute are removed from results.
- * For example, if the chosen attribute is show_name and several hits have the same value for show_name, then only the best one is kept and others are removed.
- * - ranking: (array of strings) controls the way results are sorted.
- * We have six available criteria:
- * - typo: sort according to number of typos,
- * - geo: sort according to decreassing distance when performing a geo-location based search,
- * - proximity: sort according to the proximity of query words in hits,
- * - attribute: sort according to the order of attributes defined by attributesToIndex,
- * - exact:
- * - if the user query contains one word: sort objects having an attribute that is exactly the query word before others.
- * For example if you search for the "V" TV show, you want to find it with the "V" query and avoid to have all popular TV
- * show starting by the v letter before it.
- * - if the user query contains multiple words: sort according to the number of words that matched exactly (and not as a prefix).
- * - custom: sort according to a user defined formula set in **customRanking** attribute.
- * The standard order is ["typo", "geo", "proximity", "attribute", "exact", "custom"]
- * - customRanking: (array of strings) lets you specify part of the ranking.
- * The syntax of this condition is an array of strings containing attributes prefixed by asc (ascending order) or desc (descending order) operator.
- * For example `"customRanking" => ["desc(population)", "asc(name)"]`
- * - queryType: Select how the query words are interpreted, it can be one of the following value:
- * - prefixAll: all query words are interpreted as prefixes,
- * - prefixLast: only the last word is interpreted as a prefix (default behavior),
- * - prefixNone: no query word is interpreted as a prefix. This option is not recommended.
- * - highlightPreTag: (string) Specify the string that is inserted before the highlighted parts in the query result (default to "").
- * - highlightPostTag: (string) Specify the string that is inserted after the highlighted parts in the query result (default to "").
- * - optionalWords: (array of strings) Specify a list of words that should be considered as optional when found in the query.
- * @param callback (optional) the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer or the error message if a failure occured
- */
- setSettings: function(settings, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'PUT',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/settings',
- body: settings,
- callback: callback });
- },
- /*
- * List all existing user keys associated to this index
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- listUserKeys: function(callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'GET',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/keys',
- callback: callback });
- },
- /*
- * Get ACL of a user key associated to this index
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- getUserKeyACL: function(key, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'GET',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/keys/' + key,
- callback: callback });
- },
- /*
- * Delete an existing user key associated to this index
- *
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- deleteUserKey: function(key, callback) {
- var indexObj = this;
- this.as._jsonRequest({ method: 'DELETE',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/keys/' + key,
- callback: callback });
- },
- /*
- * Add an existing user key associated to this index
- *
- * @param acls the list of ACL for this key. Defined by an array of strings that
- * can contains the following values:
- * - search: allow to search (https and http)
- * - addObject: allows to add/update an object in the index (https only)
- * - deleteObject : allows to delete an existing object (https only)
- * - deleteIndex : allows to delete index content (https only)
- * - settings : allows to get index settings (https only)
- * - editSettings : allows to change index settings (https only)
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- addUserKey: function(acls, callback) {
- var indexObj = this;
- var aclsObject = {};
- aclsObject.acl = acls;
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/keys',
- body: aclsObject,
- callback: callback });
- },
- /*
- * Add an existing user key associated to this index
- *
- * @param acls the list of ACL for this key. Defined by an array of strings that
- * can contains the following values:
- * - search: allow to search (https and http)
- * - addObject: allows to add/update an object in the index (https only)
- * - deleteObject : allows to delete an existing object (https only)
- * - deleteIndex : allows to delete index content (https only)
- * - settings : allows to get index settings (https only)
- * - editSettings : allows to change index settings (https only)
- * @param validity the number of seconds after which the key will be automatically removed (0 means no time limit for this key)
- * @param maxQueriesPerIPPerHour Specify the maximum number of API calls allowed from an IP address per hour.
- * @param maxHitsPerQuery Specify the maximum number of hits this API key can retrieve in one call.
- * @param callback the result callback with two arguments
- * success: boolean set to true if the request was successfull
- * content: the server answer with user keys list or error description if success is false.
- */
- addUserKeyWithValidity: function(acls, validity, maxQueriesPerIPPerHour, maxHitsPerQuery, callback) {
- var indexObj = this;
- var aclsObject = {};
- aclsObject.acl = acls;
- aclsObject.validity = validity;
- aclsObject.maxQueriesPerIPPerHour = maxQueriesPerIPPerHour;
- aclsObject.maxHitsPerQuery = maxHitsPerQuery;
- this.as._jsonRequest({ method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(indexObj.indexName) + '/keys',
- body: aclsObject,
- callback: callback });
- },
- ///
- /// Internal methods only after this line
- ///
- _search: function(params, callback) {
- if (this.as.jsonp == null) {
- var self = this;
- this.as._waitReady(function() { self._search(params, callback); });
- return;
- }
- var pObj = {params: params, apiKey: this.as.apiKey, appID: this.as.applicationID};
- if (this.as.tagFilters) {
- pObj['X-Algolia-TagFilters'] = this.as.tagFilters;
- }
- if (this.as.userToken) {
- pObj['X-Algolia-UserToken'] = this.as.userToken;
- }
- if (this.as.jsonp) {
- this.as._jsonRequest({ cache: this.cache,
- method: 'GET', jsonp: true,
- url: '/1/indexes/' + encodeURIComponent(this.indexName),
- body: pObj,
- callback: callback });
- } else {
- this.as._jsonRequest({ cache: this.cache,
- method: 'POST',
- url: '/1/indexes/' + encodeURIComponent(this.indexName) + '/query',
- body: pObj,
- callback: callback });
- }
- },
-
- // internal attributes
- as: null,
- indexName: null,
- cache: {},
- typeAheadArgs: null,
- typeAheadValueOption: null,
- emptyConstructor: function() {}
-};
-
-/*
- * Copyright (c) 2014 Algolia
- * http://www.algolia.com/
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
-(function($) {
- var extend = function(out) {
- out = out || {};
- for (var i = 1; i < arguments.length; i++) {
- if (!arguments[i]) {
- continue;
- }
- for (var key in arguments[i]) {
- if (arguments[i].hasOwnProperty(key)) {
- out[key] = arguments[i][key];
- }
- }
- }
- return out;
- };
-
- /**
- * Algolia Search Helper providing faceting and disjunctive faceting
- * @param {AlgoliaSearch} client an AlgoliaSearch client
- * @param {string} index the index name to query
- * @param {hash} options an associative array defining the hitsPerPage, list of facets and list of disjunctive facets
- */
- window.AlgoliaSearchHelper = function(client, index, options) {
- /// Default options
- var defaults = {
- facets: [], // list of facets to compute
- disjunctiveFacets: [], // list of disjunctive facets to compute
- hitsPerPage: 20 // number of hits per page
- };
-
- this.init(client, index, extend({}, defaults, options));
- };
-
- AlgoliaSearchHelper.prototype = {
- /**
- * Initialize a new AlgoliaSearchHelper
- * @param {AlgoliaSearch} client an AlgoliaSearch client
- * @param {string} index the index name to query
- * @param {hash} options an associative array defining the hitsPerPage, list of facets and list of disjunctive facets
- * @return {AlgoliaSearchHelper}
- */
- init: function(client, index, options) {
- this.client = client;
- this.index = index;
- this.options = options;
- this.page = 0;
- this.refinements = {};
- this.disjunctiveRefinements = {};
- },
-
- /**
- * Perform a query
- * @param {string} q the user query
- * @param {function} searchCallback the result callback called with two arguments:
- * success: boolean set to true if the request was successfull
- * content: the query answer with an extra 'disjunctiveFacets' attribute
- */
- search: function(q, searchCallback, searchParams) {
- this.q = q;
- this.searchCallback = searchCallback;
- this.searchParams = searchParams || {};
- this.page = this.page || 0;
- this.refinements = this.refinements || {};
- this.disjunctiveRefinements = this.disjunctiveRefinements || {};
- this._search();
- },
-
- /**
- * Remove all refinements (disjunctive + conjunctive)
- */
- clearRefinements: function() {
- this.disjunctiveRefinements = {};
- this.refinements = {};
- },
-
- /**
- * Ensure a facet refinement exists
- * @param {string} facet the facet to refine
- * @param {string} value the associated value
- */
- addDisjunctiveRefine: function(facet, value) {
- this.disjunctiveRefinements = this.disjunctiveRefinements || {};
- this.disjunctiveRefinements[facet] = this.disjunctiveRefinements[facet] || {};
- this.disjunctiveRefinements[facet][value] = true;
- },
-
- /**
- * Ensure a facet refinement does not exist
- * @param {string} facet the facet to refine
- * @param {string} value the associated value
- */
- removeDisjunctiveRefine: function(facet, value) {
- this.disjunctiveRefinements = this.disjunctiveRefinements || {};
- this.disjunctiveRefinements[facet] = this.disjunctiveRefinements[facet] || {};
- try {
- delete this.disjunctiveRefinements[facet][value];
- } catch (e) {
- this.disjunctiveRefinements[facet][value] = undefined; // IE compat
- }
- },
-
- /**
- * Ensure a facet refinement exists
- * @param {string} facet the facet to refine
- * @param {string} value the associated value
- */
- addRefine: function(facet, value) {
- var refinement = facet + ':' + value;
- this.refinements = this.refinements || {};
- this.refinements[refinement] = true;
- },
-
- /**
- * Ensure a facet refinement does not exist
- * @param {string} facet the facet to refine
- * @param {string} value the associated value
- */
- removeRefine: function(facet, value) {
- var refinement = facet + ':' + value;
- this.refinements = this.refinements || {};
- this.refinements[refinement] = false;
- },
-
- /**
- * Toggle refinement state of a facet
- * @param {string} facet the facet to refine
- * @param {string} value the associated value
- * @return {boolean} true if the facet has been found
- */
- toggleRefine: function(facet, value) {
- for (var i = 0; i < this.options.facets.length; ++i) {
- if (this.options.facets[i] == facet) {
- var refinement = facet + ':' + value;
- this.refinements[refinement] = !this.refinements[refinement];
- this.page = 0;
- this._search();
- return true;
- }
- }
- this.disjunctiveRefinements[facet] = this.disjunctiveRefinements[facet] || {};
- for (var j = 0; j < this.options.disjunctiveFacets.length; ++j) {
- if (this.options.disjunctiveFacets[j] == facet) {
- this.disjunctiveRefinements[facet][value] = !this.disjunctiveRefinements[facet][value];
- this.page = 0;
- this._search();
- return true;
- }
- }
- return false;
- },
-
- /**
- * Check the refinement state of a facet
- * @param {string} facet the facet
- * @param {string} value the associated value
- * @return {boolean} true if refined
- */
- isRefined: function(facet, value) {
- var refinement = facet + ':' + value;
- if (this.refinements[refinement]) {
- return true;
- }
- if (this.disjunctiveRefinements[facet] && this.disjunctiveRefinements[facet][value]) {
- return true;
- }
- return false;
- },
-
- /**
- * Go to next page
- */
- nextPage: function() {
- this._gotoPage(this.page + 1);
- },
-
- /**
- * Go to previous page
- */
- previousPage: function() {
- if (this.page > 0) {
- this._gotoPage(this.page - 1);
- }
- },
-
- /**
- * Goto a page
- * @param {integer} page The page number
- */
- gotoPage: function(page) {
- this._gotoPage(page);
- },
-
- /**
- * Configure the page but do not trigger a reload
- * @param {integer} page The page number
- */
- setPage: function(page) {
- this.page = page;
- },
-
- ///////////// PRIVATE
-
- /**
- * Goto a page
- * @param {integer} page The page number
- */
- _gotoPage: function(page) {
- this.page = page;
- this._search();
- },
-
- /**
- * Perform the underlying queries
- */
- _search: function() {
- this.client.startQueriesBatch();
- this.client.addQueryInBatch(this.index, this.q, this._getHitsSearchParams());
- for (var i = 0; i < this.options.disjunctiveFacets.length; ++i) {
- this.client.addQueryInBatch(this.index, this.q, this._getDisjunctiveFacetSearchParams(this.options.disjunctiveFacets[i]));
- }
- var self = this;
- this.client.sendQueriesBatch(function(success, content) {
- if (!success) {
- self.searchCallback(false, content);
- return;
- }
- var aggregatedAnswer = content.results[0];
- aggregatedAnswer.disjunctiveFacets = {};
- aggregatedAnswer.facetStats = {};
- for (var i = 1; i < content.results.length; ++i) {
- for (var facet in content.results[i].facets) {
- aggregatedAnswer.disjunctiveFacets[facet] = content.results[i].facets[facet];
- if (self.disjunctiveRefinements[facet]) {
- for (var value in self.disjunctiveRefinements[facet]) {
- if (!aggregatedAnswer.disjunctiveFacets[facet][value] && self.disjunctiveRefinements[facet][value]) {
- aggregatedAnswer.disjunctiveFacets[facet][value] = 0;
- }
- }
- }
- }
- for (var stats in content.results[i].facets_stats)
- {
- aggregatedAnswer.facetStats[stats] = content.results[i].facets_stats[stats];
- }
- }
- self.searchCallback(true, aggregatedAnswer);
- });
- },
-
- /**
- * Build search parameters used to fetch hits
- * @return {hash}
- */
- _getHitsSearchParams: function() {
- return extend({}, {
- hitsPerPage: this.options.hitsPerPage,
- page: this.page,
- facets: this.options.facets,
- facetFilters: this._getFacetFilters()
- }, this.searchParams);
- },
-
- /**
- * Build search parameters used to fetch a disjunctive facet
- * @param {string} facet the associated facet name
- * @return {hash}
- */
- _getDisjunctiveFacetSearchParams: function(facet) {
- return extend({}, this.searchParams, {
- hitsPerPage: 1,
- page: 0,
- facets: facet,
- facetFilters: this._getFacetFilters(facet)
- });
- },
-
- /**
- * Build facetFilters parameter based on current refinements
- * @param {string} facet if set, the current disjunctive facet
- * @return {hash}
- */
- _getFacetFilters: function(facet) {
- var facetFilters = [];
- for (var refinement in this.refinements) {
- if (this.refinements[refinement]) {
- facetFilters.push(refinement);
- }
- }
- for (var disjunctiveRefinement in this.disjunctiveRefinements) {
- if (disjunctiveRefinement != facet) {
- var refinements = [];
- for (var value in this.disjunctiveRefinements[disjunctiveRefinement]) {
- if (this.disjunctiveRefinements[disjunctiveRefinement][value]) {
- refinements.push(disjunctiveRefinement + ':' + value);
- }
- }
- if (refinements.length > 0) {
- facetFilters.push(refinements);
- }
- }
- }
- return facetFilters;
- }
- };
-})();
-
-/*
- json2.js
- 2014-02-04
-
- Public Domain.
-
- NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
-
- See http://www.JSON.org/js.html
-
-
- This code should be minified before deployment.
- See http://javascript.crockford.com/jsmin.html
-
- USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
- NOT CONTROL.
-
-
- This file creates a global JSON object containing two methods: stringify
- and parse.
-
- JSON.stringify(value, replacer, space)
- value any JavaScript value, usually an object or array.
-
- replacer an optional parameter that determines how object
- values are stringified for objects. It can be a
- function or an array of strings.
-
- space an optional parameter that specifies the indentation
- of nested structures. If it is omitted, the text will
- be packed without extra whitespace. If it is a number,
- it will specify the number of spaces to indent at each
- level. If it is a string (such as '\t' or ' '),
- it contains the characters used to indent at each level.
-
- This method produces a JSON text from a JavaScript value.
-
- When an object value is found, if the object contains a toJSON
- method, its toJSON method will be called and the result will be
- stringified. A toJSON method does not serialize: it returns the
- value represented by the name/value pair that should be serialized,
- or undefined if nothing should be serialized. The toJSON method
- will be passed the key associated with the value, and this will be
- bound to the value
-
- For example, this would serialize Dates as ISO strings.
-
- Date.prototype.toJSON = function (key) {
- function f(n) {
- // Format integers to have at least two digits.
- return n < 10 ? '0' + n : n;
- }
-
- return this.getUTCFullYear() + '-' +
- f(this.getUTCMonth() + 1) + '-' +
- f(this.getUTCDate()) + 'T' +
- f(this.getUTCHours()) + ':' +
- f(this.getUTCMinutes()) + ':' +
- f(this.getUTCSeconds()) + 'Z';
- };
-
- You can provide an optional replacer method. It will be passed the
- key and value of each member, with this bound to the containing
- object. The value that is returned from your method will be
- serialized. If your method returns undefined, then the member will
- be excluded from the serialization.
-
- If the replacer parameter is an array of strings, then it will be
- used to select the members to be serialized. It filters the results
- such that only members with keys listed in the replacer array are
- stringified.
-
- Values that do not have JSON representations, such as undefined or
- functions, will not be serialized. Such values in objects will be
- dropped; in arrays they will be replaced with null. You can use
- a replacer function to replace those with JSON values.
- JSON.stringify(undefined) returns undefined.
-
- The optional space parameter produces a stringification of the
- value that is filled with line breaks and indentation to make it
- easier to read.
-
- If the space parameter is a non-empty string, then that string will
- be used for indentation. If the space parameter is a number, then
- the indentation will be that many spaces.
-
- Example:
-
- text = JSON.stringify(['e', {pluribus: 'unum'}]);
- // text is '["e",{"pluribus":"unum"}]'
-
-
- text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t');
- // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]'
-
- text = JSON.stringify([new Date()], function (key, value) {
- return this[key] instanceof Date ?
- 'Date(' + this[key] + ')' : value;
- });
- // text is '["Date(---current time---)"]'
-
-
- JSON.parse(text, reviver)
- This method parses a JSON text to produce an object or array.
- It can throw a SyntaxError exception.
-
- The optional reviver parameter is a function that can filter and
- transform the results. It receives each of the keys and values,
- and its return value is used instead of the original value.
- If it returns what it received, then the structure is not modified.
- If it returns undefined then the member is deleted.
-
- Example:
-
- // Parse the text. Values that look like ISO date strings will
- // be converted to Date objects.
-
- myData = JSON.parse(text, function (key, value) {
- var a;
- if (typeof value === 'string') {
- a =
-/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
- if (a) {
- return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4],
- +a[5], +a[6]));
- }
- }
- return value;
- });
-
- myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) {
- var d;
- if (typeof value === 'string' &&
- value.slice(0, 5) === 'Date(' &&
- value.slice(-1) === ')') {
- d = new Date(value.slice(5, -1));
- if (d) {
- return d;
- }
- }
- return value;
- });
-
-
- This is a reference implementation. You are free to copy, modify, or
- redistribute.
-*/
-
-/*jslint evil: true, regexp: true */
-
-/*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply,
- call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours,
- getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join,
- lastIndex, length, parse, prototype, push, replace, slice, stringify,
- test, toJSON, toString, valueOf
-*/
-
-
-// Create a JSON object only if one does not already exist. We create the
-// methods in a closure to avoid creating global variables.
-
-if (typeof JSON !== 'object') {
- JSON = {};
-}
-
-(function () {
- 'use strict';
-
- function f(n) {
- // Format integers to have at least two digits.
- return n < 10 ? '0' + n : n;
- }
-
- if (typeof Date.prototype.toJSON !== 'function') {
-
- Date.prototype.toJSON = function () {
-
- return isFinite(this.valueOf())
- ? this.getUTCFullYear() + '-' +
- f(this.getUTCMonth() + 1) + '-' +
- f(this.getUTCDate()) + 'T' +
- f(this.getUTCHours()) + ':' +
- f(this.getUTCMinutes()) + ':' +
- f(this.getUTCSeconds()) + 'Z'
- : null;
- };
-
- String.prototype.toJSON =
- Number.prototype.toJSON =
- Boolean.prototype.toJSON = function () {
- return this.valueOf();
- };
- }
-
- var cx,
- escapable,
- gap,
- indent,
- meta,
- rep;
-
-
- function quote(string) {
-
-// If the string contains no control characters, no quote characters, and no
-// backslash characters, then we can safely slap some quotes around it.
-// Otherwise we must also replace the offending characters with safe escape
-// sequences.
-
- escapable.lastIndex = 0;
- return escapable.test(string) ? '"' + string.replace(escapable, function (a) {
- var c = meta[a];
- return typeof c === 'string'
- ? c
- : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
- }) + '"' : '"' + string + '"';
- }
-
-
- function str(key, holder) {
-
-// Produce a string from holder[key].
-
- var i, // The loop counter.
- k, // The member key.
- v, // The member value.
- length,
- mind = gap,
- partial,
- value = holder[key];
-
-// If the value has a toJSON method, call it to obtain a replacement value.
-
- if (value && typeof value === 'object' &&
- typeof value.toJSON === 'function') {
- value = value.toJSON(key);
- }
-
-// If we were called with a replacer function, then call the replacer to
-// obtain a replacement value.
-
- if (typeof rep === 'function') {
- value = rep.call(holder, key, value);
- }
-
-// What happens next depends on the value's type.
-
- switch (typeof value) {
- case 'string':
- return quote(value);
-
- case 'number':
-
-// JSON numbers must be finite. Encode non-finite numbers as null.
-
- return isFinite(value) ? String(value) : 'null';
-
- case 'boolean':
- case 'null':
-
-// If the value is a boolean or null, convert it to a string. Note:
-// typeof null does not produce 'null'. The case is included here in
-// the remote chance that this gets fixed someday.
-
- return String(value);
-
-// If the type is 'object', we might be dealing with an object or an array or
-// null.
-
- case 'object':
-
-// Due to a specification blunder in ECMAScript, typeof null is 'object',
-// so watch out for that case.
-
- if (!value) {
- return 'null';
- }
-
-// Make an array to hold the partial results of stringifying this object value.
-
- gap += indent;
- partial = [];
-
-// Is the value an array?
-
- if (Object.prototype.toString.apply(value) === '[object Array]') {
-
-// The value is an array. Stringify every element. Use null as a placeholder
-// for non-JSON values.
-
- length = value.length;
- for (i = 0; i < length; i += 1) {
- partial[i] = str(i, value) || 'null';
- }
-
-// Join all of the elements together, separated with commas, and wrap them in
-// brackets.
-
- v = partial.length === 0
- ? '[]'
- : gap
- ? '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']'
- : '[' + partial.join(',') + ']';
- gap = mind;
- return v;
- }
-
-// If the replacer is an array, use it to select the members to be stringified.
-
- if (rep && typeof rep === 'object') {
- length = rep.length;
- for (i = 0; i < length; i += 1) {
- if (typeof rep[i] === 'string') {
- k = rep[i];
- v = str(k, value);
- if (v) {
- partial.push(quote(k) + (gap ? ': ' : ':') + v);
- }
- }
- }
- } else {
-
-// Otherwise, iterate through all of the keys in the object.
-
- for (k in value) {
- if (Object.prototype.hasOwnProperty.call(value, k)) {
- v = str(k, value);
- if (v) {
- partial.push(quote(k) + (gap ? ': ' : ':') + v);
- }
- }
- }
- }
-
-// Join all of the member texts together, separated with commas,
-// and wrap them in braces.
-
- v = partial.length === 0
- ? '{}'
- : gap
- ? '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}'
- : '{' + partial.join(',') + '}';
- gap = mind;
- return v;
- }
- }
-
-// If the JSON object does not yet have a stringify method, give it one.
-
- if (typeof JSON.stringify !== 'function') {
- escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;
- meta = { // table of character substitutions
- '\b': '\\b',
- '\t': '\\t',
- '\n': '\\n',
- '\f': '\\f',
- '\r': '\\r',
- '"' : '\\"',
- '\\': '\\\\'
- };
- JSON.stringify = function (value, replacer, space) {
-
-// The stringify method takes a value and an optional replacer, and an optional
-// space parameter, and returns a JSON text. The replacer can be a function
-// that can replace values, or an array of strings that will select the keys.
-// A default replacer method can be provided. Use of the space parameter can
-// produce text that is more easily readable.
-
- var i;
- gap = '';
- indent = '';
-
-// If the space parameter is a number, make an indent string containing that
-// many spaces.
-
- if (typeof space === 'number') {
- for (i = 0; i < space; i += 1) {
- indent += ' ';
- }
-
-// If the space parameter is a string, it will be used as the indent string.
-
- } else if (typeof space === 'string') {
- indent = space;
- }
-
-// If there is a replacer, it must be a function or an array.
-// Otherwise, throw an error.
-
- rep = replacer;
- if (replacer && typeof replacer !== 'function' &&
- (typeof replacer !== 'object' ||
- typeof replacer.length !== 'number')) {
- throw new Error('JSON.stringify');
- }
-
-// Make a fake root object containing our value under the key of ''.
-// Return the result of stringifying the value.
-
- return str('', {'': value});
- };
- }
-
-
-// If the JSON object does not yet have a parse method, give it one.
-
- if (typeof JSON.parse !== 'function') {
- cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;
- JSON.parse = function (text, reviver) {
-
-// The parse method takes a text and an optional reviver function, and returns
-// a JavaScript value if the text is a valid JSON text.
-
- var j;
-
- function walk(holder, key) {
-
-// The walk method is used to recursively walk the resulting structure so
-// that modifications can be made.
-
- var k, v, value = holder[key];
- if (value && typeof value === 'object') {
- for (k in value) {
- if (Object.prototype.hasOwnProperty.call(value, k)) {
- v = walk(value, k);
- if (v !== undefined) {
- value[k] = v;
- } else {
- delete value[k];
- }
- }
- }
- }
- return reviver.call(holder, key, value);
- }
-
-
-// Parsing happens in four stages. In the first stage, we replace certain
-// Unicode characters with escape sequences. JavaScript handles many characters
-// incorrectly, either silently deleting them, or treating them as line endings.
-
- text = String(text);
- cx.lastIndex = 0;
- if (cx.test(text)) {
- text = text.replace(cx, function (a) {
- return '\\u' +
- ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
- });
- }
-
-// In the second stage, we run the text against regular expressions that look
-// for non-JSON patterns. We are especially concerned with '()' and 'new'
-// because they can cause invocation, and '=' because it can cause mutation.
-// But just to be safe, we want to reject all unexpected forms.
-
-// We split the second stage into 4 regexp operations in order to work around
-// crippling inefficiencies in IE's and Safari's regexp engines. First we
-// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we
-// replace all simple value tokens with ']' characters. Third, we delete all
-// open brackets that follow a colon or comma or that begin the text. Finally,
-// we look to see that the remaining characters are only whitespace or ']' or
-// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval.
-
- if (/^[\],:{}\s]*$/
- .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@')
- .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']')
- .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {
-
-// In the third stage we use the eval function to compile the text into a
-// JavaScript structure. The '{' operator is subject to a syntactic ambiguity
-// in JavaScript: it can begin a block or an object literal. We wrap the text
-// in parens to eliminate the ambiguity.
-
- j = eval('(' + text + ')');
-
-// In the optional fourth stage, we recursively walk the new structure, passing
-// each name/value pair to a reviver function for possible transformation.
-
- return typeof reviver === 'function'
- ? walk({'': j}, '')
- : j;
- }
-
-// If the text is not JSON parseable, then a SyntaxError is thrown.
-
- throw new SyntaxError('JSON.parse');
- };
- }
-}());
\ No newline at end of file
+function AlgoliaExplainResults(a,b,c){function d(a,b){var c=[];if("object"==typeof a&&"matchedWords"in a&&"value"in a){for(var e=!1,f=0;f0?h[0]:"",f.subtitles=[],"undefined"!=typeof c)for(var i=0;i.5&&this.hosts.reverse(),this.hosts.push(this._isUndefined(c)||null==c?("https:"==document.location.protocol?"https":"http")+"://"+e[g]:"https"===c||"HTTPS"===c?"https://"+e[g]:"http://"+e[g]);Math.random()>.5&&this.hosts.reverse(),this.jsonp=null,this.jsonpWait=0,this._jsonRequest({method:"GET",url:"/1/isalive",callback:function(a){f.jsonp=!a}}),this.extraHeaders=[]};AlgoliaSearch.prototype={deleteIndex:function(a,b){this._jsonRequest({method:"DELETE",url:"/1/indexes/"+encodeURIComponent(a),callback:b})},moveIndex:function(a,b,c){var d={operation:"move",destination:b};this._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(a)+"/operation",body:d,callback:c})},copyIndex:function(a,b,c){var d={operation:"copy",destination:b};this._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(a)+"/operation",body:d,callback:c})},getLogs:function(a,b,c){this._isUndefined(b)&&(b=0),this._isUndefined(c)&&(c=10),this._jsonRequest({method:"GET",url:"/1/logs?offset="+b+"&length="+c,callback:a})},listIndexes:function(a){this._jsonRequest({method:"GET",url:"/1/indexes",callback:a})},initIndex:function(a){return new this.Index(this,a)},listUserKeys:function(a){this._jsonRequest({method:"GET",url:"/1/keys",callback:a})},getUserKeyACL:function(a,b){this._jsonRequest({method:"GET",url:"/1/keys/"+a,callback:b})},deleteUserKey:function(a,b){this._jsonRequest({method:"DELETE",url:"/1/keys/"+a,callback:b})},addUserKey:function(a,b){var c={};c.acl=a,this._jsonRequest({method:"POST",url:"/1/keys",body:c,callback:b})},addUserKeyWithValidity:function(a,b,c,d,e){var f=this,g={};g.acl=a,g.validity=b,g.maxQueriesPerIPPerHour=c,g.maxHitsPerQuery=d,this._jsonRequest({method:"POST",url:"/1/indexes/"+f.indexName+"/keys",body:g,callback:e})},setSecurityTags:function(a){if("[object Array]"===Object.prototype.toString.call(a)){for(var b=[],c=0;c0){var f=window.setTimeout(function(){c._sendQueriesBatch(d,a)},b);c.onDelayTrigger=f}else this._sendQueriesBatch(d,a)},Index:function(a,b){this.indexName=b,this.as=a,this.typeAheadArgs=null,this.typeAheadValueOption=null},setExtraHeader:function(a,b){this.extraHeaders.push({key:a,value:b})},_sendQueriesBatch:function(a,b){if(null==this.jsonp){var c=this;return void this._waitReady(function(){c._sendQueriesBatch(a,b)})}if(this.jsonp){for(var d="",e=0;e2e3&&(this.jsonp=!0),setTimeout(a,100))},_getSearchParams:function(a,b){if(this._isUndefined(a)||null==a)return b;for(var c in a)null!=c&&a.hasOwnProperty(c)&&(b+=0===b.length?"?":"&",b+=c+"="+encodeURIComponent("[object Array]"===Object.prototype.toString.call(a[c])?JSON.stringify(a[c]):a[c]));return b},_isUndefined:function(a){return void 0===a},applicationID:null,apiKey:null,tagFilters:null,userToken:null,hosts:[],cache:{},extraHeaders:[]},AlgoliaSearch.prototype.Index.prototype={clearCache:function(){this.cache={}},addObject:function(a,b,c){var d=this;this.as._jsonRequest(this.as._isUndefined(c)?{method:"POST",url:"/1/indexes/"+encodeURIComponent(d.indexName),body:a,callback:b}:{method:"PUT",url:"/1/indexes/"+encodeURIComponent(d.indexName)+"/"+encodeURIComponent(c),body:a,callback:b})},addObjects:function(a,b){for(var c=this,d={requests:[]},e=0;e0){var g=window.setTimeout(function(){e._search(f,b)},d);e.onDelayTrigger=g}else this._search(f,b)},browse:function(a,b,c){var d=this,e="?page="+a;_.isUndefined(c)||(e+="&hitsPerPage="+c),this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(d.indexName)+"/browse"+e,callback:b})},ttAdapter:function(a){var b=this;return function(c,d){b.search(c,function(a,b){a&&d(b.hits)},a)}},waitTask:function(a,b){var c=this;this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(c.indexName)+"/task/"+a,callback:function(d,e){d?"published"===e.status?b(!0,e):setTimeout(function(){c.waitTask(a,b)},100):b(!1,e)}})},clearIndex:function(a){var b=this;this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(b.indexName)+"/clear",callback:a})},getSettings:function(a){var b=this;this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(b.indexName)+"/settings",callback:a})},setSettings:function(a,b){var c=this;this.as._jsonRequest({method:"PUT",url:"/1/indexes/"+encodeURIComponent(c.indexName)+"/settings",body:a,callback:b})},listUserKeys:function(a){var b=this;this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(b.indexName)+"/keys",callback:a})},getUserKeyACL:function(a,b){var c=this;this.as._jsonRequest({method:"GET",url:"/1/indexes/"+encodeURIComponent(c.indexName)+"/keys/"+a,callback:b})},deleteUserKey:function(a,b){var c=this;this.as._jsonRequest({method:"DELETE",url:"/1/indexes/"+encodeURIComponent(c.indexName)+"/keys/"+a,callback:b})},addUserKey:function(a,b){var c=this,d={};d.acl=a,this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(c.indexName)+"/keys",body:d,callback:b})},addUserKeyWithValidity:function(a,b,c,d,e){var f=this,g={};g.acl=a,g.validity=b,g.maxQueriesPerIPPerHour=c,g.maxHitsPerQuery=d,this.as._jsonRequest({method:"POST",url:"/1/indexes/"+encodeURIComponent(f.indexName)+"/keys",body:g,callback:e})},_search:function(a,b){if(null==this.as.jsonp){var c=this;return void this.as._waitReady(function(){c._search(a,b)})}var d={params:a,apiKey:this.as.apiKey,appID:this.as.applicationID};this.as.tagFilters&&(d["X-Algolia-TagFilters"]=this.as.tagFilters),this.as.userToken&&(d["X-Algolia-UserToken"]=this.as.userToken),this.as._jsonRequest(this.as.jsonp?{cache:this.cache,method:"GET",jsonp:!0,url:"/1/indexes/"+encodeURIComponent(this.indexName),body:d,callback:b}:{cache:this.cache,method:"POST",url:"/1/indexes/"+encodeURIComponent(this.indexName)+"/query",body:d,callback:b})},as:null,indexName:null,cache:{},typeAheadArgs:null,typeAheadValueOption:null,emptyConstructor:function(){}},function(){var a=function(a){a=a||{};for(var b=1;b0&&this._gotoPage(this.page-1)},gotoPage:function(a){this._gotoPage(a)},setPage:function(a){this.page=a},_gotoPage:function(a){this.page=a,this._search()},_search:function(){this.client.startQueriesBatch(),this.client.addQueryInBatch(this.index,this.q,this._getHitsSearchParams());for(var a=0;a0&&b.push(e)}return b}}}(),"object"!=typeof JSON&&(JSON={}),function(){"use strict";function f(a){return 10>a?"0"+a:a}function quote(a){return escapable.lastIndex=0,escapable.test(a)?'"'+a.replace(escapable,function(a){var b=meta[a];return"string"==typeof b?b:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+a+'"'}function str(a,b){var c,d,e,f,g,h=gap,i=b[a];switch(i&&"object"==typeof i&&"function"==typeof i.toJSON&&(i=i.toJSON(a)),"function"==typeof rep&&(i=rep.call(b,a,i)),typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i)return"null";if(gap+=indent,g=[],"[object Array]"===Object.prototype.toString.apply(i)){for(f=i.length,c=0;f>c;c+=1)g[c]=str(c,i)||"null";return e=0===g.length?"[]":gap?"[\n"+gap+g.join(",\n"+gap)+"\n"+h+"]":"["+g.join(",")+"]",gap=h,e}if(rep&&"object"==typeof rep)for(f=rep.length,c=0;f>c;c+=1)"string"==typeof rep[c]&&(d=rep[c],e=str(d,i),e&&g.push(quote(d)+(gap?": ":":")+e));else for(d in i)Object.prototype.hasOwnProperty.call(i,d)&&(e=str(d,i),e&&g.push(quote(d)+(gap?": ":":")+e));return e=0===g.length?"{}":gap?"{\n"+gap+g.join(",\n"+gap)+"\n"+h+"}":"{"+g.join(",")+"}",gap=h,e}}"function"!=typeof Date.prototype.toJSON&&(Date.prototype.toJSON=function(){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null},String.prototype.toJSON=Number.prototype.toJSON=Boolean.prototype.toJSON=function(){return this.valueOf()});var cx,escapable,gap,indent,meta,rep;"function"!=typeof JSON.stringify&&(escapable=/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,meta={"\b":"\\b"," ":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},JSON.stringify=function(a,b,c){var d;if(gap="",indent="","number"==typeof c)for(d=0;c>d;d+=1)indent+=" ";else"string"==typeof c&&(indent=c);if(rep=b,b&&"function"!=typeof b&&("object"!=typeof b||"number"!=typeof b.length))throw new Error("JSON.stringify");return str("",{"":a})}),"function"!=typeof JSON.parse&&(cx=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,JSON.parse=function(text,reviver){function walk(a,b){var c,d,e=a[b];if(e&&"object"==typeof e)for(c in e)Object.prototype.hasOwnProperty.call(e,c)&&(d=walk(e,c),void 0!==d?e[c]=d:delete e[c]);return reviver.call(a,b,e)}var j;if(text=String(text),cx.lastIndex=0,cx.test(text)&&(text=text.replace(cx,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})),/^[\],:{}\s]*$/.test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,"@").replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,"]").replace(/(?:^|:|,)(?:\s*\[)+/g,"")))return j=eval("("+text+")"),"function"==typeof reviver?walk({"":j},""):j;throw new SyntaxError("JSON.parse")})}();
\ No newline at end of file
diff --git a/services/web/public/js/libs/moment-2.9.0.js b/services/web/public/js/libs/moment-2.9.0.js
index d98958d75a..024d488fbc 100755
--- a/services/web/public/js/libs/moment-2.9.0.js
+++ b/services/web/public/js/libs/moment-2.9.0.js
@@ -3,3041 +3,5 @@
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! momentjs.com
-
-(function (undefined) {
- /************************************
- Constants
- ************************************/
-
- var moment,
- VERSION = '2.9.0',
- // the global-scope this is NOT the global object in Node.js
- globalScope = (typeof global !== 'undefined' && (typeof window === 'undefined' || window === global.window)) ? global : this,
- oldGlobalMoment,
- round = Math.round,
- hasOwnProperty = Object.prototype.hasOwnProperty,
- i,
-
- YEAR = 0,
- MONTH = 1,
- DATE = 2,
- HOUR = 3,
- MINUTE = 4,
- SECOND = 5,
- MILLISECOND = 6,
-
- // internal storage for locale config files
- locales = {},
-
- // extra moment internal properties (plugins register props here)
- momentProperties = [],
-
- // check for nodeJS
- hasModule = (typeof module !== 'undefined' && module && module.exports),
-
- // ASP.NET json date format regex
- aspNetJsonRegex = /^\/?Date\((\-?\d+)/i,
- aspNetTimeSpanJsonRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,
-
- // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html
- // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere
- isoDurationRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/,
-
- // format tokens
- formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|x|X|zz?|ZZ?|.)/g,
- localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,
-
- // parsing token regexes
- parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99
- parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999
- parseTokenOneToFourDigits = /\d{1,4}/, // 0 - 9999
- parseTokenOneToSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999
- parseTokenDigits = /\d+/, // nonzero number of digits
- parseTokenWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, // any word (or two) characters or numbers including two/three word month in arabic.
- parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z
- parseTokenT = /T/i, // T (ISO separator)
- parseTokenOffsetMs = /[\+\-]?\d+/, // 1234567890123
- parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123
-
- //strict parsing regexes
- parseTokenOneDigit = /\d/, // 0 - 9
- parseTokenTwoDigits = /\d\d/, // 00 - 99
- parseTokenThreeDigits = /\d{3}/, // 000 - 999
- parseTokenFourDigits = /\d{4}/, // 0000 - 9999
- parseTokenSixDigits = /[+-]?\d{6}/, // -999,999 - 999,999
- parseTokenSignedNumber = /[+-]?\d+/, // -inf - inf
-
- // iso 8601 regex
- // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00)
- isoRegex = /^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,
-
- isoFormat = 'YYYY-MM-DDTHH:mm:ssZ',
-
- isoDates = [
- ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/],
- ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/],
- ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/],
- ['GGGG-[W]WW', /\d{4}-W\d{2}/],
- ['YYYY-DDD', /\d{4}-\d{3}/]
- ],
-
- // iso time formats and regexes
- isoTimes = [
- ['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/],
- ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/],
- ['HH:mm', /(T| )\d\d:\d\d/],
- ['HH', /(T| )\d\d/]
- ],
-
- // timezone chunker '+10:00' > ['10', '00'] or '-1530' > ['-', '15', '30']
- parseTimezoneChunker = /([\+\-]|\d\d)/gi,
-
- // getter and setter names
- proxyGettersAndSetters = 'Date|Hours|Minutes|Seconds|Milliseconds'.split('|'),
- unitMillisecondFactors = {
- 'Milliseconds' : 1,
- 'Seconds' : 1e3,
- 'Minutes' : 6e4,
- 'Hours' : 36e5,
- 'Days' : 864e5,
- 'Months' : 2592e6,
- 'Years' : 31536e6
- },
-
- unitAliases = {
- ms : 'millisecond',
- s : 'second',
- m : 'minute',
- h : 'hour',
- d : 'day',
- D : 'date',
- w : 'week',
- W : 'isoWeek',
- M : 'month',
- Q : 'quarter',
- y : 'year',
- DDD : 'dayOfYear',
- e : 'weekday',
- E : 'isoWeekday',
- gg: 'weekYear',
- GG: 'isoWeekYear'
- },
-
- camelFunctions = {
- dayofyear : 'dayOfYear',
- isoweekday : 'isoWeekday',
- isoweek : 'isoWeek',
- weekyear : 'weekYear',
- isoweekyear : 'isoWeekYear'
- },
-
- // format function strings
- formatFunctions = {},
-
- // default relative time thresholds
- relativeTimeThresholds = {
- s: 45, // seconds to minute
- m: 45, // minutes to hour
- h: 22, // hours to day
- d: 26, // days to month
- M: 11 // months to year
- },
-
- // tokens to ordinalize and pad
- ordinalizeTokens = 'DDD w W M D d'.split(' '),
- paddedTokens = 'M D H h m s w W'.split(' '),
-
- formatTokenFunctions = {
- M : function () {
- return this.month() + 1;
- },
- MMM : function (format) {
- return this.localeData().monthsShort(this, format);
- },
- MMMM : function (format) {
- return this.localeData().months(this, format);
- },
- D : function () {
- return this.date();
- },
- DDD : function () {
- return this.dayOfYear();
- },
- d : function () {
- return this.day();
- },
- dd : function (format) {
- return this.localeData().weekdaysMin(this, format);
- },
- ddd : function (format) {
- return this.localeData().weekdaysShort(this, format);
- },
- dddd : function (format) {
- return this.localeData().weekdays(this, format);
- },
- w : function () {
- return this.week();
- },
- W : function () {
- return this.isoWeek();
- },
- YY : function () {
- return leftZeroFill(this.year() % 100, 2);
- },
- YYYY : function () {
- return leftZeroFill(this.year(), 4);
- },
- YYYYY : function () {
- return leftZeroFill(this.year(), 5);
- },
- YYYYYY : function () {
- var y = this.year(), sign = y >= 0 ? '+' : '-';
- return sign + leftZeroFill(Math.abs(y), 6);
- },
- gg : function () {
- return leftZeroFill(this.weekYear() % 100, 2);
- },
- gggg : function () {
- return leftZeroFill(this.weekYear(), 4);
- },
- ggggg : function () {
- return leftZeroFill(this.weekYear(), 5);
- },
- GG : function () {
- return leftZeroFill(this.isoWeekYear() % 100, 2);
- },
- GGGG : function () {
- return leftZeroFill(this.isoWeekYear(), 4);
- },
- GGGGG : function () {
- return leftZeroFill(this.isoWeekYear(), 5);
- },
- e : function () {
- return this.weekday();
- },
- E : function () {
- return this.isoWeekday();
- },
- a : function () {
- return this.localeData().meridiem(this.hours(), this.minutes(), true);
- },
- A : function () {
- return this.localeData().meridiem(this.hours(), this.minutes(), false);
- },
- H : function () {
- return this.hours();
- },
- h : function () {
- return this.hours() % 12 || 12;
- },
- m : function () {
- return this.minutes();
- },
- s : function () {
- return this.seconds();
- },
- S : function () {
- return toInt(this.milliseconds() / 100);
- },
- SS : function () {
- return leftZeroFill(toInt(this.milliseconds() / 10), 2);
- },
- SSS : function () {
- return leftZeroFill(this.milliseconds(), 3);
- },
- SSSS : function () {
- return leftZeroFill(this.milliseconds(), 3);
- },
- Z : function () {
- var a = this.utcOffset(),
- b = '+';
- if (a < 0) {
- a = -a;
- b = '-';
- }
- return b + leftZeroFill(toInt(a / 60), 2) + ':' + leftZeroFill(toInt(a) % 60, 2);
- },
- ZZ : function () {
- var a = this.utcOffset(),
- b = '+';
- if (a < 0) {
- a = -a;
- b = '-';
- }
- return b + leftZeroFill(toInt(a / 60), 2) + leftZeroFill(toInt(a) % 60, 2);
- },
- z : function () {
- return this.zoneAbbr();
- },
- zz : function () {
- return this.zoneName();
- },
- x : function () {
- return this.valueOf();
- },
- X : function () {
- return this.unix();
- },
- Q : function () {
- return this.quarter();
- }
- },
-
- deprecations = {},
-
- lists = ['months', 'monthsShort', 'weekdays', 'weekdaysShort', 'weekdaysMin'],
-
- updateInProgress = false;
-
- // Pick the first defined of two or three arguments. dfl comes from
- // default.
- function dfl(a, b, c) {
- switch (arguments.length) {
- case 2: return a != null ? a : b;
- case 3: return a != null ? a : b != null ? b : c;
- default: throw new Error('Implement me');
- }
- }
-
- function hasOwnProp(a, b) {
- return hasOwnProperty.call(a, b);
- }
-
- function defaultParsingFlags() {
- // We need to deep clone this object, and es5 standard is not very
- // helpful.
- return {
- empty : false,
- unusedTokens : [],
- unusedInput : [],
- overflow : -2,
- charsLeftOver : 0,
- nullInput : false,
- invalidMonth : null,
- invalidFormat : false,
- userInvalidated : false,
- iso: false
- };
- }
-
- function printMsg(msg) {
- if (moment.suppressDeprecationWarnings === false &&
- typeof console !== 'undefined' && console.warn) {
- console.warn('Deprecation warning: ' + msg);
- }
- }
-
- function deprecate(msg, fn) {
- var firstTime = true;
- return extend(function () {
- if (firstTime) {
- printMsg(msg);
- firstTime = false;
- }
- return fn.apply(this, arguments);
- }, fn);
- }
-
- function deprecateSimple(name, msg) {
- if (!deprecations[name]) {
- printMsg(msg);
- deprecations[name] = true;
- }
- }
-
- function padToken(func, count) {
- return function (a) {
- return leftZeroFill(func.call(this, a), count);
- };
- }
- function ordinalizeToken(func, period) {
- return function (a) {
- return this.localeData().ordinal(func.call(this, a), period);
- };
- }
-
- function monthDiff(a, b) {
- // difference in months
- var wholeMonthDiff = ((b.year() - a.year()) * 12) + (b.month() - a.month()),
- // b is in (anchor - 1 month, anchor + 1 month)
- anchor = a.clone().add(wholeMonthDiff, 'months'),
- anchor2, adjust;
-
- if (b - anchor < 0) {
- anchor2 = a.clone().add(wholeMonthDiff - 1, 'months');
- // linear across the month
- adjust = (b - anchor) / (anchor - anchor2);
- } else {
- anchor2 = a.clone().add(wholeMonthDiff + 1, 'months');
- // linear across the month
- adjust = (b - anchor) / (anchor2 - anchor);
- }
-
- return -(wholeMonthDiff + adjust);
- }
-
- while (ordinalizeTokens.length) {
- i = ordinalizeTokens.pop();
- formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i], i);
- }
- while (paddedTokens.length) {
- i = paddedTokens.pop();
- formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2);
- }
- formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3);
-
-
- function meridiemFixWrap(locale, hour, meridiem) {
- var isPm;
-
- if (meridiem == null) {
- // nothing to do
- return hour;
- }
- if (locale.meridiemHour != null) {
- return locale.meridiemHour(hour, meridiem);
- } else if (locale.isPM != null) {
- // Fallback
- isPm = locale.isPM(meridiem);
- if (isPm && hour < 12) {
- hour += 12;
- }
- if (!isPm && hour === 12) {
- hour = 0;
- }
- return hour;
- } else {
- // thie is not supposed to happen
- return hour;
- }
- }
-
- /************************************
- Constructors
- ************************************/
-
- function Locale() {
- }
-
- // Moment prototype object
- function Moment(config, skipOverflow) {
- if (skipOverflow !== false) {
- checkOverflow(config);
- }
- copyConfig(this, config);
- this._d = new Date(+config._d);
- // Prevent infinite loop in case updateOffset creates new moment
- // objects.
- if (updateInProgress === false) {
- updateInProgress = true;
- moment.updateOffset(this);
- updateInProgress = false;
- }
- }
-
- // Duration Constructor
- function Duration(duration) {
- var normalizedInput = normalizeObjectUnits(duration),
- years = normalizedInput.year || 0,
- quarters = normalizedInput.quarter || 0,
- months = normalizedInput.month || 0,
- weeks = normalizedInput.week || 0,
- days = normalizedInput.day || 0,
- hours = normalizedInput.hour || 0,
- minutes = normalizedInput.minute || 0,
- seconds = normalizedInput.second || 0,
- milliseconds = normalizedInput.millisecond || 0;
-
- // representation for dateAddRemove
- this._milliseconds = +milliseconds +
- seconds * 1e3 + // 1000
- minutes * 6e4 + // 1000 * 60
- hours * 36e5; // 1000 * 60 * 60
- // Because of dateAddRemove treats 24 hours as different from a
- // day when working around DST, we need to store them separately
- this._days = +days +
- weeks * 7;
- // It is impossible translate months into days without knowing
- // which months you are are talking about, so we have to store
- // it separately.
- this._months = +months +
- quarters * 3 +
- years * 12;
-
- this._data = {};
-
- this._locale = moment.localeData();
-
- this._bubble();
- }
-
- /************************************
- Helpers
- ************************************/
-
-
- function extend(a, b) {
- for (var i in b) {
- if (hasOwnProp(b, i)) {
- a[i] = b[i];
- }
- }
-
- if (hasOwnProp(b, 'toString')) {
- a.toString = b.toString;
- }
-
- if (hasOwnProp(b, 'valueOf')) {
- a.valueOf = b.valueOf;
- }
-
- return a;
- }
-
- function copyConfig(to, from) {
- var i, prop, val;
-
- if (typeof from._isAMomentObject !== 'undefined') {
- to._isAMomentObject = from._isAMomentObject;
- }
- if (typeof from._i !== 'undefined') {
- to._i = from._i;
- }
- if (typeof from._f !== 'undefined') {
- to._f = from._f;
- }
- if (typeof from._l !== 'undefined') {
- to._l = from._l;
- }
- if (typeof from._strict !== 'undefined') {
- to._strict = from._strict;
- }
- if (typeof from._tzm !== 'undefined') {
- to._tzm = from._tzm;
- }
- if (typeof from._isUTC !== 'undefined') {
- to._isUTC = from._isUTC;
- }
- if (typeof from._offset !== 'undefined') {
- to._offset = from._offset;
- }
- if (typeof from._pf !== 'undefined') {
- to._pf = from._pf;
- }
- if (typeof from._locale !== 'undefined') {
- to._locale = from._locale;
- }
-
- if (momentProperties.length > 0) {
- for (i in momentProperties) {
- prop = momentProperties[i];
- val = from[prop];
- if (typeof val !== 'undefined') {
- to[prop] = val;
- }
- }
- }
-
- return to;
- }
-
- function absRound(number) {
- if (number < 0) {
- return Math.ceil(number);
- } else {
- return Math.floor(number);
- }
- }
-
- // left zero fill a number
- // see http://jsperf.com/left-zero-filling for performance comparison
- function leftZeroFill(number, targetLength, forceSign) {
- var output = '' + Math.abs(number),
- sign = number >= 0;
-
- while (output.length < targetLength) {
- output = '0' + output;
- }
- return (sign ? (forceSign ? '+' : '') : '-') + output;
- }
-
- function positiveMomentsDifference(base, other) {
- var res = {milliseconds: 0, months: 0};
-
- res.months = other.month() - base.month() +
- (other.year() - base.year()) * 12;
- if (base.clone().add(res.months, 'M').isAfter(other)) {
- --res.months;
- }
-
- res.milliseconds = +other - +(base.clone().add(res.months, 'M'));
-
- return res;
- }
-
- function momentsDifference(base, other) {
- var res;
- other = makeAs(other, base);
- if (base.isBefore(other)) {
- res = positiveMomentsDifference(base, other);
- } else {
- res = positiveMomentsDifference(other, base);
- res.milliseconds = -res.milliseconds;
- res.months = -res.months;
- }
-
- return res;
- }
-
- // TODO: remove 'name' arg after deprecation is removed
- function createAdder(direction, name) {
- return function (val, period) {
- var dur, tmp;
- //invert the arguments, but complain about it
- if (period !== null && !isNaN(+period)) {
- deprecateSimple(name, 'moment().' + name + '(period, number) is deprecated. Please use moment().' + name + '(number, period).');
- tmp = val; val = period; period = tmp;
- }
-
- val = typeof val === 'string' ? +val : val;
- dur = moment.duration(val, period);
- addOrSubtractDurationFromMoment(this, dur, direction);
- return this;
- };
- }
-
- function addOrSubtractDurationFromMoment(mom, duration, isAdding, updateOffset) {
- var milliseconds = duration._milliseconds,
- days = duration._days,
- months = duration._months;
- updateOffset = updateOffset == null ? true : updateOffset;
-
- if (milliseconds) {
- mom._d.setTime(+mom._d + milliseconds * isAdding);
- }
- if (days) {
- rawSetter(mom, 'Date', rawGetter(mom, 'Date') + days * isAdding);
- }
- if (months) {
- rawMonthSetter(mom, rawGetter(mom, 'Month') + months * isAdding);
- }
- if (updateOffset) {
- moment.updateOffset(mom, days || months);
- }
- }
-
- // check if is an array
- function isArray(input) {
- return Object.prototype.toString.call(input) === '[object Array]';
- }
-
- function isDate(input) {
- return Object.prototype.toString.call(input) === '[object Date]' ||
- input instanceof Date;
- }
-
- // compare two arrays, return the number of differences
- function compareArrays(array1, array2, dontConvert) {
- var len = Math.min(array1.length, array2.length),
- lengthDiff = Math.abs(array1.length - array2.length),
- diffs = 0,
- i;
- for (i = 0; i < len; i++) {
- if ((dontConvert && array1[i] !== array2[i]) ||
- (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) {
- diffs++;
- }
- }
- return diffs + lengthDiff;
- }
-
- function normalizeUnits(units) {
- if (units) {
- var lowered = units.toLowerCase().replace(/(.)s$/, '$1');
- units = unitAliases[units] || camelFunctions[lowered] || lowered;
- }
- return units;
- }
-
- function normalizeObjectUnits(inputObject) {
- var normalizedInput = {},
- normalizedProp,
- prop;
-
- for (prop in inputObject) {
- if (hasOwnProp(inputObject, prop)) {
- normalizedProp = normalizeUnits(prop);
- if (normalizedProp) {
- normalizedInput[normalizedProp] = inputObject[prop];
- }
- }
- }
-
- return normalizedInput;
- }
-
- function makeList(field) {
- var count, setter;
-
- if (field.indexOf('week') === 0) {
- count = 7;
- setter = 'day';
- }
- else if (field.indexOf('month') === 0) {
- count = 12;
- setter = 'month';
- }
- else {
- return;
- }
-
- moment[field] = function (format, index) {
- var i, getter,
- method = moment._locale[field],
- results = [];
-
- if (typeof format === 'number') {
- index = format;
- format = undefined;
- }
-
- getter = function (i) {
- var m = moment().utc().set(setter, i);
- return method.call(moment._locale, m, format || '');
- };
-
- if (index != null) {
- return getter(index);
- }
- else {
- for (i = 0; i < count; i++) {
- results.push(getter(i));
- }
- return results;
- }
- };
- }
-
- function toInt(argumentForCoercion) {
- var coercedNumber = +argumentForCoercion,
- value = 0;
-
- if (coercedNumber !== 0 && isFinite(coercedNumber)) {
- if (coercedNumber >= 0) {
- value = Math.floor(coercedNumber);
- } else {
- value = Math.ceil(coercedNumber);
- }
- }
-
- return value;
- }
-
- function daysInMonth(year, month) {
- return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
- }
-
- function weeksInYear(year, dow, doy) {
- return weekOfYear(moment([year, 11, 31 + dow - doy]), dow, doy).week;
- }
-
- function daysInYear(year) {
- return isLeapYear(year) ? 366 : 365;
- }
-
- function isLeapYear(year) {
- return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
- }
-
- function checkOverflow(m) {
- var overflow;
- if (m._a && m._pf.overflow === -2) {
- overflow =
- m._a[MONTH] < 0 || m._a[MONTH] > 11 ? MONTH :
- m._a[DATE] < 1 || m._a[DATE] > daysInMonth(m._a[YEAR], m._a[MONTH]) ? DATE :
- m._a[HOUR] < 0 || m._a[HOUR] > 24 ||
- (m._a[HOUR] === 24 && (m._a[MINUTE] !== 0 ||
- m._a[SECOND] !== 0 ||
- m._a[MILLISECOND] !== 0)) ? HOUR :
- m._a[MINUTE] < 0 || m._a[MINUTE] > 59 ? MINUTE :
- m._a[SECOND] < 0 || m._a[SECOND] > 59 ? SECOND :
- m._a[MILLISECOND] < 0 || m._a[MILLISECOND] > 999 ? MILLISECOND :
- -1;
-
- if (m._pf._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) {
- overflow = DATE;
- }
-
- m._pf.overflow = overflow;
- }
- }
-
- function isValid(m) {
- if (m._isValid == null) {
- m._isValid = !isNaN(m._d.getTime()) &&
- m._pf.overflow < 0 &&
- !m._pf.empty &&
- !m._pf.invalidMonth &&
- !m._pf.nullInput &&
- !m._pf.invalidFormat &&
- !m._pf.userInvalidated;
-
- if (m._strict) {
- m._isValid = m._isValid &&
- m._pf.charsLeftOver === 0 &&
- m._pf.unusedTokens.length === 0 &&
- m._pf.bigHour === undefined;
- }
- }
- return m._isValid;
- }
-
- function normalizeLocale(key) {
- return key ? key.toLowerCase().replace('_', '-') : key;
- }
-
- // pick the locale from the array
- // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each
- // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root
- function chooseLocale(names) {
- var i = 0, j, next, locale, split;
-
- while (i < names.length) {
- split = normalizeLocale(names[i]).split('-');
- j = split.length;
- next = normalizeLocale(names[i + 1]);
- next = next ? next.split('-') : null;
- while (j > 0) {
- locale = loadLocale(split.slice(0, j).join('-'));
- if (locale) {
- return locale;
- }
- if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) {
- //the next array item is better than a shallower substring of this one
- break;
- }
- j--;
- }
- i++;
- }
- return null;
- }
-
- function loadLocale(name) {
- var oldLocale = null;
- if (!locales[name] && hasModule) {
- try {
- oldLocale = moment.locale();
- require('./locale/' + name);
- // because defineLocale currently also sets the global locale, we want to undo that for lazy loaded locales
- moment.locale(oldLocale);
- } catch (e) { }
- }
- return locales[name];
- }
-
- // Return a moment from input, that is local/utc/utcOffset equivalent to
- // model.
- function makeAs(input, model) {
- var res, diff;
- if (model._isUTC) {
- res = model.clone();
- diff = (moment.isMoment(input) || isDate(input) ?
- +input : +moment(input)) - (+res);
- // Use low-level api, because this fn is low-level api.
- res._d.setTime(+res._d + diff);
- moment.updateOffset(res, false);
- return res;
- } else {
- return moment(input).local();
- }
- }
-
- /************************************
- Locale
- ************************************/
-
-
- extend(Locale.prototype, {
-
- set : function (config) {
- var prop, i;
- for (i in config) {
- prop = config[i];
- if (typeof prop === 'function') {
- this[i] = prop;
- } else {
- this['_' + i] = prop;
- }
- }
- // Lenient ordinal parsing accepts just a number in addition to
- // number + (possibly) stuff coming from _ordinalParseLenient.
- this._ordinalParseLenient = new RegExp(this._ordinalParse.source + '|' + /\d{1,2}/.source);
- },
-
- _months : 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_'),
- months : function (m) {
- return this._months[m.month()];
- },
-
- _monthsShort : 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'),
- monthsShort : function (m) {
- return this._monthsShort[m.month()];
- },
-
- monthsParse : function (monthName, format, strict) {
- var i, mom, regex;
-
- if (!this._monthsParse) {
- this._monthsParse = [];
- this._longMonthsParse = [];
- this._shortMonthsParse = [];
- }
-
- for (i = 0; i < 12; i++) {
- // make the regex if we don't have it already
- mom = moment.utc([2000, i]);
- if (strict && !this._longMonthsParse[i]) {
- this._longMonthsParse[i] = new RegExp('^' + this.months(mom, '').replace('.', '') + '$', 'i');
- this._shortMonthsParse[i] = new RegExp('^' + this.monthsShort(mom, '').replace('.', '') + '$', 'i');
- }
- if (!strict && !this._monthsParse[i]) {
- regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, '');
- this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i');
- }
- // test the regex
- if (strict && format === 'MMMM' && this._longMonthsParse[i].test(monthName)) {
- return i;
- } else if (strict && format === 'MMM' && this._shortMonthsParse[i].test(monthName)) {
- return i;
- } else if (!strict && this._monthsParse[i].test(monthName)) {
- return i;
- }
- }
- },
-
- _weekdays : 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'),
- weekdays : function (m) {
- return this._weekdays[m.day()];
- },
-
- _weekdaysShort : 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'),
- weekdaysShort : function (m) {
- return this._weekdaysShort[m.day()];
- },
-
- _weekdaysMin : 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'),
- weekdaysMin : function (m) {
- return this._weekdaysMin[m.day()];
- },
-
- weekdaysParse : function (weekdayName) {
- var i, mom, regex;
-
- if (!this._weekdaysParse) {
- this._weekdaysParse = [];
- }
-
- for (i = 0; i < 7; i++) {
- // make the regex if we don't have it already
- if (!this._weekdaysParse[i]) {
- mom = moment([2000, 1]).day(i);
- regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, '');
- this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i');
- }
- // test the regex
- if (this._weekdaysParse[i].test(weekdayName)) {
- return i;
- }
- }
- },
-
- _longDateFormat : {
- LTS : 'h:mm:ss A',
- LT : 'h:mm A',
- L : 'MM/DD/YYYY',
- LL : 'MMMM D, YYYY',
- LLL : 'MMMM D, YYYY LT',
- LLLL : 'dddd, MMMM D, YYYY LT'
- },
- longDateFormat : function (key) {
- var output = this._longDateFormat[key];
- if (!output && this._longDateFormat[key.toUpperCase()]) {
- output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) {
- return val.slice(1);
- });
- this._longDateFormat[key] = output;
- }
- return output;
- },
-
- isPM : function (input) {
- // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays
- // Using charAt should be more compatible.
- return ((input + '').toLowerCase().charAt(0) === 'p');
- },
-
- _meridiemParse : /[ap]\.?m?\.?/i,
- meridiem : function (hours, minutes, isLower) {
- if (hours > 11) {
- return isLower ? 'pm' : 'PM';
- } else {
- return isLower ? 'am' : 'AM';
- }
- },
-
-
- _calendar : {
- sameDay : '[Today at] LT',
- nextDay : '[Tomorrow at] LT',
- nextWeek : 'dddd [at] LT',
- lastDay : '[Yesterday at] LT',
- lastWeek : '[Last] dddd [at] LT',
- sameElse : 'L'
- },
- calendar : function (key, mom, now) {
- var output = this._calendar[key];
- return typeof output === 'function' ? output.apply(mom, [now]) : output;
- },
-
- _relativeTime : {
- future : 'in %s',
- past : '%s ago',
- s : 'a few seconds',
- m : 'a minute',
- mm : '%d minutes',
- h : 'an hour',
- hh : '%d hours',
- d : 'a day',
- dd : '%d days',
- M : 'a month',
- MM : '%d months',
- y : 'a year',
- yy : '%d years'
- },
-
- relativeTime : function (number, withoutSuffix, string, isFuture) {
- var output = this._relativeTime[string];
- return (typeof output === 'function') ?
- output(number, withoutSuffix, string, isFuture) :
- output.replace(/%d/i, number);
- },
-
- pastFuture : function (diff, output) {
- var format = this._relativeTime[diff > 0 ? 'future' : 'past'];
- return typeof format === 'function' ? format(output) : format.replace(/%s/i, output);
- },
-
- ordinal : function (number) {
- return this._ordinal.replace('%d', number);
- },
- _ordinal : '%d',
- _ordinalParse : /\d{1,2}/,
-
- preparse : function (string) {
- return string;
- },
-
- postformat : function (string) {
- return string;
- },
-
- week : function (mom) {
- return weekOfYear(mom, this._week.dow, this._week.doy).week;
- },
-
- _week : {
- dow : 0, // Sunday is the first day of the week.
- doy : 6 // The week that contains Jan 1st is the first week of the year.
- },
-
- firstDayOfWeek : function () {
- return this._week.dow;
- },
-
- firstDayOfYear : function () {
- return this._week.doy;
- },
-
- _invalidDate: 'Invalid date',
- invalidDate: function () {
- return this._invalidDate;
- }
- });
-
- /************************************
- Formatting
- ************************************/
-
-
- function removeFormattingTokens(input) {
- if (input.match(/\[[\s\S]/)) {
- return input.replace(/^\[|\]$/g, '');
- }
- return input.replace(/\\/g, '');
- }
-
- function makeFormatFunction(format) {
- var array = format.match(formattingTokens), i, length;
-
- for (i = 0, length = array.length; i < length; i++) {
- if (formatTokenFunctions[array[i]]) {
- array[i] = formatTokenFunctions[array[i]];
- } else {
- array[i] = removeFormattingTokens(array[i]);
- }
- }
-
- return function (mom) {
- var output = '';
- for (i = 0; i < length; i++) {
- output += array[i] instanceof Function ? array[i].call(mom, format) : array[i];
- }
- return output;
- };
- }
-
- // format date using native date object
- function formatMoment(m, format) {
- if (!m.isValid()) {
- return m.localeData().invalidDate();
- }
-
- format = expandFormat(format, m.localeData());
-
- if (!formatFunctions[format]) {
- formatFunctions[format] = makeFormatFunction(format);
- }
-
- return formatFunctions[format](m);
- }
-
- function expandFormat(format, locale) {
- var i = 5;
-
- function replaceLongDateFormatTokens(input) {
- return locale.longDateFormat(input) || input;
- }
-
- localFormattingTokens.lastIndex = 0;
- while (i >= 0 && localFormattingTokens.test(format)) {
- format = format.replace(localFormattingTokens, replaceLongDateFormatTokens);
- localFormattingTokens.lastIndex = 0;
- i -= 1;
- }
-
- return format;
- }
-
-
- /************************************
- Parsing
- ************************************/
-
-
- // get the regex to find the next token
- function getParseRegexForToken(token, config) {
- var a, strict = config._strict;
- switch (token) {
- case 'Q':
- return parseTokenOneDigit;
- case 'DDDD':
- return parseTokenThreeDigits;
- case 'YYYY':
- case 'GGGG':
- case 'gggg':
- return strict ? parseTokenFourDigits : parseTokenOneToFourDigits;
- case 'Y':
- case 'G':
- case 'g':
- return parseTokenSignedNumber;
- case 'YYYYYY':
- case 'YYYYY':
- case 'GGGGG':
- case 'ggggg':
- return strict ? parseTokenSixDigits : parseTokenOneToSixDigits;
- case 'S':
- if (strict) {
- return parseTokenOneDigit;
- }
- /* falls through */
- case 'SS':
- if (strict) {
- return parseTokenTwoDigits;
- }
- /* falls through */
- case 'SSS':
- if (strict) {
- return parseTokenThreeDigits;
- }
- /* falls through */
- case 'DDD':
- return parseTokenOneToThreeDigits;
- case 'MMM':
- case 'MMMM':
- case 'dd':
- case 'ddd':
- case 'dddd':
- return parseTokenWord;
- case 'a':
- case 'A':
- return config._locale._meridiemParse;
- case 'x':
- return parseTokenOffsetMs;
- case 'X':
- return parseTokenTimestampMs;
- case 'Z':
- case 'ZZ':
- return parseTokenTimezone;
- case 'T':
- return parseTokenT;
- case 'SSSS':
- return parseTokenDigits;
- case 'MM':
- case 'DD':
- case 'YY':
- case 'GG':
- case 'gg':
- case 'HH':
- case 'hh':
- case 'mm':
- case 'ss':
- case 'ww':
- case 'WW':
- return strict ? parseTokenTwoDigits : parseTokenOneOrTwoDigits;
- case 'M':
- case 'D':
- case 'd':
- case 'H':
- case 'h':
- case 'm':
- case 's':
- case 'w':
- case 'W':
- case 'e':
- case 'E':
- return parseTokenOneOrTwoDigits;
- case 'Do':
- return strict ? config._locale._ordinalParse : config._locale._ordinalParseLenient;
- default :
- a = new RegExp(regexpEscape(unescapeFormat(token.replace('\\', '')), 'i'));
- return a;
- }
- }
-
- function utcOffsetFromString(string) {
- string = string || '';
- var possibleTzMatches = (string.match(parseTokenTimezone) || []),
- tzChunk = possibleTzMatches[possibleTzMatches.length - 1] || [],
- parts = (tzChunk + '').match(parseTimezoneChunker) || ['-', 0, 0],
- minutes = +(parts[1] * 60) + toInt(parts[2]);
-
- return parts[0] === '+' ? minutes : -minutes;
- }
-
- // function to convert string input to date
- function addTimeToArrayFromToken(token, input, config) {
- var a, datePartArray = config._a;
-
- switch (token) {
- // QUARTER
- case 'Q':
- if (input != null) {
- datePartArray[MONTH] = (toInt(input) - 1) * 3;
- }
- break;
- // MONTH
- case 'M' : // fall through to MM
- case 'MM' :
- if (input != null) {
- datePartArray[MONTH] = toInt(input) - 1;
- }
- break;
- case 'MMM' : // fall through to MMMM
- case 'MMMM' :
- a = config._locale.monthsParse(input, token, config._strict);
- // if we didn't find a month name, mark the date as invalid.
- if (a != null) {
- datePartArray[MONTH] = a;
- } else {
- config._pf.invalidMonth = input;
- }
- break;
- // DAY OF MONTH
- case 'D' : // fall through to DD
- case 'DD' :
- if (input != null) {
- datePartArray[DATE] = toInt(input);
- }
- break;
- case 'Do' :
- if (input != null) {
- datePartArray[DATE] = toInt(parseInt(
- input.match(/\d{1,2}/)[0], 10));
- }
- break;
- // DAY OF YEAR
- case 'DDD' : // fall through to DDDD
- case 'DDDD' :
- if (input != null) {
- config._dayOfYear = toInt(input);
- }
-
- break;
- // YEAR
- case 'YY' :
- datePartArray[YEAR] = moment.parseTwoDigitYear(input);
- break;
- case 'YYYY' :
- case 'YYYYY' :
- case 'YYYYYY' :
- datePartArray[YEAR] = toInt(input);
- break;
- // AM / PM
- case 'a' : // fall through to A
- case 'A' :
- config._meridiem = input;
- // config._isPm = config._locale.isPM(input);
- break;
- // HOUR
- case 'h' : // fall through to hh
- case 'hh' :
- config._pf.bigHour = true;
- /* falls through */
- case 'H' : // fall through to HH
- case 'HH' :
- datePartArray[HOUR] = toInt(input);
- break;
- // MINUTE
- case 'm' : // fall through to mm
- case 'mm' :
- datePartArray[MINUTE] = toInt(input);
- break;
- // SECOND
- case 's' : // fall through to ss
- case 'ss' :
- datePartArray[SECOND] = toInt(input);
- break;
- // MILLISECOND
- case 'S' :
- case 'SS' :
- case 'SSS' :
- case 'SSSS' :
- datePartArray[MILLISECOND] = toInt(('0.' + input) * 1000);
- break;
- // UNIX OFFSET (MILLISECONDS)
- case 'x':
- config._d = new Date(toInt(input));
- break;
- // UNIX TIMESTAMP WITH MS
- case 'X':
- config._d = new Date(parseFloat(input) * 1000);
- break;
- // TIMEZONE
- case 'Z' : // fall through to ZZ
- case 'ZZ' :
- config._useUTC = true;
- config._tzm = utcOffsetFromString(input);
- break;
- // WEEKDAY - human
- case 'dd':
- case 'ddd':
- case 'dddd':
- a = config._locale.weekdaysParse(input);
- // if we didn't get a weekday name, mark the date as invalid
- if (a != null) {
- config._w = config._w || {};
- config._w['d'] = a;
- } else {
- config._pf.invalidWeekday = input;
- }
- break;
- // WEEK, WEEK DAY - numeric
- case 'w':
- case 'ww':
- case 'W':
- case 'WW':
- case 'd':
- case 'e':
- case 'E':
- token = token.substr(0, 1);
- /* falls through */
- case 'gggg':
- case 'GGGG':
- case 'GGGGG':
- token = token.substr(0, 2);
- if (input) {
- config._w = config._w || {};
- config._w[token] = toInt(input);
- }
- break;
- case 'gg':
- case 'GG':
- config._w = config._w || {};
- config._w[token] = moment.parseTwoDigitYear(input);
- }
- }
-
- function dayOfYearFromWeekInfo(config) {
- var w, weekYear, week, weekday, dow, doy, temp;
-
- w = config._w;
- if (w.GG != null || w.W != null || w.E != null) {
- dow = 1;
- doy = 4;
-
- // TODO: We need to take the current isoWeekYear, but that depends on
- // how we interpret now (local, utc, fixed offset). So create
- // a now version of current config (take local/utc/offset flags, and
- // create now).
- weekYear = dfl(w.GG, config._a[YEAR], weekOfYear(moment(), 1, 4).year);
- week = dfl(w.W, 1);
- weekday = dfl(w.E, 1);
- } else {
- dow = config._locale._week.dow;
- doy = config._locale._week.doy;
-
- weekYear = dfl(w.gg, config._a[YEAR], weekOfYear(moment(), dow, doy).year);
- week = dfl(w.w, 1);
-
- if (w.d != null) {
- // weekday -- low day numbers are considered next week
- weekday = w.d;
- if (weekday < dow) {
- ++week;
- }
- } else if (w.e != null) {
- // local weekday -- counting starts from begining of week
- weekday = w.e + dow;
- } else {
- // default to begining of week
- weekday = dow;
- }
- }
- temp = dayOfYearFromWeeks(weekYear, week, weekday, doy, dow);
-
- config._a[YEAR] = temp.year;
- config._dayOfYear = temp.dayOfYear;
- }
-
- // convert an array to a date.
- // the array should mirror the parameters below
- // note: all values past the year are optional and will default to the lowest possible value.
- // [year, month, day , hour, minute, second, millisecond]
- function dateFromConfig(config) {
- var i, date, input = [], currentDate, yearToUse;
-
- if (config._d) {
- return;
- }
-
- currentDate = currentDateArray(config);
-
- //compute day of the year from weeks and weekdays
- if (config._w && config._a[DATE] == null && config._a[MONTH] == null) {
- dayOfYearFromWeekInfo(config);
- }
-
- //if the day of the year is set, figure out what it is
- if (config._dayOfYear) {
- yearToUse = dfl(config._a[YEAR], currentDate[YEAR]);
-
- if (config._dayOfYear > daysInYear(yearToUse)) {
- config._pf._overflowDayOfYear = true;
- }
-
- date = makeUTCDate(yearToUse, 0, config._dayOfYear);
- config._a[MONTH] = date.getUTCMonth();
- config._a[DATE] = date.getUTCDate();
- }
-
- // Default to current date.
- // * if no year, month, day of month are given, default to today
- // * if day of month is given, default month and year
- // * if month is given, default only year
- // * if year is given, don't default anything
- for (i = 0; i < 3 && config._a[i] == null; ++i) {
- config._a[i] = input[i] = currentDate[i];
- }
-
- // Zero out whatever was not defaulted, including time
- for (; i < 7; i++) {
- config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i];
- }
-
- // Check for 24:00:00.000
- if (config._a[HOUR] === 24 &&
- config._a[MINUTE] === 0 &&
- config._a[SECOND] === 0 &&
- config._a[MILLISECOND] === 0) {
- config._nextDay = true;
- config._a[HOUR] = 0;
- }
-
- config._d = (config._useUTC ? makeUTCDate : makeDate).apply(null, input);
- // Apply timezone offset from input. The actual utcOffset can be changed
- // with parseZone.
- if (config._tzm != null) {
- config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm);
- }
-
- if (config._nextDay) {
- config._a[HOUR] = 24;
- }
- }
-
- function dateFromObject(config) {
- var normalizedInput;
-
- if (config._d) {
- return;
- }
-
- normalizedInput = normalizeObjectUnits(config._i);
- config._a = [
- normalizedInput.year,
- normalizedInput.month,
- normalizedInput.day || normalizedInput.date,
- normalizedInput.hour,
- normalizedInput.minute,
- normalizedInput.second,
- normalizedInput.millisecond
- ];
-
- dateFromConfig(config);
- }
-
- function currentDateArray(config) {
- var now = new Date();
- if (config._useUTC) {
- return [
- now.getUTCFullYear(),
- now.getUTCMonth(),
- now.getUTCDate()
- ];
- } else {
- return [now.getFullYear(), now.getMonth(), now.getDate()];
- }
- }
-
- // date from string and format string
- function makeDateFromStringAndFormat(config) {
- if (config._f === moment.ISO_8601) {
- parseISO(config);
- return;
- }
-
- config._a = [];
- config._pf.empty = true;
-
- // This array is used to make a Date, either with `new Date` or `Date.UTC`
- var string = '' + config._i,
- i, parsedInput, tokens, token, skipped,
- stringLength = string.length,
- totalParsedInputLength = 0;
-
- tokens = expandFormat(config._f, config._locale).match(formattingTokens) || [];
-
- for (i = 0; i < tokens.length; i++) {
- token = tokens[i];
- parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0];
- if (parsedInput) {
- skipped = string.substr(0, string.indexOf(parsedInput));
- if (skipped.length > 0) {
- config._pf.unusedInput.push(skipped);
- }
- string = string.slice(string.indexOf(parsedInput) + parsedInput.length);
- totalParsedInputLength += parsedInput.length;
- }
- // don't parse if it's not a known token
- if (formatTokenFunctions[token]) {
- if (parsedInput) {
- config._pf.empty = false;
- }
- else {
- config._pf.unusedTokens.push(token);
- }
- addTimeToArrayFromToken(token, parsedInput, config);
- }
- else if (config._strict && !parsedInput) {
- config._pf.unusedTokens.push(token);
- }
- }
-
- // add remaining unparsed input length to the string
- config._pf.charsLeftOver = stringLength - totalParsedInputLength;
- if (string.length > 0) {
- config._pf.unusedInput.push(string);
- }
-
- // clear _12h flag if hour is <= 12
- if (config._pf.bigHour === true && config._a[HOUR] <= 12) {
- config._pf.bigHour = undefined;
- }
- // handle meridiem
- config._a[HOUR] = meridiemFixWrap(config._locale, config._a[HOUR],
- config._meridiem);
- dateFromConfig(config);
- checkOverflow(config);
- }
-
- function unescapeFormat(s) {
- return s.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) {
- return p1 || p2 || p3 || p4;
- });
- }
-
- // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
- function regexpEscape(s) {
- return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
- }
-
- // date from string and array of format strings
- function makeDateFromStringAndArray(config) {
- var tempConfig,
- bestMoment,
-
- scoreToBeat,
- i,
- currentScore;
-
- if (config._f.length === 0) {
- config._pf.invalidFormat = true;
- config._d = new Date(NaN);
- return;
- }
-
- for (i = 0; i < config._f.length; i++) {
- currentScore = 0;
- tempConfig = copyConfig({}, config);
- if (config._useUTC != null) {
- tempConfig._useUTC = config._useUTC;
- }
- tempConfig._pf = defaultParsingFlags();
- tempConfig._f = config._f[i];
- makeDateFromStringAndFormat(tempConfig);
-
- if (!isValid(tempConfig)) {
- continue;
- }
-
- // if there is any input that was not parsed add a penalty for that format
- currentScore += tempConfig._pf.charsLeftOver;
-
- //or tokens
- currentScore += tempConfig._pf.unusedTokens.length * 10;
-
- tempConfig._pf.score = currentScore;
-
- if (scoreToBeat == null || currentScore < scoreToBeat) {
- scoreToBeat = currentScore;
- bestMoment = tempConfig;
- }
- }
-
- extend(config, bestMoment || tempConfig);
- }
-
- // date from iso format
- function parseISO(config) {
- var i, l,
- string = config._i,
- match = isoRegex.exec(string);
-
- if (match) {
- config._pf.iso = true;
- for (i = 0, l = isoDates.length; i < l; i++) {
- if (isoDates[i][1].exec(string)) {
- // match[5] should be 'T' or undefined
- config._f = isoDates[i][0] + (match[6] || ' ');
- break;
- }
- }
- for (i = 0, l = isoTimes.length; i < l; i++) {
- if (isoTimes[i][1].exec(string)) {
- config._f += isoTimes[i][0];
- break;
- }
- }
- if (string.match(parseTokenTimezone)) {
- config._f += 'Z';
- }
- makeDateFromStringAndFormat(config);
- } else {
- config._isValid = false;
- }
- }
-
- // date from iso format or fallback
- function makeDateFromString(config) {
- parseISO(config);
- if (config._isValid === false) {
- delete config._isValid;
- moment.createFromInputFallback(config);
- }
- }
-
- function map(arr, fn) {
- var res = [], i;
- for (i = 0; i < arr.length; ++i) {
- res.push(fn(arr[i], i));
- }
- return res;
- }
-
- function makeDateFromInput(config) {
- var input = config._i, matched;
- if (input === undefined) {
- config._d = new Date();
- } else if (isDate(input)) {
- config._d = new Date(+input);
- } else if ((matched = aspNetJsonRegex.exec(input)) !== null) {
- config._d = new Date(+matched[1]);
- } else if (typeof input === 'string') {
- makeDateFromString(config);
- } else if (isArray(input)) {
- config._a = map(input.slice(0), function (obj) {
- return parseInt(obj, 10);
- });
- dateFromConfig(config);
- } else if (typeof(input) === 'object') {
- dateFromObject(config);
- } else if (typeof(input) === 'number') {
- // from milliseconds
- config._d = new Date(input);
- } else {
- moment.createFromInputFallback(config);
- }
- }
-
- function makeDate(y, m, d, h, M, s, ms) {
- //can't just apply() to create a date:
- //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply
- var date = new Date(y, m, d, h, M, s, ms);
-
- //the date constructor doesn't accept years < 1970
- if (y < 1970) {
- date.setFullYear(y);
- }
- return date;
- }
-
- function makeUTCDate(y) {
- var date = new Date(Date.UTC.apply(null, arguments));
- if (y < 1970) {
- date.setUTCFullYear(y);
- }
- return date;
- }
-
- function parseWeekday(input, locale) {
- if (typeof input === 'string') {
- if (!isNaN(input)) {
- input = parseInt(input, 10);
- }
- else {
- input = locale.weekdaysParse(input);
- if (typeof input !== 'number') {
- return null;
- }
- }
- }
- return input;
- }
-
- /************************************
- Relative Time
- ************************************/
-
-
- // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize
- function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) {
- return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture);
- }
-
- function relativeTime(posNegDuration, withoutSuffix, locale) {
- var duration = moment.duration(posNegDuration).abs(),
- seconds = round(duration.as('s')),
- minutes = round(duration.as('m')),
- hours = round(duration.as('h')),
- days = round(duration.as('d')),
- months = round(duration.as('M')),
- years = round(duration.as('y')),
-
- args = seconds < relativeTimeThresholds.s && ['s', seconds] ||
- minutes === 1 && ['m'] ||
- minutes < relativeTimeThresholds.m && ['mm', minutes] ||
- hours === 1 && ['h'] ||
- hours < relativeTimeThresholds.h && ['hh', hours] ||
- days === 1 && ['d'] ||
- days < relativeTimeThresholds.d && ['dd', days] ||
- months === 1 && ['M'] ||
- months < relativeTimeThresholds.M && ['MM', months] ||
- years === 1 && ['y'] || ['yy', years];
-
- args[2] = withoutSuffix;
- args[3] = +posNegDuration > 0;
- args[4] = locale;
- return substituteTimeAgo.apply({}, args);
- }
-
-
- /************************************
- Week of Year
- ************************************/
-
-
- // firstDayOfWeek 0 = sun, 6 = sat
- // the day of the week that starts the week
- // (usually sunday or monday)
- // firstDayOfWeekOfYear 0 = sun, 6 = sat
- // the first week is the week that contains the first
- // of this day of the week
- // (eg. ISO weeks use thursday (4))
- function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) {
- var end = firstDayOfWeekOfYear - firstDayOfWeek,
- daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(),
- adjustedMoment;
-
-
- if (daysToDayOfWeek > end) {
- daysToDayOfWeek -= 7;
- }
-
- if (daysToDayOfWeek < end - 7) {
- daysToDayOfWeek += 7;
- }
-
- adjustedMoment = moment(mom).add(daysToDayOfWeek, 'd');
- return {
- week: Math.ceil(adjustedMoment.dayOfYear() / 7),
- year: adjustedMoment.year()
- };
- }
-
- //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday
- function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) {
- var d = makeUTCDate(year, 0, 1).getUTCDay(), daysToAdd, dayOfYear;
-
- d = d === 0 ? 7 : d;
- weekday = weekday != null ? weekday : firstDayOfWeek;
- daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 7 : 0);
- dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1;
-
- return {
- year: dayOfYear > 0 ? year : year - 1,
- dayOfYear: dayOfYear > 0 ? dayOfYear : daysInYear(year - 1) + dayOfYear
- };
- }
-
- /************************************
- Top Level Functions
- ************************************/
-
- function makeMoment(config) {
- var input = config._i,
- format = config._f,
- res;
-
- config._locale = config._locale || moment.localeData(config._l);
-
- if (input === null || (format === undefined && input === '')) {
- return moment.invalid({nullInput: true});
- }
-
- if (typeof input === 'string') {
- config._i = input = config._locale.preparse(input);
- }
-
- if (moment.isMoment(input)) {
- return new Moment(input, true);
- } else if (format) {
- if (isArray(format)) {
- makeDateFromStringAndArray(config);
- } else {
- makeDateFromStringAndFormat(config);
- }
- } else {
- makeDateFromInput(config);
- }
-
- res = new Moment(config);
- if (res._nextDay) {
- // Adding is smart enough around DST
- res.add(1, 'd');
- res._nextDay = undefined;
- }
-
- return res;
- }
-
- moment = function (input, format, locale, strict) {
- var c;
-
- if (typeof(locale) === 'boolean') {
- strict = locale;
- locale = undefined;
- }
- // object construction must be done this way.
- // https://github.com/moment/moment/issues/1423
- c = {};
- c._isAMomentObject = true;
- c._i = input;
- c._f = format;
- c._l = locale;
- c._strict = strict;
- c._isUTC = false;
- c._pf = defaultParsingFlags();
-
- return makeMoment(c);
- };
-
- moment.suppressDeprecationWarnings = false;
-
- moment.createFromInputFallback = deprecate(
- 'moment construction falls back to js Date. This is ' +
- 'discouraged and will be removed in upcoming major ' +
- 'release. Please refer to ' +
- 'https://github.com/moment/moment/issues/1407 for more info.',
- function (config) {
- config._d = new Date(config._i + (config._useUTC ? ' UTC' : ''));
- }
- );
-
- // Pick a moment m from moments so that m[fn](other) is true for all
- // other. This relies on the function fn to be transitive.
- //
- // moments should either be an array of moment objects or an array, whose
- // first element is an array of moment objects.
- function pickBy(fn, moments) {
- var res, i;
- if (moments.length === 1 && isArray(moments[0])) {
- moments = moments[0];
- }
- if (!moments.length) {
- return moment();
- }
- res = moments[0];
- for (i = 1; i < moments.length; ++i) {
- if (moments[i][fn](res)) {
- res = moments[i];
- }
- }
- return res;
- }
-
- moment.min = function () {
- var args = [].slice.call(arguments, 0);
-
- return pickBy('isBefore', args);
- };
-
- moment.max = function () {
- var args = [].slice.call(arguments, 0);
-
- return pickBy('isAfter', args);
- };
-
- // creating with utc
- moment.utc = function (input, format, locale, strict) {
- var c;
-
- if (typeof(locale) === 'boolean') {
- strict = locale;
- locale = undefined;
- }
- // object construction must be done this way.
- // https://github.com/moment/moment/issues/1423
- c = {};
- c._isAMomentObject = true;
- c._useUTC = true;
- c._isUTC = true;
- c._l = locale;
- c._i = input;
- c._f = format;
- c._strict = strict;
- c._pf = defaultParsingFlags();
-
- return makeMoment(c).utc();
- };
-
- // creating with unix timestamp (in seconds)
- moment.unix = function (input) {
- return moment(input * 1000);
- };
-
- // duration
- moment.duration = function (input, key) {
- var duration = input,
- // matching against regexp is expensive, do it on demand
- match = null,
- sign,
- ret,
- parseIso,
- diffRes;
-
- if (moment.isDuration(input)) {
- duration = {
- ms: input._milliseconds,
- d: input._days,
- M: input._months
- };
- } else if (typeof input === 'number') {
- duration = {};
- if (key) {
- duration[key] = input;
- } else {
- duration.milliseconds = input;
- }
- } else if (!!(match = aspNetTimeSpanJsonRegex.exec(input))) {
- sign = (match[1] === '-') ? -1 : 1;
- duration = {
- y: 0,
- d: toInt(match[DATE]) * sign,
- h: toInt(match[HOUR]) * sign,
- m: toInt(match[MINUTE]) * sign,
- s: toInt(match[SECOND]) * sign,
- ms: toInt(match[MILLISECOND]) * sign
- };
- } else if (!!(match = isoDurationRegex.exec(input))) {
- sign = (match[1] === '-') ? -1 : 1;
- parseIso = function (inp) {
- // We'd normally use ~~inp for this, but unfortunately it also
- // converts floats to ints.
- // inp may be undefined, so careful calling replace on it.
- var res = inp && parseFloat(inp.replace(',', '.'));
- // apply sign while we're at it
- return (isNaN(res) ? 0 : res) * sign;
- };
- duration = {
- y: parseIso(match[2]),
- M: parseIso(match[3]),
- d: parseIso(match[4]),
- h: parseIso(match[5]),
- m: parseIso(match[6]),
- s: parseIso(match[7]),
- w: parseIso(match[8])
- };
- } else if (duration == null) {// checks for null or undefined
- duration = {};
- } else if (typeof duration === 'object' &&
- ('from' in duration || 'to' in duration)) {
- diffRes = momentsDifference(moment(duration.from), moment(duration.to));
-
- duration = {};
- duration.ms = diffRes.milliseconds;
- duration.M = diffRes.months;
- }
-
- ret = new Duration(duration);
-
- if (moment.isDuration(input) && hasOwnProp(input, '_locale')) {
- ret._locale = input._locale;
- }
-
- return ret;
- };
-
- // version number
- moment.version = VERSION;
-
- // default format
- moment.defaultFormat = isoFormat;
-
- // constant that refers to the ISO standard
- moment.ISO_8601 = function () {};
-
- // Plugins that add properties should also add the key here (null value),
- // so we can properly clone ourselves.
- moment.momentProperties = momentProperties;
-
- // This function will be called whenever a moment is mutated.
- // It is intended to keep the offset in sync with the timezone.
- moment.updateOffset = function () {};
-
- // This function allows you to set a threshold for relative time strings
- moment.relativeTimeThreshold = function (threshold, limit) {
- if (relativeTimeThresholds[threshold] === undefined) {
- return false;
- }
- if (limit === undefined) {
- return relativeTimeThresholds[threshold];
- }
- relativeTimeThresholds[threshold] = limit;
- return true;
- };
-
- moment.lang = deprecate(
- 'moment.lang is deprecated. Use moment.locale instead.',
- function (key, value) {
- return moment.locale(key, value);
- }
- );
-
- // This function will load locale and then set the global locale. If
- // no arguments are passed in, it will simply return the current global
- // locale key.
- moment.locale = function (key, values) {
- var data;
- if (key) {
- if (typeof(values) !== 'undefined') {
- data = moment.defineLocale(key, values);
- }
- else {
- data = moment.localeData(key);
- }
-
- if (data) {
- moment.duration._locale = moment._locale = data;
- }
- }
-
- return moment._locale._abbr;
- };
-
- moment.defineLocale = function (name, values) {
- if (values !== null) {
- values.abbr = name;
- if (!locales[name]) {
- locales[name] = new Locale();
- }
- locales[name].set(values);
-
- // backwards compat for now: also set the locale
- moment.locale(name);
-
- return locales[name];
- } else {
- // useful for testing
- delete locales[name];
- return null;
- }
- };
-
- moment.langData = deprecate(
- 'moment.langData is deprecated. Use moment.localeData instead.',
- function (key) {
- return moment.localeData(key);
- }
- );
-
- // returns locale data
- moment.localeData = function (key) {
- var locale;
-
- if (key && key._locale && key._locale._abbr) {
- key = key._locale._abbr;
- }
-
- if (!key) {
- return moment._locale;
- }
-
- if (!isArray(key)) {
- //short-circuit everything else
- locale = loadLocale(key);
- if (locale) {
- return locale;
- }
- key = [key];
- }
-
- return chooseLocale(key);
- };
-
- // compare moment object
- moment.isMoment = function (obj) {
- return obj instanceof Moment ||
- (obj != null && hasOwnProp(obj, '_isAMomentObject'));
- };
-
- // for typechecking Duration objects
- moment.isDuration = function (obj) {
- return obj instanceof Duration;
- };
-
- for (i = lists.length - 1; i >= 0; --i) {
- makeList(lists[i]);
- }
-
- moment.normalizeUnits = function (units) {
- return normalizeUnits(units);
- };
-
- moment.invalid = function (flags) {
- var m = moment.utc(NaN);
- if (flags != null) {
- extend(m._pf, flags);
- }
- else {
- m._pf.userInvalidated = true;
- }
-
- return m;
- };
-
- moment.parseZone = function () {
- return moment.apply(null, arguments).parseZone();
- };
-
- moment.parseTwoDigitYear = function (input) {
- return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
- };
-
- moment.isDate = isDate;
-
- /************************************
- Moment Prototype
- ************************************/
-
-
- extend(moment.fn = Moment.prototype, {
-
- clone : function () {
- return moment(this);
- },
-
- valueOf : function () {
- return +this._d - ((this._offset || 0) * 60000);
- },
-
- unix : function () {
- return Math.floor(+this / 1000);
- },
-
- toString : function () {
- return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ');
- },
-
- toDate : function () {
- return this._offset ? new Date(+this) : this._d;
- },
-
- toISOString : function () {
- var m = moment(this).utc();
- if (0 < m.year() && m.year() <= 9999) {
- if ('function' === typeof Date.prototype.toISOString) {
- // native implementation is ~50x faster, use it when we can
- return this.toDate().toISOString();
- } else {
- return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
- }
- } else {
- return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
- }
- },
-
- toArray : function () {
- var m = this;
- return [
- m.year(),
- m.month(),
- m.date(),
- m.hours(),
- m.minutes(),
- m.seconds(),
- m.milliseconds()
- ];
- },
-
- isValid : function () {
- return isValid(this);
- },
-
- isDSTShifted : function () {
- if (this._a) {
- return this.isValid() && compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()) > 0;
- }
-
- return false;
- },
-
- parsingFlags : function () {
- return extend({}, this._pf);
- },
-
- invalidAt: function () {
- return this._pf.overflow;
- },
-
- utc : function (keepLocalTime) {
- return this.utcOffset(0, keepLocalTime);
- },
-
- local : function (keepLocalTime) {
- if (this._isUTC) {
- this.utcOffset(0, keepLocalTime);
- this._isUTC = false;
-
- if (keepLocalTime) {
- this.subtract(this._dateUtcOffset(), 'm');
- }
- }
- return this;
- },
-
- format : function (inputString) {
- var output = formatMoment(this, inputString || moment.defaultFormat);
- return this.localeData().postformat(output);
- },
-
- add : createAdder(1, 'add'),
-
- subtract : createAdder(-1, 'subtract'),
-
- diff : function (input, units, asFloat) {
- var that = makeAs(input, this),
- zoneDiff = (that.utcOffset() - this.utcOffset()) * 6e4,
- anchor, diff, output, daysAdjust;
-
- units = normalizeUnits(units);
-
- if (units === 'year' || units === 'month' || units === 'quarter') {
- output = monthDiff(this, that);
- if (units === 'quarter') {
- output = output / 3;
- } else if (units === 'year') {
- output = output / 12;
- }
- } else {
- diff = this - that;
- output = units === 'second' ? diff / 1e3 : // 1000
- units === 'minute' ? diff / 6e4 : // 1000 * 60
- units === 'hour' ? diff / 36e5 : // 1000 * 60 * 60
- units === 'day' ? (diff - zoneDiff) / 864e5 : // 1000 * 60 * 60 * 24, negate dst
- units === 'week' ? (diff - zoneDiff) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst
- diff;
- }
- return asFloat ? output : absRound(output);
- },
-
- from : function (time, withoutSuffix) {
- return moment.duration({to: this, from: time}).locale(this.locale()).humanize(!withoutSuffix);
- },
-
- fromNow : function (withoutSuffix) {
- return this.from(moment(), withoutSuffix);
- },
-
- calendar : function (time) {
- // We want to compare the start of today, vs this.
- // Getting start-of-today depends on whether we're locat/utc/offset
- // or not.
- var now = time || moment(),
- sod = makeAs(now, this).startOf('day'),
- diff = this.diff(sod, 'days', true),
- format = diff < -6 ? 'sameElse' :
- diff < -1 ? 'lastWeek' :
- diff < 0 ? 'lastDay' :
- diff < 1 ? 'sameDay' :
- diff < 2 ? 'nextDay' :
- diff < 7 ? 'nextWeek' : 'sameElse';
- return this.format(this.localeData().calendar(format, this, moment(now)));
- },
-
- isLeapYear : function () {
- return isLeapYear(this.year());
- },
-
- isDST : function () {
- return (this.utcOffset() > this.clone().month(0).utcOffset() ||
- this.utcOffset() > this.clone().month(5).utcOffset());
- },
-
- day : function (input) {
- var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
- if (input != null) {
- input = parseWeekday(input, this.localeData());
- return this.add(input - day, 'd');
- } else {
- return day;
- }
- },
-
- month : makeAccessor('Month', true),
-
- startOf : function (units) {
- units = normalizeUnits(units);
- // the following switch intentionally omits break keywords
- // to utilize falling through the cases.
- switch (units) {
- case 'year':
- this.month(0);
- /* falls through */
- case 'quarter':
- case 'month':
- this.date(1);
- /* falls through */
- case 'week':
- case 'isoWeek':
- case 'day':
- this.hours(0);
- /* falls through */
- case 'hour':
- this.minutes(0);
- /* falls through */
- case 'minute':
- this.seconds(0);
- /* falls through */
- case 'second':
- this.milliseconds(0);
- /* falls through */
- }
-
- // weeks are a special case
- if (units === 'week') {
- this.weekday(0);
- } else if (units === 'isoWeek') {
- this.isoWeekday(1);
- }
-
- // quarters are also special
- if (units === 'quarter') {
- this.month(Math.floor(this.month() / 3) * 3);
- }
-
- return this;
- },
-
- endOf: function (units) {
- units = normalizeUnits(units);
- if (units === undefined || units === 'millisecond') {
- return this;
- }
- return this.startOf(units).add(1, (units === 'isoWeek' ? 'week' : units)).subtract(1, 'ms');
- },
-
- isAfter: function (input, units) {
- var inputMs;
- units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond');
- if (units === 'millisecond') {
- input = moment.isMoment(input) ? input : moment(input);
- return +this > +input;
- } else {
- inputMs = moment.isMoment(input) ? +input : +moment(input);
- return inputMs < +this.clone().startOf(units);
- }
- },
-
- isBefore: function (input, units) {
- var inputMs;
- units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond');
- if (units === 'millisecond') {
- input = moment.isMoment(input) ? input : moment(input);
- return +this < +input;
- } else {
- inputMs = moment.isMoment(input) ? +input : +moment(input);
- return +this.clone().endOf(units) < inputMs;
- }
- },
-
- isBetween: function (from, to, units) {
- return this.isAfter(from, units) && this.isBefore(to, units);
- },
-
- isSame: function (input, units) {
- var inputMs;
- units = normalizeUnits(units || 'millisecond');
- if (units === 'millisecond') {
- input = moment.isMoment(input) ? input : moment(input);
- return +this === +input;
- } else {
- inputMs = +moment(input);
- return +(this.clone().startOf(units)) <= inputMs && inputMs <= +(this.clone().endOf(units));
- }
- },
-
- min: deprecate(
- 'moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548',
- function (other) {
- other = moment.apply(null, arguments);
- return other < this ? this : other;
- }
- ),
-
- max: deprecate(
- 'moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548',
- function (other) {
- other = moment.apply(null, arguments);
- return other > this ? this : other;
- }
- ),
-
- zone : deprecate(
- 'moment().zone is deprecated, use moment().utcOffset instead. ' +
- 'https://github.com/moment/moment/issues/1779',
- function (input, keepLocalTime) {
- if (input != null) {
- if (typeof input !== 'string') {
- input = -input;
- }
-
- this.utcOffset(input, keepLocalTime);
-
- return this;
- } else {
- return -this.utcOffset();
- }
- }
- ),
-
- // keepLocalTime = true means only change the timezone, without
- // affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]-->
- // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset
- // +0200, so we adjust the time as needed, to be valid.
- //
- // Keeping the time actually adds/subtracts (one hour)
- // from the actual represented time. That is why we call updateOffset
- // a second time. In case it wants us to change the offset again
- // _changeInProgress == true case, then we have to adjust, because
- // there is no such time in the given timezone.
- utcOffset : function (input, keepLocalTime) {
- var offset = this._offset || 0,
- localAdjust;
- if (input != null) {
- if (typeof input === 'string') {
- input = utcOffsetFromString(input);
- }
- if (Math.abs(input) < 16) {
- input = input * 60;
- }
- if (!this._isUTC && keepLocalTime) {
- localAdjust = this._dateUtcOffset();
- }
- this._offset = input;
- this._isUTC = true;
- if (localAdjust != null) {
- this.add(localAdjust, 'm');
- }
- if (offset !== input) {
- if (!keepLocalTime || this._changeInProgress) {
- addOrSubtractDurationFromMoment(this,
- moment.duration(input - offset, 'm'), 1, false);
- } else if (!this._changeInProgress) {
- this._changeInProgress = true;
- moment.updateOffset(this, true);
- this._changeInProgress = null;
- }
- }
-
- return this;
- } else {
- return this._isUTC ? offset : this._dateUtcOffset();
- }
- },
-
- isLocal : function () {
- return !this._isUTC;
- },
-
- isUtcOffset : function () {
- return this._isUTC;
- },
-
- isUtc : function () {
- return this._isUTC && this._offset === 0;
- },
-
- zoneAbbr : function () {
- return this._isUTC ? 'UTC' : '';
- },
-
- zoneName : function () {
- return this._isUTC ? 'Coordinated Universal Time' : '';
- },
-
- parseZone : function () {
- if (this._tzm) {
- this.utcOffset(this._tzm);
- } else if (typeof this._i === 'string') {
- this.utcOffset(utcOffsetFromString(this._i));
- }
- return this;
- },
-
- hasAlignedHourOffset : function (input) {
- if (!input) {
- input = 0;
- }
- else {
- input = moment(input).utcOffset();
- }
-
- return (this.utcOffset() - input) % 60 === 0;
- },
-
- daysInMonth : function () {
- return daysInMonth(this.year(), this.month());
- },
-
- dayOfYear : function (input) {
- var dayOfYear = round((moment(this).startOf('day') - moment(this).startOf('year')) / 864e5) + 1;
- return input == null ? dayOfYear : this.add((input - dayOfYear), 'd');
- },
-
- quarter : function (input) {
- return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3);
- },
-
- weekYear : function (input) {
- var year = weekOfYear(this, this.localeData()._week.dow, this.localeData()._week.doy).year;
- return input == null ? year : this.add((input - year), 'y');
- },
-
- isoWeekYear : function (input) {
- var year = weekOfYear(this, 1, 4).year;
- return input == null ? year : this.add((input - year), 'y');
- },
-
- week : function (input) {
- var week = this.localeData().week(this);
- return input == null ? week : this.add((input - week) * 7, 'd');
- },
-
- isoWeek : function (input) {
- var week = weekOfYear(this, 1, 4).week;
- return input == null ? week : this.add((input - week) * 7, 'd');
- },
-
- weekday : function (input) {
- var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7;
- return input == null ? weekday : this.add(input - weekday, 'd');
- },
-
- isoWeekday : function (input) {
- // behaves the same as moment#day except
- // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6)
- // as a setter, sunday should belong to the previous week.
- return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7);
- },
-
- isoWeeksInYear : function () {
- return weeksInYear(this.year(), 1, 4);
- },
-
- weeksInYear : function () {
- var weekInfo = this.localeData()._week;
- return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy);
- },
-
- get : function (units) {
- units = normalizeUnits(units);
- return this[units]();
- },
-
- set : function (units, value) {
- var unit;
- if (typeof units === 'object') {
- for (unit in units) {
- this.set(unit, units[unit]);
- }
- }
- else {
- units = normalizeUnits(units);
- if (typeof this[units] === 'function') {
- this[units](value);
- }
- }
- return this;
- },
-
- // If passed a locale key, it will set the locale for this
- // instance. Otherwise, it will return the locale configuration
- // variables for this instance.
- locale : function (key) {
- var newLocaleData;
-
- if (key === undefined) {
- return this._locale._abbr;
- } else {
- newLocaleData = moment.localeData(key);
- if (newLocaleData != null) {
- this._locale = newLocaleData;
- }
- return this;
- }
- },
-
- lang : deprecate(
- 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.',
- function (key) {
- if (key === undefined) {
- return this.localeData();
- } else {
- return this.locale(key);
- }
- }
- ),
-
- localeData : function () {
- return this._locale;
- },
-
- _dateUtcOffset : function () {
- // On Firefox.24 Date#getTimezoneOffset returns a floating point.
- // https://github.com/moment/moment/pull/1871
- return -Math.round(this._d.getTimezoneOffset() / 15) * 15;
- }
-
- });
-
- function rawMonthSetter(mom, value) {
- var dayOfMonth;
-
- // TODO: Move this out of here!
- if (typeof value === 'string') {
- value = mom.localeData().monthsParse(value);
- // TODO: Another silent failure?
- if (typeof value !== 'number') {
- return mom;
- }
- }
-
- dayOfMonth = Math.min(mom.date(),
- daysInMonth(mom.year(), value));
- mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth);
- return mom;
- }
-
- function rawGetter(mom, unit) {
- return mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]();
- }
-
- function rawSetter(mom, unit, value) {
- if (unit === 'Month') {
- return rawMonthSetter(mom, value);
- } else {
- return mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value);
- }
- }
-
- function makeAccessor(unit, keepTime) {
- return function (value) {
- if (value != null) {
- rawSetter(this, unit, value);
- moment.updateOffset(this, keepTime);
- return this;
- } else {
- return rawGetter(this, unit);
- }
- };
- }
-
- moment.fn.millisecond = moment.fn.milliseconds = makeAccessor('Milliseconds', false);
- moment.fn.second = moment.fn.seconds = makeAccessor('Seconds', false);
- moment.fn.minute = moment.fn.minutes = makeAccessor('Minutes', false);
- // Setting the hour should keep the time, because the user explicitly
- // specified which hour he wants. So trying to maintain the same hour (in
- // a new timezone) makes sense. Adding/subtracting hours does not follow
- // this rule.
- moment.fn.hour = moment.fn.hours = makeAccessor('Hours', true);
- // moment.fn.month is defined separately
- moment.fn.date = makeAccessor('Date', true);
- moment.fn.dates = deprecate('dates accessor is deprecated. Use date instead.', makeAccessor('Date', true));
- moment.fn.year = makeAccessor('FullYear', true);
- moment.fn.years = deprecate('years accessor is deprecated. Use year instead.', makeAccessor('FullYear', true));
-
- // add plural methods
- moment.fn.days = moment.fn.day;
- moment.fn.months = moment.fn.month;
- moment.fn.weeks = moment.fn.week;
- moment.fn.isoWeeks = moment.fn.isoWeek;
- moment.fn.quarters = moment.fn.quarter;
-
- // add aliased format methods
- moment.fn.toJSON = moment.fn.toISOString;
-
- // alias isUtc for dev-friendliness
- moment.fn.isUTC = moment.fn.isUtc;
-
- /************************************
- Duration Prototype
- ************************************/
-
-
- function daysToYears (days) {
- // 400 years have 146097 days (taking into account leap year rules)
- return days * 400 / 146097;
- }
-
- function yearsToDays (years) {
- // years * 365 + absRound(years / 4) -
- // absRound(years / 100) + absRound(years / 400);
- return years * 146097 / 400;
- }
-
- extend(moment.duration.fn = Duration.prototype, {
-
- _bubble : function () {
- var milliseconds = this._milliseconds,
- days = this._days,
- months = this._months,
- data = this._data,
- seconds, minutes, hours, years = 0;
-
- // The following code bubbles up values, see the tests for
- // examples of what that means.
- data.milliseconds = milliseconds % 1000;
-
- seconds = absRound(milliseconds / 1000);
- data.seconds = seconds % 60;
-
- minutes = absRound(seconds / 60);
- data.minutes = minutes % 60;
-
- hours = absRound(minutes / 60);
- data.hours = hours % 24;
-
- days += absRound(hours / 24);
-
- // Accurately convert days to years, assume start from year 0.
- years = absRound(daysToYears(days));
- days -= absRound(yearsToDays(years));
-
- // 30 days to a month
- // TODO (iskren): Use anchor date (like 1st Jan) to compute this.
- months += absRound(days / 30);
- days %= 30;
-
- // 12 months -> 1 year
- years += absRound(months / 12);
- months %= 12;
-
- data.days = days;
- data.months = months;
- data.years = years;
- },
-
- abs : function () {
- this._milliseconds = Math.abs(this._milliseconds);
- this._days = Math.abs(this._days);
- this._months = Math.abs(this._months);
-
- this._data.milliseconds = Math.abs(this._data.milliseconds);
- this._data.seconds = Math.abs(this._data.seconds);
- this._data.minutes = Math.abs(this._data.minutes);
- this._data.hours = Math.abs(this._data.hours);
- this._data.months = Math.abs(this._data.months);
- this._data.years = Math.abs(this._data.years);
-
- return this;
- },
-
- weeks : function () {
- return absRound(this.days() / 7);
- },
-
- valueOf : function () {
- return this._milliseconds +
- this._days * 864e5 +
- (this._months % 12) * 2592e6 +
- toInt(this._months / 12) * 31536e6;
- },
-
- humanize : function (withSuffix) {
- var output = relativeTime(this, !withSuffix, this.localeData());
-
- if (withSuffix) {
- output = this.localeData().pastFuture(+this, output);
- }
-
- return this.localeData().postformat(output);
- },
-
- add : function (input, val) {
- // supports only 2.0-style add(1, 's') or add(moment)
- var dur = moment.duration(input, val);
-
- this._milliseconds += dur._milliseconds;
- this._days += dur._days;
- this._months += dur._months;
-
- this._bubble();
-
- return this;
- },
-
- subtract : function (input, val) {
- var dur = moment.duration(input, val);
-
- this._milliseconds -= dur._milliseconds;
- this._days -= dur._days;
- this._months -= dur._months;
-
- this._bubble();
-
- return this;
- },
-
- get : function (units) {
- units = normalizeUnits(units);
- return this[units.toLowerCase() + 's']();
- },
-
- as : function (units) {
- var days, months;
- units = normalizeUnits(units);
-
- if (units === 'month' || units === 'year') {
- days = this._days + this._milliseconds / 864e5;
- months = this._months + daysToYears(days) * 12;
- return units === 'month' ? months : months / 12;
- } else {
- // handle milliseconds separately because of floating point math errors (issue #1867)
- days = this._days + Math.round(yearsToDays(this._months / 12));
- switch (units) {
- case 'week': return days / 7 + this._milliseconds / 6048e5;
- case 'day': return days + this._milliseconds / 864e5;
- case 'hour': return days * 24 + this._milliseconds / 36e5;
- case 'minute': return days * 24 * 60 + this._milliseconds / 6e4;
- case 'second': return days * 24 * 60 * 60 + this._milliseconds / 1000;
- // Math.floor prevents floating point math errors here
- case 'millisecond': return Math.floor(days * 24 * 60 * 60 * 1000) + this._milliseconds;
- default: throw new Error('Unknown unit ' + units);
- }
- }
- },
-
- lang : moment.fn.lang,
- locale : moment.fn.locale,
-
- toIsoString : deprecate(
- 'toIsoString() is deprecated. Please use toISOString() instead ' +
- '(notice the capitals)',
- function () {
- return this.toISOString();
- }
- ),
-
- toISOString : function () {
- // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js
- var years = Math.abs(this.years()),
- months = Math.abs(this.months()),
- days = Math.abs(this.days()),
- hours = Math.abs(this.hours()),
- minutes = Math.abs(this.minutes()),
- seconds = Math.abs(this.seconds() + this.milliseconds() / 1000);
-
- if (!this.asSeconds()) {
- // this is the same as C#'s (Noda) and python (isodate)...
- // but not other JS (goog.date)
- return 'P0D';
- }
-
- return (this.asSeconds() < 0 ? '-' : '') +
- 'P' +
- (years ? years + 'Y' : '') +
- (months ? months + 'M' : '') +
- (days ? days + 'D' : '') +
- ((hours || minutes || seconds) ? 'T' : '') +
- (hours ? hours + 'H' : '') +
- (minutes ? minutes + 'M' : '') +
- (seconds ? seconds + 'S' : '');
- },
-
- localeData : function () {
- return this._locale;
- },
-
- toJSON : function () {
- return this.toISOString();
- }
- });
-
- moment.duration.fn.toString = moment.duration.fn.toISOString;
-
- function makeDurationGetter(name) {
- moment.duration.fn[name] = function () {
- return this._data[name];
- };
- }
-
- for (i in unitMillisecondFactors) {
- if (hasOwnProp(unitMillisecondFactors, i)) {
- makeDurationGetter(i.toLowerCase());
- }
- }
-
- moment.duration.fn.asMilliseconds = function () {
- return this.as('ms');
- };
- moment.duration.fn.asSeconds = function () {
- return this.as('s');
- };
- moment.duration.fn.asMinutes = function () {
- return this.as('m');
- };
- moment.duration.fn.asHours = function () {
- return this.as('h');
- };
- moment.duration.fn.asDays = function () {
- return this.as('d');
- };
- moment.duration.fn.asWeeks = function () {
- return this.as('weeks');
- };
- moment.duration.fn.asMonths = function () {
- return this.as('M');
- };
- moment.duration.fn.asYears = function () {
- return this.as('y');
- };
-
- /************************************
- Default Locale
- ************************************/
-
-
- // Set default locale, other locale will inherit from English.
- moment.locale('en', {
- ordinalParse: /\d{1,2}(th|st|nd|rd)/,
- ordinal : function (number) {
- var b = number % 10,
- output = (toInt(number % 100 / 10) === 1) ? 'th' :
- (b === 1) ? 'st' :
- (b === 2) ? 'nd' :
- (b === 3) ? 'rd' : 'th';
- return number + output;
- }
- });
-
- /* EMBED_LOCALES */
-
- /************************************
- Exposing Moment
- ************************************/
-
- function makeGlobal(shouldDeprecate) {
- /*global ender:false */
- if (typeof ender !== 'undefined') {
- return;
- }
- oldGlobalMoment = globalScope.moment;
- if (shouldDeprecate) {
- globalScope.moment = deprecate(
- 'Accessing Moment through the global scope is ' +
- 'deprecated, and will be removed in an upcoming ' +
- 'release.',
- moment);
- } else {
- globalScope.moment = moment;
- }
- }
-
- // CommonJS module is defined
- if (hasModule) {
- module.exports = moment;
- } else if (typeof define === 'function' && define.amd) {
- define(function (require, exports, module) {
- if (module.config && module.config() && module.config().noGlobal === true) {
- // release the global variable
- globalScope.moment = oldGlobalMoment;
- }
-
- return moment;
- });
- makeGlobal(true);
- } else {
- makeGlobal();
- }
-}).call(this);
\ No newline at end of file
+(function(a){function b(a,b,c){switch(arguments.length){case 2:return null!=a?a:b;case 3:return null!=a?a:null!=b?b:c;default:throw new Error("Implement me")}}function c(a,b){return Bb.call(a,b)}function d(){return{empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1}}function e(a){vb.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+a)}function f(a,b){var c=!0;return o(function(){return c&&(e(a),c=!1),b.apply(this,arguments)},b)}function g(a,b){sc[a]||(e(b),sc[a]=!0)}function h(a,b){return function(c){return r(a.call(this,c),b)}}function i(a,b){return function(c){return this.localeData().ordinal(a.call(this,c),b)}}function j(a,b){var c,d,e=12*(b.year()-a.year())+(b.month()-a.month()),f=a.clone().add(e,"months");return 0>b-f?(c=a.clone().add(e-1,"months"),d=(b-f)/(f-c)):(c=a.clone().add(e+1,"months"),d=(b-f)/(c-f)),-(e+d)}function k(a,b,c){var d;return null==c?b:null!=a.meridiemHour?a.meridiemHour(b,c):null!=a.isPM?(d=a.isPM(c),d&&12>b&&(b+=12),d||12!==b||(b=0),b):b}function l(){}function m(a,b){b!==!1&&H(a),p(this,a),this._d=new Date(+a._d),uc===!1&&(uc=!0,vb.updateOffset(this),uc=!1)}function n(a){var b=A(a),c=b.year||0,d=b.quarter||0,e=b.month||0,f=b.week||0,g=b.day||0,h=b.hour||0,i=b.minute||0,j=b.second||0,k=b.millisecond||0;this._milliseconds=+k+1e3*j+6e4*i+36e5*h,this._days=+g+7*f,this._months=+e+3*d+12*c,this._data={},this._locale=vb.localeData(),this._bubble()}function o(a,b){for(var d in b)c(b,d)&&(a[d]=b[d]);return c(b,"toString")&&(a.toString=b.toString),c(b,"valueOf")&&(a.valueOf=b.valueOf),a}function p(a,b){var c,d,e;if("undefined"!=typeof b._isAMomentObject&&(a._isAMomentObject=b._isAMomentObject),"undefined"!=typeof b._i&&(a._i=b._i),"undefined"!=typeof b._f&&(a._f=b._f),"undefined"!=typeof b._l&&(a._l=b._l),"undefined"!=typeof b._strict&&(a._strict=b._strict),"undefined"!=typeof b._tzm&&(a._tzm=b._tzm),"undefined"!=typeof b._isUTC&&(a._isUTC=b._isUTC),"undefined"!=typeof b._offset&&(a._offset=b._offset),"undefined"!=typeof b._pf&&(a._pf=b._pf),"undefined"!=typeof b._locale&&(a._locale=b._locale),Kb.length>0)for(c in Kb)d=Kb[c],e=b[d],"undefined"!=typeof e&&(a[d]=e);return a}function q(a){return 0>a?Math.ceil(a):Math.floor(a)}function r(a,b,c){for(var d=""+Math.abs(a),e=a>=0;d.lengthd;d++)(c&&a[d]!==b[d]||!c&&C(a[d])!==C(b[d]))&&g++;return g+f}function z(a){if(a){var b=a.toLowerCase().replace(/(.)s$/,"$1");a=lc[a]||mc[b]||b}return a}function A(a){var b,d,e={};for(d in a)c(a,d)&&(b=z(d),b&&(e[b]=a[d]));return e}function B(b){var c,d;if(0===b.indexOf("week"))c=7,d="day";else{if(0!==b.indexOf("month"))return;c=12,d="month"}vb[b]=function(e,f){var g,h,i=vb._locale[b],j=[];if("number"==typeof e&&(f=e,e=a),h=function(a){var b=vb().utc().set(d,a);return i.call(vb._locale,b,e||"")},null!=f)return h(f);for(g=0;c>g;g++)j.push(h(g));return j}}function C(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=b>=0?Math.floor(b):Math.ceil(b)),c}function D(a,b){return new Date(Date.UTC(a,b+1,0)).getUTCDate()}function E(a,b,c){return jb(vb([a,11,31+b-c]),b,c).week}function F(a){return G(a)?366:365}function G(a){return a%4===0&&a%100!==0||a%400===0}function H(a){var b;a._a&&-2===a._pf.overflow&&(b=a._a[Db]<0||a._a[Db]>11?Db:a._a[Eb]<1||a._a[Eb]>D(a._a[Cb],a._a[Db])?Eb:a._a[Fb]<0||a._a[Fb]>24||24===a._a[Fb]&&(0!==a._a[Gb]||0!==a._a[Hb]||0!==a._a[Ib])?Fb:a._a[Gb]<0||a._a[Gb]>59?Gb:a._a[Hb]<0||a._a[Hb]>59?Hb:a._a[Ib]<0||a._a[Ib]>999?Ib:-1,a._pf._overflowDayOfYear&&(Cb>b||b>Eb)&&(b=Eb),a._pf.overflow=b)}function I(b){return null==b._isValid&&(b._isValid=!isNaN(b._d.getTime())&&b._pf.overflow<0&&!b._pf.empty&&!b._pf.invalidMonth&&!b._pf.nullInput&&!b._pf.invalidFormat&&!b._pf.userInvalidated,b._strict&&(b._isValid=b._isValid&&0===b._pf.charsLeftOver&&0===b._pf.unusedTokens.length&&b._pf.bigHour===a)),b._isValid}function J(a){return a?a.toLowerCase().replace("_","-"):a}function K(a){for(var b,c,d,e,f=0;f0;){if(d=L(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&y(e,c,!0)>=b-1)break;b--}f++}return null}function L(a){var b=null;if(!Jb[a]&&Lb)try{b=vb.locale(),require("./locale/"+a),vb.locale(b)}catch(c){}return Jb[a]}function M(a,b){var c,d;return b._isUTC?(c=b.clone(),d=(vb.isMoment(a)||x(a)?+a:+vb(a))-+c,c._d.setTime(+c._d+d),vb.updateOffset(c,!1),c):vb(a).local()}function N(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function O(a){var b,c,d=a.match(Pb);for(b=0,c=d.length;c>b;b++)d[b]=rc[d[b]]?rc[d[b]]:N(d[b]);return function(e){var f="";for(b=0;c>b;b++)f+=d[b]instanceof Function?d[b].call(e,a):d[b];return f}}function P(a,b){return a.isValid()?(b=Q(b,a.localeData()),nc[b]||(nc[b]=O(b)),nc[b](a)):a.localeData().invalidDate()}function Q(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(Qb.lastIndex=0;d>=0&&Qb.test(a);)a=a.replace(Qb,c),Qb.lastIndex=0,d-=1;return a}function R(a,b){var c,d=b._strict;switch(a){case"Q":return _b;case"DDDD":return bc;case"YYYY":case"GGGG":case"gggg":return d?cc:Tb;case"Y":case"G":case"g":return ec;case"YYYYYY":case"YYYYY":case"GGGGG":case"ggggg":return d?dc:Ub;case"S":if(d)return _b;case"SS":if(d)return ac;case"SSS":if(d)return bc;case"DDD":return Sb;case"MMM":case"MMMM":case"dd":case"ddd":case"dddd":return Wb;case"a":case"A":return b._locale._meridiemParse;case"x":return Zb;case"X":return $b;case"Z":case"ZZ":return Xb;case"T":return Yb;case"SSSS":return Vb;case"MM":case"DD":case"YY":case"GG":case"gg":case"HH":case"hh":case"mm":case"ss":case"ww":case"WW":return d?ac:Rb;case"M":case"D":case"d":case"H":case"h":case"m":case"s":case"w":case"W":case"e":case"E":return Rb;case"Do":return d?b._locale._ordinalParse:b._locale._ordinalParseLenient;default:return c=new RegExp($(Z(a.replace("\\","")),"i"))}}function S(a){a=a||"";var b=a.match(Xb)||[],c=b[b.length-1]||[],d=(c+"").match(jc)||["-",0,0],e=+(60*d[1])+C(d[2]);return"+"===d[0]?e:-e}function T(a,b,c){var d,e=c._a;switch(a){case"Q":null!=b&&(e[Db]=3*(C(b)-1));break;case"M":case"MM":null!=b&&(e[Db]=C(b)-1);break;case"MMM":case"MMMM":d=c._locale.monthsParse(b,a,c._strict),null!=d?e[Db]=d:c._pf.invalidMonth=b;break;case"D":case"DD":null!=b&&(e[Eb]=C(b));break;case"Do":null!=b&&(e[Eb]=C(parseInt(b.match(/\d{1,2}/)[0],10)));break;case"DDD":case"DDDD":null!=b&&(c._dayOfYear=C(b));break;case"YY":e[Cb]=vb.parseTwoDigitYear(b);break;case"YYYY":case"YYYYY":case"YYYYYY":e[Cb]=C(b);break;case"a":case"A":c._meridiem=b;break;case"h":case"hh":c._pf.bigHour=!0;case"H":case"HH":e[Fb]=C(b);break;case"m":case"mm":e[Gb]=C(b);break;case"s":case"ss":e[Hb]=C(b);break;case"S":case"SS":case"SSS":case"SSSS":e[Ib]=C(1e3*("0."+b));break;case"x":c._d=new Date(C(b));break;case"X":c._d=new Date(1e3*parseFloat(b));break;case"Z":case"ZZ":c._useUTC=!0,c._tzm=S(b);break;case"dd":case"ddd":case"dddd":d=c._locale.weekdaysParse(b),null!=d?(c._w=c._w||{},c._w.d=d):c._pf.invalidWeekday=b;break;case"w":case"ww":case"W":case"WW":case"d":case"e":case"E":a=a.substr(0,1);case"gggg":case"GGGG":case"GGGGG":a=a.substr(0,2),b&&(c._w=c._w||{},c._w[a]=C(b));break;case"gg":case"GG":c._w=c._w||{},c._w[a]=vb.parseTwoDigitYear(b)}}function U(a){var c,d,e,f,g,h,i;c=a._w,null!=c.GG||null!=c.W||null!=c.E?(g=1,h=4,d=b(c.GG,a._a[Cb],jb(vb(),1,4).year),e=b(c.W,1),f=b(c.E,1)):(g=a._locale._week.dow,h=a._locale._week.doy,d=b(c.gg,a._a[Cb],jb(vb(),g,h).year),e=b(c.w,1),null!=c.d?(f=c.d,g>f&&++e):f=null!=c.e?c.e+g:g),i=kb(d,e,f,h,g),a._a[Cb]=i.year,a._dayOfYear=i.dayOfYear}function V(a){var c,d,e,f,g=[];if(!a._d){for(e=X(a),a._w&&null==a._a[Eb]&&null==a._a[Db]&&U(a),a._dayOfYear&&(f=b(a._a[Cb],e[Cb]),a._dayOfYear>F(f)&&(a._pf._overflowDayOfYear=!0),d=fb(f,0,a._dayOfYear),a._a[Db]=d.getUTCMonth(),a._a[Eb]=d.getUTCDate()),c=0;3>c&&null==a._a[c];++c)a._a[c]=g[c]=e[c];for(;7>c;c++)a._a[c]=g[c]=null==a._a[c]?2===c?1:0:a._a[c];24===a._a[Fb]&&0===a._a[Gb]&&0===a._a[Hb]&&0===a._a[Ib]&&(a._nextDay=!0,a._a[Fb]=0),a._d=(a._useUTC?fb:eb).apply(null,g),null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()-a._tzm),a._nextDay&&(a._a[Fb]=24)}}function W(a){var b;a._d||(b=A(a._i),a._a=[b.year,b.month,b.day||b.date,b.hour,b.minute,b.second,b.millisecond],V(a))}function X(a){var b=new Date;return a._useUTC?[b.getUTCFullYear(),b.getUTCMonth(),b.getUTCDate()]:[b.getFullYear(),b.getMonth(),b.getDate()]}function Y(b){if(b._f===vb.ISO_8601)return void ab(b);b._a=[],b._pf.empty=!0;var c,d,e,f,g,h=""+b._i,i=h.length,j=0;for(e=Q(b._f,b._locale).match(Pb)||[],c=0;c0&&b._pf.unusedInput.push(g),h=h.slice(h.indexOf(d)+d.length),j+=d.length),rc[f]?(d?b._pf.empty=!1:b._pf.unusedTokens.push(f),T(f,d,b)):b._strict&&!d&&b._pf.unusedTokens.push(f);b._pf.charsLeftOver=i-j,h.length>0&&b._pf.unusedInput.push(h),b._pf.bigHour===!0&&b._a[Fb]<=12&&(b._pf.bigHour=a),b._a[Fb]=k(b._locale,b._a[Fb],b._meridiem),V(b),H(b)}function Z(a){return a.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e})}function $(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function _(a){var b,c,e,f,g;if(0===a._f.length)return a._pf.invalidFormat=!0,void(a._d=new Date(0/0));for(f=0;fg)&&(e=g,c=b));o(a,c||b)}function ab(a){var b,c,d=a._i,e=fc.exec(d);if(e){for(a._pf.iso=!0,b=0,c=hc.length;c>b;b++)if(hc[b][1].exec(d)){a._f=hc[b][0]+(e[6]||" ");break}for(b=0,c=ic.length;c>b;b++)if(ic[b][1].exec(d)){a._f+=ic[b][0];break}d.match(Xb)&&(a._f+="Z"),Y(a)}else a._isValid=!1}function bb(a){ab(a),a._isValid===!1&&(delete a._isValid,vb.createFromInputFallback(a))}function cb(a,b){var c,d=[];for(c=0;ca&&h.setFullYear(a),h}function fb(a){var b=new Date(Date.UTC.apply(null,arguments));return 1970>a&&b.setUTCFullYear(a),b}function gb(a,b){if("string"==typeof a)if(isNaN(a)){if(a=b.weekdaysParse(a),"number"!=typeof a)return null}else a=parseInt(a,10);return a}function hb(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function ib(a,b,c){var d=vb.duration(a).abs(),e=Ab(d.as("s")),f=Ab(d.as("m")),g=Ab(d.as("h")),h=Ab(d.as("d")),i=Ab(d.as("M")),j=Ab(d.as("y")),k=e0,k[4]=c,hb.apply({},k)}function jb(a,b,c){var d,e=c-b,f=c-a.day();return f>e&&(f-=7),e-7>f&&(f+=7),d=vb(a).add(f,"d"),{week:Math.ceil(d.dayOfYear()/7),year:d.year()}}function kb(a,b,c,d,e){var f,g,h=fb(a,0,1).getUTCDay();return h=0===h?7:h,c=null!=c?c:e,f=e-h+(h>d?7:0)-(e>h?7:0),g=7*(b-1)+(c-e)+f+1,{year:g>0?a:a-1,dayOfYear:g>0?g:F(a-1)+g}}function lb(b){var c,d=b._i,e=b._f;return b._locale=b._locale||vb.localeData(b._l),null===d||e===a&&""===d?vb.invalid({nullInput:!0}):("string"==typeof d&&(b._i=d=b._locale.preparse(d)),vb.isMoment(d)?new m(d,!0):(e?w(e)?_(b):Y(b):db(b),c=new m(b),c._nextDay&&(c.add(1,"d"),c._nextDay=a),c))}function mb(a,b){var c,d;if(1===b.length&&w(b[0])&&(b=b[0]),!b.length)return vb();for(c=b[0],d=1;d=0?"+":"-";return b+r(Math.abs(a),6)},gg:function(){return r(this.weekYear()%100,2)},gggg:function(){return r(this.weekYear(),4)},ggggg:function(){return r(this.weekYear(),5)},GG:function(){return r(this.isoWeekYear()%100,2)},GGGG:function(){return r(this.isoWeekYear(),4)},GGGGG:function(){return r(this.isoWeekYear(),5)},e:function(){return this.weekday()},E:function(){return this.isoWeekday()},a:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!0)},A:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!1)},H:function(){return this.hours()},h:function(){return this.hours()%12||12},m:function(){return this.minutes()},s:function(){return this.seconds()},S:function(){return C(this.milliseconds()/100)},SS:function(){return r(C(this.milliseconds()/10),2)},SSS:function(){return r(this.milliseconds(),3)},SSSS:function(){return r(this.milliseconds(),3)},Z:function(){var a=this.utcOffset(),b="+";return 0>a&&(a=-a,b="-"),b+r(C(a/60),2)+":"+r(C(a)%60,2)},ZZ:function(){var a=this.utcOffset(),b="+";return 0>a&&(a=-a,b="-"),b+r(C(a/60),2)+r(C(a)%60,2)},z:function(){return this.zoneAbbr()},zz:function(){return this.zoneName()},x:function(){return this.valueOf()},X:function(){return this.unix()},Q:function(){return this.quarter()}},sc={},tc=["months","monthsShort","weekdays","weekdaysShort","weekdaysMin"],uc=!1;pc.length;)xb=pc.pop(),rc[xb+"o"]=i(rc[xb],xb);for(;qc.length;)xb=qc.pop(),rc[xb+xb]=h(rc[xb],2);rc.DDDD=h(rc.DDD,3),o(l.prototype,{set:function(a){var b,c;for(c in a)b=a[c],"function"==typeof b?this[c]=b:this["_"+c]=b;this._ordinalParseLenient=new RegExp(this._ordinalParse.source+"|"+/\d{1,2}/.source)},_months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),months:function(a){return this._months[a.month()]},_monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),monthsShort:function(a){return this._monthsShort[a.month()]},monthsParse:function(a,b,c){var d,e,f;for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),d=0;12>d;d++){if(e=vb.utc([2e3,d]),c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp("^"+this.months(e,"").replace(".","")+"$","i"),this._shortMonthsParse[d]=new RegExp("^"+this.monthsShort(e,"").replace(".","")+"$","i")),c||this._monthsParse[d]||(f="^"+this.months(e,"")+"|^"+this.monthsShort(e,""),this._monthsParse[d]=new RegExp(f.replace(".",""),"i")),c&&"MMMM"===b&&this._longMonthsParse[d].test(a))return d;if(c&&"MMM"===b&&this._shortMonthsParse[d].test(a))return d;if(!c&&this._monthsParse[d].test(a))return d}},_weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdays:function(a){return this._weekdays[a.day()]},_weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysShort:function(a){return this._weekdaysShort[a.day()]},_weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),weekdaysMin:function(a){return this._weekdaysMin[a.day()]},weekdaysParse:function(a){var b,c,d;for(this._weekdaysParse||(this._weekdaysParse=[]),b=0;7>b;b++)if(this._weekdaysParse[b]||(c=vb([2e3,1]).day(b),d="^"+this.weekdays(c,"")+"|^"+this.weekdaysShort(c,"")+"|^"+this.weekdaysMin(c,""),this._weekdaysParse[b]=new RegExp(d.replace(".",""),"i")),this._weekdaysParse[b].test(a))return b},_longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY LT",LLLL:"dddd, MMMM D, YYYY LT"},longDateFormat:function(a){var b=this._longDateFormat[a];return!b&&this._longDateFormat[a.toUpperCase()]&&(b=this._longDateFormat[a.toUpperCase()].replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a]=b),b},isPM:function(a){return"p"===(a+"").toLowerCase().charAt(0)},_meridiemParse:/[ap]\.?m?\.?/i,meridiem:function(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"},_calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},calendar:function(a,b,c){var d=this._calendar[a];return"function"==typeof d?d.apply(b,[c]):d},_relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},relativeTime:function(a,b,c,d){var e=this._relativeTime[c];return"function"==typeof e?e(a,b,c,d):e.replace(/%d/i,a)},pastFuture:function(a,b){var c=this._relativeTime[a>0?"future":"past"];return"function"==typeof c?c(b):c.replace(/%s/i,b)},ordinal:function(a){return this._ordinal.replace("%d",a)},_ordinal:"%d",_ordinalParse:/\d{1,2}/,preparse:function(a){return a},postformat:function(a){return a},week:function(a){return jb(a,this._week.dow,this._week.doy).week},_week:{dow:0,doy:6},firstDayOfWeek:function(){return this._week.dow},firstDayOfYear:function(){return this._week.doy},_invalidDate:"Invalid date",invalidDate:function(){return this._invalidDate}}),vb=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._i=b,g._f=c,g._l=e,g._strict=f,g._isUTC=!1,g._pf=d(),lb(g)},vb.suppressDeprecationWarnings=!1,vb.createFromInputFallback=f("moment construction falls back to js Date. This is discouraged and will be removed in upcoming major release. Please refer to https://github.com/moment/moment/issues/1407 for more info.",function(a){a._d=new Date(a._i+(a._useUTC?" UTC":""))}),vb.min=function(){var a=[].slice.call(arguments,0);return mb("isBefore",a)},vb.max=function(){var a=[].slice.call(arguments,0);return mb("isAfter",a)},vb.utc=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._useUTC=!0,g._isUTC=!0,g._l=e,g._i=b,g._f=c,g._strict=f,g._pf=d(),lb(g).utc()},vb.unix=function(a){return vb(1e3*a)},vb.duration=function(a,b){var d,e,f,g,h=a,i=null;return vb.isDuration(a)?h={ms:a._milliseconds,d:a._days,M:a._months}:"number"==typeof a?(h={},b?h[b]=a:h.milliseconds=a):(i=Nb.exec(a))?(d="-"===i[1]?-1:1,h={y:0,d:C(i[Eb])*d,h:C(i[Fb])*d,m:C(i[Gb])*d,s:C(i[Hb])*d,ms:C(i[Ib])*d}):(i=Ob.exec(a))?(d="-"===i[1]?-1:1,f=function(a){var b=a&&parseFloat(a.replace(",","."));return(isNaN(b)?0:b)*d},h={y:f(i[2]),M:f(i[3]),d:f(i[4]),h:f(i[5]),m:f(i[6]),s:f(i[7]),w:f(i[8])}):null==h?h={}:"object"==typeof h&&("from"in h||"to"in h)&&(g=t(vb(h.from),vb(h.to)),h={},h.ms=g.milliseconds,h.M=g.months),e=new n(h),vb.isDuration(a)&&c(a,"_locale")&&(e._locale=a._locale),e},vb.version=yb,vb.defaultFormat=gc,vb.ISO_8601=function(){},vb.momentProperties=Kb,vb.updateOffset=function(){},vb.relativeTimeThreshold=function(b,c){return oc[b]===a?!1:c===a?oc[b]:(oc[b]=c,!0)},vb.lang=f("moment.lang is deprecated. Use moment.locale instead.",function(a,b){return vb.locale(a,b)}),vb.locale=function(a,b){var c;return a&&(c="undefined"!=typeof b?vb.defineLocale(a,b):vb.localeData(a),c&&(vb.duration._locale=vb._locale=c)),vb._locale._abbr},vb.defineLocale=function(a,b){return null!==b?(b.abbr=a,Jb[a]||(Jb[a]=new l),Jb[a].set(b),vb.locale(a),Jb[a]):(delete Jb[a],null)},vb.langData=f("moment.langData is deprecated. Use moment.localeData instead.",function(a){return vb.localeData(a)}),vb.localeData=function(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return vb._locale;if(!w(a)){if(b=L(a))return b;a=[a]}return K(a)},vb.isMoment=function(a){return a instanceof m||null!=a&&c(a,"_isAMomentObject")},vb.isDuration=function(a){return a instanceof n};for(xb=tc.length-1;xb>=0;--xb)B(tc[xb]);vb.normalizeUnits=function(a){return z(a)},vb.invalid=function(a){var b=vb.utc(0/0);return null!=a?o(b._pf,a):b._pf.userInvalidated=!0,b},vb.parseZone=function(){return vb.apply(null,arguments).parseZone()},vb.parseTwoDigitYear=function(a){return C(a)+(C(a)>68?1900:2e3)},vb.isDate=x,o(vb.fn=m.prototype,{clone:function(){return vb(this)},valueOf:function(){return+this._d-6e4*(this._offset||0)},unix:function(){return Math.floor(+this/1e3)},toString:function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},toDate:function(){return this._offset?new Date(+this):this._d},toISOString:function(){var a=vb(this).utc();return 00:!1},parsingFlags:function(){return o({},this._pf)},invalidAt:function(){return this._pf.overflow},utc:function(a){return this.utcOffset(0,a)},local:function(a){return this._isUTC&&(this.utcOffset(0,a),this._isUTC=!1,a&&this.subtract(this._dateUtcOffset(),"m")),this},format:function(a){var b=P(this,a||vb.defaultFormat);return this.localeData().postformat(b)},add:u(1,"add"),subtract:u(-1,"subtract"),diff:function(a,b,c){var d,e,f=M(a,this),g=6e4*(f.utcOffset()-this.utcOffset());return b=z(b),"year"===b||"month"===b||"quarter"===b?(e=j(this,f),"quarter"===b?e/=3:"year"===b&&(e/=12)):(d=this-f,e="second"===b?d/1e3:"minute"===b?d/6e4:"hour"===b?d/36e5:"day"===b?(d-g)/864e5:"week"===b?(d-g)/6048e5:d),c?e:q(e)},from:function(a,b){return vb.duration({to:this,from:a}).locale(this.locale()).humanize(!b)},fromNow:function(a){return this.from(vb(),a)},calendar:function(a){var b=a||vb(),c=M(b,this).startOf("day"),d=this.diff(c,"days",!0),e=-6>d?"sameElse":-1>d?"lastWeek":0>d?"lastDay":1>d?"sameDay":2>d?"nextDay":7>d?"nextWeek":"sameElse";return this.format(this.localeData().calendar(e,this,vb(b)))},isLeapYear:function(){return G(this.year())},isDST:function(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},day:function(a){var b=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=a?(a=gb(a,this.localeData()),this.add(a-b,"d")):b},month:qb("Month",!0),startOf:function(a){switch(a=z(a)){case"year":this.month(0);case"quarter":case"month":this.date(1);case"week":case"isoWeek":case"day":this.hours(0);case"hour":this.minutes(0);case"minute":this.seconds(0);case"second":this.milliseconds(0)}return"week"===a?this.weekday(0):"isoWeek"===a&&this.isoWeekday(1),"quarter"===a&&this.month(3*Math.floor(this.month()/3)),this},endOf:function(b){return b=z(b),b===a||"millisecond"===b?this:this.startOf(b).add(1,"isoWeek"===b?"week":b).subtract(1,"ms")},isAfter:function(a,b){var c;return b=z("undefined"!=typeof b?b:"millisecond"),"millisecond"===b?(a=vb.isMoment(a)?a:vb(a),+this>+a):(c=vb.isMoment(a)?+a:+vb(a),c<+this.clone().startOf(b))},isBefore:function(a,b){var c;return b=z("undefined"!=typeof b?b:"millisecond"),"millisecond"===b?(a=vb.isMoment(a)?a:vb(a),+a>+this):(c=vb.isMoment(a)?+a:+vb(a),+this.clone().endOf(b)a?this:a}),max:f("moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548",function(a){return a=vb.apply(null,arguments),a>this?this:a}),zone:f("moment().zone is deprecated, use moment().utcOffset instead. https://github.com/moment/moment/issues/1779",function(a,b){return null!=a?("string"!=typeof a&&(a=-a),this.utcOffset(a,b),this):-this.utcOffset()}),utcOffset:function(a,b){var c,d=this._offset||0;return null!=a?("string"==typeof a&&(a=S(a)),Math.abs(a)<16&&(a=60*a),!this._isUTC&&b&&(c=this._dateUtcOffset()),this._offset=a,this._isUTC=!0,null!=c&&this.add(c,"m"),d!==a&&(!b||this._changeInProgress?v(this,vb.duration(a-d,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,vb.updateOffset(this,!0),this._changeInProgress=null)),this):this._isUTC?d:this._dateUtcOffset()},isLocal:function(){return!this._isUTC},isUtcOffset:function(){return this._isUTC},isUtc:function(){return this._isUTC&&0===this._offset},zoneAbbr:function(){return this._isUTC?"UTC":""},zoneName:function(){return this._isUTC?"Coordinated Universal Time":""},parseZone:function(){return this._tzm?this.utcOffset(this._tzm):"string"==typeof this._i&&this.utcOffset(S(this._i)),this},hasAlignedHourOffset:function(a){return a=a?vb(a).utcOffset():0,(this.utcOffset()-a)%60===0},daysInMonth:function(){return D(this.year(),this.month())},dayOfYear:function(a){var b=Ab((vb(this).startOf("day")-vb(this).startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")},quarter:function(a){return null==a?Math.ceil((this.month()+1)/3):this.month(3*(a-1)+this.month()%3)},weekYear:function(a){var b=jb(this,this.localeData()._week.dow,this.localeData()._week.doy).year;return null==a?b:this.add(a-b,"y")},isoWeekYear:function(a){var b=jb(this,1,4).year;return null==a?b:this.add(a-b,"y")},week:function(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")},isoWeek:function(a){var b=jb(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")},weekday:function(a){var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")},isoWeekday:function(a){return null==a?this.day()||7:this.day(this.day()%7?a:a-7)},isoWeeksInYear:function(){return E(this.year(),1,4)},weeksInYear:function(){var a=this.localeData()._week;return E(this.year(),a.dow,a.doy)},get:function(a){return a=z(a),this[a]()},set:function(a,b){var c;if("object"==typeof a)for(c in a)this.set(c,a[c]);else a=z(a),"function"==typeof this[a]&&this[a](b);return this},locale:function(b){var c;return b===a?this._locale._abbr:(c=vb.localeData(b),null!=c&&(this._locale=c),this)},lang:f("moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.",function(b){return b===a?this.localeData():this.locale(b)}),localeData:function(){return this._locale},_dateUtcOffset:function(){return 15*-Math.round(this._d.getTimezoneOffset()/15)}}),vb.fn.millisecond=vb.fn.milliseconds=qb("Milliseconds",!1),vb.fn.second=vb.fn.seconds=qb("Seconds",!1),vb.fn.minute=vb.fn.minutes=qb("Minutes",!1),vb.fn.hour=vb.fn.hours=qb("Hours",!0),vb.fn.date=qb("Date",!0),vb.fn.dates=f("dates accessor is deprecated. Use date instead.",qb("Date",!0)),vb.fn.year=qb("FullYear",!0),vb.fn.years=f("years accessor is deprecated. Use year instead.",qb("FullYear",!0)),vb.fn.days=vb.fn.day,vb.fn.months=vb.fn.month,vb.fn.weeks=vb.fn.week,vb.fn.isoWeeks=vb.fn.isoWeek,vb.fn.quarters=vb.fn.quarter,vb.fn.toJSON=vb.fn.toISOString,vb.fn.isUTC=vb.fn.isUtc,o(vb.duration.fn=n.prototype,{_bubble:function(){var a,b,c,d=this._milliseconds,e=this._days,f=this._months,g=this._data,h=0;g.milliseconds=d%1e3,a=q(d/1e3),g.seconds=a%60,b=q(a/60),g.minutes=b%60,c=q(b/60),g.hours=c%24,e+=q(c/24),h=q(rb(e)),e-=q(sb(h)),f+=q(e/30),e%=30,h+=q(f/12),f%=12,g.days=e,g.months=f,g.years=h},abs:function(){return this._milliseconds=Math.abs(this._milliseconds),this._days=Math.abs(this._days),this._months=Math.abs(this._months),this._data.milliseconds=Math.abs(this._data.milliseconds),this._data.seconds=Math.abs(this._data.seconds),this._data.minutes=Math.abs(this._data.minutes),this._data.hours=Math.abs(this._data.hours),this._data.months=Math.abs(this._data.months),this._data.years=Math.abs(this._data.years),this},weeks:function(){return q(this.days()/7)},valueOf:function(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*C(this._months/12)
+},humanize:function(a){var b=ib(this,!a,this.localeData());return a&&(b=this.localeData().pastFuture(+this,b)),this.localeData().postformat(b)},add:function(a,b){var c=vb.duration(a,b);return this._milliseconds+=c._milliseconds,this._days+=c._days,this._months+=c._months,this._bubble(),this},subtract:function(a,b){var c=vb.duration(a,b);return this._milliseconds-=c._milliseconds,this._days-=c._days,this._months-=c._months,this._bubble(),this},get:function(a){return a=z(a),this[a.toLowerCase()+"s"]()},as:function(a){var b,c;if(a=z(a),"month"===a||"year"===a)return b=this._days+this._milliseconds/864e5,c=this._months+12*rb(b),"month"===a?c:c/12;switch(b=this._days+Math.round(sb(this._months/12)),a){case"week":return b/7+this._milliseconds/6048e5;case"day":return b+this._milliseconds/864e5;case"hour":return 24*b+this._milliseconds/36e5;case"minute":return 24*b*60+this._milliseconds/6e4;case"second":return 24*b*60*60+this._milliseconds/1e3;case"millisecond":return Math.floor(24*b*60*60*1e3)+this._milliseconds;default:throw new Error("Unknown unit "+a)}},lang:vb.fn.lang,locale:vb.fn.locale,toIsoString:f("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",function(){return this.toISOString()}),toISOString:function(){var a=Math.abs(this.years()),b=Math.abs(this.months()),c=Math.abs(this.days()),d=Math.abs(this.hours()),e=Math.abs(this.minutes()),f=Math.abs(this.seconds()+this.milliseconds()/1e3);return this.asSeconds()?(this.asSeconds()<0?"-":"")+"P"+(a?a+"Y":"")+(b?b+"M":"")+(c?c+"D":"")+(d||e||f?"T":"")+(d?d+"H":"")+(e?e+"M":"")+(f?f+"S":""):"P0D"},localeData:function(){return this._locale},toJSON:function(){return this.toISOString()}}),vb.duration.fn.toString=vb.duration.fn.toISOString;for(xb in kc)c(kc,xb)&&tb(xb.toLowerCase());vb.duration.fn.asMilliseconds=function(){return this.as("ms")},vb.duration.fn.asSeconds=function(){return this.as("s")},vb.duration.fn.asMinutes=function(){return this.as("m")},vb.duration.fn.asHours=function(){return this.as("h")},vb.duration.fn.asDays=function(){return this.as("d")},vb.duration.fn.asWeeks=function(){return this.as("weeks")},vb.duration.fn.asMonths=function(){return this.as("M")},vb.duration.fn.asYears=function(){return this.as("y")},vb.locale("en",{ordinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(a){var b=a%10,c=1===C(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),Lb?module.exports=vb:"function"==typeof define&&define.amd?(define(function(a,b,c){return c.config&&c.config()&&c.config().noGlobal===!0&&(zb.moment=wb),vb}),ub(!0)):ub()}).call(this);
\ No newline at end of file
diff --git a/services/web/public/stylesheets/app/beta-program.less b/services/web/public/stylesheets/app/beta-program.less
new file mode 100644
index 0000000000..f2141d711d
--- /dev/null
+++ b/services/web/public/stylesheets/app/beta-program.less
@@ -0,0 +1,21 @@
+.beta-opt-in-wrapper {
+ min-height: 400px;
+}
+
+.beta-opt-in {
+ .form-group {
+ margin-top: 15px;
+ }
+}
+
+.beta-feature-badge {
+ &:extend(.label);
+ &:extend(.label-warning);
+ vertical-align: 11%;
+ padding-bottom: 4px;
+ padding-top: 2px;
+ margin-left: 12px;
+ &:before {
+ content: "β";
+ }
+}
\ No newline at end of file
diff --git a/services/web/public/stylesheets/app/contact-us.less b/services/web/public/stylesheets/app/contact-us.less
index 06747b5412..37577f60e0 100644
--- a/services/web/public/stylesheets/app/contact-us.less
+++ b/services/web/public/stylesheets/app/contact-us.less
@@ -3,4 +3,57 @@
textarea {
height: 120px;
}
-}
\ No newline at end of file
+}
+
+.contact-suggestions {
+ margin: 0 -20px 10px;
+ padding: 10px 0;
+ color: @gray-dark;
+ background-color: @gray-lightest;
+ border-top: solid 1px @gray-lighter;
+ border-bottom: solid 1px @gray-lighter;
+ font-size: 0.9rem;
+}
+
+ .contact-suggestion-label {
+ margin-bottom: 10px;
+ padding: 0 20px;
+ }
+
+ .contact-suggestion-list {
+ .list-unstyled();
+ background-color: #FFF;
+ border-top: solid 1px @gray-lighter;
+ border-bottom: solid 1px @gray-lighter;
+ margin: 0;
+
+ li:last-child .contact-suggestion-list-item {
+ border-bottom: none;
+ }
+ }
+
+ .contact-suggestion-list-item {
+ display: table;
+ width: 100%;
+ color: @dropdown-link-color;
+ padding: 10px 20px;
+ border-bottom: solid 1px lighten(@gray-lighter, 10%);
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ color: @dropdown-link-hover-color;
+ background-color: @dropdown-link-hover-bg;
+
+ .fa {
+ color: inherit;
+ }
+ }
+
+ .fa {
+ display: table-cell;
+ text-align: right;
+ color: @gray-lighter;
+ }
+ }
\ No newline at end of file
diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less
index 153ea19fca..09b972c342 100644
--- a/services/web/public/stylesheets/app/editor.less
+++ b/services/web/public/stylesheets/app/editor.less
@@ -252,7 +252,7 @@
margin-bottom:0px;
}
-.sl_references_search_hint {
+.sl_references_search_hint-varDefault {
position: absolute;
bottom: -22px;
left: -1px;
@@ -262,10 +262,49 @@
background: rgb(202, 214, 250);
border: 1px solid lightgray;
box-shadow: 3px 3px 5px rgba(0,0,0,.2);
+
span {
color: black;
}
}
+
+.sl_references_search_hint-varButton {
+ position: absolute;
+ bottom: -65px;
+ left: -1px;
+ right: 0px;
+ padding: 0 6px 6px;
+ text-align: center;
+ background: #fbfbfb;
+ color: #FFF;
+ box-shadow: 3px 3px 5px rgba(0,0,0,.2);
+ border-left: 1px solid lightgray;
+ font-family: @font-family-sans-serif;
+ font-size: 13px;
+ font-weight: 600;
+
+ hr {
+ margin: 6px -6px;
+ }
+
+ button {
+ width: 100%;
+ font-size: inherit;
+ line-height: 1.4;
+ }
+
+ span {
+ padding-left: 10px;
+ }
+
+ kbd {
+ display: block;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: normal;
+ }
+}
+
// -- References Search Modal --
.references-search-modal-backdrop {
// don't grey out the editor when the
@@ -344,4 +383,21 @@
}
}
}
+}
+
+.referencesImportModal {
+ .referencesImportPreview {
+ margin-top: 15px;
+ .referencesImportPreviewScroller {
+ font-family: monospace;
+ font-size: 0.8em;
+ max-height: 360px;
+ overflow: scroll;
+ white-space: pre;
+ padding: 8px 12px;
+ margin-bottom: 15px;
+ border: 1px solid @gray-lighter;
+ background-color: @gray-lightest;
+ }
+ }
}
\ No newline at end of file
diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less
index 353984de51..2183798650 100644
--- a/services/web/public/stylesheets/app/editor/pdf.less
+++ b/services/web/public/stylesheets/app/editor/pdf.less
@@ -3,7 +3,7 @@
top: 58px;
}
-.pdf-logs, .pdf-errors, .pdf-uncompiled {
+.pdf-logs, .pdf-errors, .pdf-uncompiled, .pdf-validation-problems{
padding: @line-height-computed / 2;
}
@@ -120,6 +120,10 @@
float: right;
color: @gray;
font-weight: 700;
+
+ .fa {
+ opacity: 0;
+ }
}
.entry-message {
font-weight: 700;
@@ -130,6 +134,26 @@
font-size: 0.8rem;
//font-family: @font-family-monospace;
}
+
+ &:hover .line-no {
+ color: inherit;
+ .fa {
+ opacity: 1;
+ }
+ }
+
+ &.alert-danger:hover {
+ background-color: darken(@alert-danger-bg, 5%);
+ }
+
+ &.alert-warning:hover {
+ background-color: darken(@alert-warning-bg, 5%);
+ }
+
+ &.alert-info:hover {
+ background-color: darken(@alert-info-bg, 5%);
+ }
+
}
pre {
font-size: 12px;
@@ -168,3 +192,145 @@
}
}
}
+
+.keyboard-tooltip {
+ .tooltip-inner {
+ max-width: none;
+ }
+}
+
+.keyboard-shortcut {
+ white-space: nowrap;
+}
+
+
+@keyframes expand-feedback-area {
+ from {
+ max-height: 0;
+ }
+
+ to {
+ max-height: 500px;
+ }
+}
+
+.card-hint:extend(.card-thin) {
+ margin-top: 10px;
+ padding-bottom: 7px;
+ cursor: default;
+
+ &-icon-container {
+ background: currentColor;
+ width: 2.5rem;
+ height: 2.5rem;
+ font-size: 1.5rem;
+ text-align: center;
+ border-radius: 50%;
+ float: left;
+ margin-right: 10px;
+
+ .fa {
+ color: #FFF;
+ }
+
+ .alert-danger & {
+ color: @alert-danger-border;
+ }
+
+ .alert-warning & {
+ color: @alert-warning-border;
+ }
+
+ .alert-info & {
+ color: @alert-info-border;
+ }
+
+ }
+
+ &-text,
+ &-feedback-label {
+ color: @gray-dark;
+ font-size: 0.9rem;
+ margin-bottom: 20px;
+ }
+
+ &-text {
+ min-height: 35px;
+ }
+
+ &-feedback-label {
+ font-size: inherit;
+ margin-right: 0.5em;
+ margin-bottom: 0;
+ font-weight: normal;
+ }
+
+ &-ext-link,
+ &-feedback {
+ display: inline-block;
+ font-size: 0.8rem;
+ }
+
+ &-actions a,
+ &-text a {
+ .alert-danger & {
+ color: @alert-danger-text;
+ }
+
+ .alert-warning & {
+ color: @alert-warning-text;
+ }
+
+ .alert-info & {
+ color: @alert-info-text;
+ }
+ }
+
+ &-feedback {
+ color: @gray-dark;
+ float: right;
+ }
+
+ &-extra-feedback {
+ color: @gray-dark;
+ font-size: 0.8rem;
+ margin-top: 10px;
+ padding-bottom: 5px;
+ animation: 0.5s ease-out expand-feedback-area;
+ overflow: hidden;
+
+ &-label {
+ margin: 5px 0 10px;
+ padding-top: 5px;
+ border-top: solid 1px @gray-lighter;
+ }
+
+ .radio {
+ margin: 5px;
+ }
+
+ textarea {
+ font-size: 0.8rem;
+ margin-bottom: 10px;
+ padding: 5px;
+ }
+
+ input[type="radio"] {
+ margin-top: 2px;
+ }
+ }
+
+ & + p {
+ margin-top: 20px;
+ }
+
+}
+
+.files-dropdown-container {
+ .pull-right();
+ position: relative;
+}
+
+ .files-dropdown {
+ display: inline-block;
+ }
diff --git a/services/web/public/stylesheets/app/sprites.less b/services/web/public/stylesheets/app/sprites.less
new file mode 100644
index 0000000000..b64e84ad68
--- /dev/null
+++ b/services/web/public/stylesheets/app/sprites.less
@@ -0,0 +1,105 @@
+
+.sprite-icon {
+ background-image: url('/img/sprite.png');
+}
+
+.sprite-icon-ko {
+ background-position: -0px -0px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-cn {
+ background-position: -0px -24px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-da {
+ background-position: -0px -48px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-de {
+ background-position: -0px -72px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-en {
+ background-position: -0px -96px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-es {
+ background-position: -0px -120px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-fi {
+ background-position: -0px -144px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-fr {
+ background-position: -0px -168px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-it {
+ background-position: -0px -192px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-ja {
+ background-position: -0px -216px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-cs {
+ background-position: -0px -240px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-nl {
+ background-position: -0px -264px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-no {
+ background-position: -0px -288px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-pl {
+ background-position: -0px -312px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-pt {
+ background-position: -0px -336px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-ru {
+ background-position: -0px -360px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-sv {
+ background-position: -0px -384px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-tr {
+ background-position: -0px -408px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-uk {
+ background-position: -0px -432px;
+ width: 24px;
+ height: 24px;
+}
+.sprite-icon-zh-CN {
+ background-position: -0px -456px;
+ width: 24px;
+ height: 24px;
+}
diff --git a/services/web/public/stylesheets/app/wiki.less b/services/web/public/stylesheets/app/wiki.less
index 210230989c..5f7462d02c 100644
--- a/services/web/public/stylesheets/app/wiki.less
+++ b/services/web/public/stylesheets/app/wiki.less
@@ -39,6 +39,8 @@
}
.example {
+ max-width: 100%;
+
.code {
pre {
background-color: @gray-lightest;
@@ -49,10 +51,13 @@
}
}
.output {
+ text-align: center;
+ padding-top: 10px;
+
img {
- width: auto !important;
- height: auto !important;
- max-width: 100% !important;
+ width: auto;
+ height: auto;
+ max-width: 100%;
box-shadow: 0 1px 3px @gray-light;
border-radius: 6px;
}
diff --git a/services/web/public/stylesheets/components/footer.less b/services/web/public/stylesheets/components/footer.less
index 7a3bc66723..e4e4f82da1 100644
--- a/services/web/public/stylesheets/components/footer.less
+++ b/services/web/public/stylesheets/components/footer.less
@@ -23,3 +23,8 @@ footer.site-footer {
}
}
}
+
+.sprite-icon-lang {
+ display: inline-block;
+ vertical-align: middle;
+}
\ No newline at end of file
diff --git a/services/web/public/stylesheets/components/navbar.less b/services/web/public/stylesheets/components/navbar.less
index 9a7555cb81..5079b10b7e 100755
--- a/services/web/public/stylesheets/components/navbar.less
+++ b/services/web/public/stylesheets/components/navbar.less
@@ -381,13 +381,15 @@
padding: 1rem 2rem;
.navbar-brand {
- background-image: url('/img/logo.png');
- background-size: 135px 16px;
- background-repeat: no-repeat;
- height: 16px;
- margin-top: 10px;
- padding: 0;
+ position: absolute;
+ top: 5px;
+ bottom: 5px;
width: 135px;
+ padding: 0;
+ background-image: url('/img/logo.png');
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: left center;
}
@media
only screen and (-webkit-min-device-pixel-ratio: 2),
diff --git a/services/web/public/stylesheets/core/variables.less b/services/web/public/stylesheets/core/variables.less
index d753228e8c..c9a3fcda62 100755
--- a/services/web/public/stylesheets/core/variables.less
+++ b/services/web/public/stylesheets/core/variables.less
@@ -49,8 +49,8 @@
//
//## Font, line-height, and color for body text, headings, and more.
-@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);
-@import url(//fonts.googleapis.com/css?family=PT+Serif:400,600,700);
+@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);
+@import url(https://fonts.googleapis.com/css?family=PT+Serif:400,600,700);
@font-family-sans-serif: "Open Sans", sans-serif;
@font-family-serif: "PT Serif", serif;
diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less
index 4e9823631c..b02891425c 100755
--- a/services/web/public/stylesheets/style.less
+++ b/services/web/public/stylesheets/style.less
@@ -1,5 +1,7 @@
// Core variables and mixins
@import "core/variables.less";
+@import url(https://netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css);
+
@import "core/mixins.less";
// Reset
@@ -58,6 +60,7 @@
// ShareLaTeX app classes
@import "app/base.less";
@import "app/account-settings.less";
+@import "app/beta-program.less";
@import "app/about-page.less";
@import "app/project-list.less";
@import "app/editor.less";
@@ -72,3 +75,9 @@
@import "app/wiki.less";
@import "app/translations.less";
@import "app/contact-us.less";
+@import "app/sprites.less";
+
+@import "../js/libs/pdfListView/TextLayer.css";
+@import "../js/libs/pdfListView/AnnotationsLayer.css";
+@import "../js/libs/pdfListView/HighlightsLayer.css";
+
diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
index f83b38617b..4e9b248f25 100644
--- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
@@ -21,6 +21,10 @@ describe "AuthenticationController", ->
"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}
"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
"settings-sharelatex": {}
+ "../User/UserSessionsManager": @UserSessionsManager =
+ trackSession: sinon.stub()
+ untrackSession: sinon.stub()
+ revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
@user =
_id: ObjectId()
email: @email = "USER@example.com"
@@ -57,7 +61,7 @@ describe "AuthenticationController", ->
@res.statusCode.should.equal 429
done()
@AuthenticationController.login(@req, @res)
-
+
describe 'when the user is authenticated', ->
beforeEach ->
@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
@@ -76,7 +80,7 @@ describe "AuthenticationController", ->
@AuthenticationController.establishUserSession
.calledWith(@req, @user)
.should.equal true
-
+
it "should set res.session.justLoggedIn", ->
@req.session.justLoggedIn.should.equal true
@@ -95,7 +99,7 @@ describe "AuthenticationController", ->
@logger.log
.calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in")
.should.equal true
-
+
describe 'when the user is not authenticated', ->
beforeEach ->
@@ -112,7 +116,7 @@ describe "AuthenticationController", ->
it "should not establish a session", ->
@AuthenticationController.establishUserSession.called.should.equal false
-
+
it "should not setup the user data in the background", ->
@UserHandler.setupLoginData.called.should.equal false
@@ -137,12 +141,12 @@ describe "AuthenticationController", ->
describe "getLoggedInUserId", ->
beforeEach ->
- @req =
+ @req =
session :{}
it "should return the user id from the session", (done)->
@user_id = "2134"
- @req.session.user =
+ @req.session.user =
_id:@user_id
@AuthenticationController.getLoggedInUserId @req, (err, user_id)=>
expect(user_id).to.equal @user_id
@@ -168,7 +172,7 @@ describe "AuthenticationController", ->
describe "getLoggedInUser", ->
beforeEach ->
@UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user)
-
+
describe "with an established session", ->
beforeEach ->
@req.session =
@@ -189,7 +193,7 @@ describe "AuthenticationController", ->
_id: "user-id-123"
email: "user@sharelatex.com"
@middleware = @AuthenticationController.requireLogin()
-
+
describe "when the user is logged in", ->
beforeEach ->
@req.session =
@@ -219,13 +223,13 @@ describe "AuthenticationController", ->
beforeEach ->
@req.headers = {}
@AuthenticationController.httpAuth = sinon.stub()
-
+
describe "with white listed url", ->
beforeEach ->
@AuthenticationController.addEndpointToLoginWhitelist "/login"
@req._parsedUrl.pathname = "/login"
@AuthenticationController.requireGlobalLogin @req, @res, @next
-
+
it "should call next() directly", ->
@next.called.should.equal true
@@ -235,9 +239,9 @@ describe "AuthenticationController", ->
@req._parsedUrl.pathname = "/login"
@req.url = "/login?query=something"
@AuthenticationController.requireGlobalLogin @req, @res, @next
-
+
it "should call next() directly", ->
- @next.called.should.equal true
+ @next.called.should.equal true
describe "with http auth", ->
beforeEach ->
@@ -248,20 +252,20 @@ describe "AuthenticationController", ->
@AuthenticationController.httpAuth
.calledWith(@req, @res, @next)
.should.equal true
-
+
describe "with a user session", ->
beforeEach ->
@req.session =
user: {"mock": "user"}
@AuthenticationController.requireGlobalLogin @req, @res, @next
-
+
it "should call next() directly", ->
@next.called.should.equal true
-
+
describe "with no login credentials", ->
beforeEach ->
@AuthenticationController.requireGlobalLogin @req, @res, @next
-
+
it "should redirect to the /login page", ->
@res.redirectedTo.should.equal "/login"
@@ -274,7 +278,7 @@ describe "AuthenticationController", ->
@req.query = {}
describe "they have come directly to the url", ->
- beforeEach ->
+ beforeEach ->
@req.query = {}
@middleware(@req, @res, @next)
@@ -284,7 +288,7 @@ describe "AuthenticationController", ->
describe "they have come via a templates link", ->
- beforeEach ->
+ beforeEach ->
@req.query.zipUrl = "something"
@middleware(@req, @res, @next)
@@ -293,8 +297,8 @@ describe "AuthenticationController", ->
@AuthenticationController._redirectToLoginPage.calledWith(@req, @res).should.equal false
describe "they have been invited to a project", ->
-
- beforeEach ->
+
+ beforeEach ->
@req.query.project_name = "something"
@middleware(@req, @res, @next)
@@ -333,7 +337,7 @@ describe "AuthenticationController", ->
beforeEach ->
@UserUpdater.updateUser = sinon.stub().callsArg(2)
@AuthenticationController._recordSuccessfulLogin(@user._id, @callback)
-
+
it "should increment the user.login.success metric", ->
@Metrics.inc
.calledWith("user.login.success")
@@ -349,7 +353,7 @@ describe "AuthenticationController", ->
describe "_recordFailedLogin", ->
beforeEach ->
@AuthenticationController._recordFailedLogin(@callback)
-
+
it "should increment the user.login.failed metric", ->
@Metrics.inc
.calledWith("user.login.failed")
@@ -365,6 +369,7 @@ describe "AuthenticationController", ->
destroy : sinon.stub()
@req.sessionStore =
generate: sinon.stub()
+ @req.ip = "1.2.3.4"
@AuthenticationController.establishUserSession @req, @user, @callback
it "should set the session user to a basic version of the user", ->
@@ -374,13 +379,15 @@ describe "AuthenticationController", ->
@req.session.user.last_name.should.equal @user.last_name
@req.session.user.referal_id.should.equal @user.referal_id
@req.session.user.isAdmin.should.equal @user.isAdmin
-
+ @req.session.user.ip_address.should.equal @req.ip
+ expect(typeof @req.session.user.ip_address).to.equal 'string'
+ expect(typeof @req.session.user.session_created).to.equal 'string'
+
it "should destroy the session", ->
@req.session.destroy.called.should.equal true
-
+
it "should regenerate the session to protect against session fixation", ->
@req.sessionStore.generate.calledWith(@req).should.equal true
it "should return the callback", ->
@callback.called.should.equal true
-
diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee
index f4d5e72c26..2805527259 100644
--- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee
@@ -16,6 +16,7 @@ describe "AuthenticationManager", ->
users: {}
ObjectId: ObjectId
"bcrypt": @bcrypt = {}
+ "settings-sharelatex": { security: { bcryptRounds: 12 } }
@callback = sinon.stub()
describe "authenticate", ->
@@ -31,6 +32,7 @@ describe "AuthenticationManager", ->
beforeEach (done) ->
@user.hashedPassword = @hashedPassword = "asdfjadflasdf"
@bcrypt.compare = sinon.stub().callsArgWith(2, null, true)
+ @bcrypt.getRounds = sinon.stub().returns 12
@AuthenticationManager.authenticate email: @email, @unencryptedPassword, (error, user) =>
@callback(error, user)
done()
@@ -54,6 +56,35 @@ describe "AuthenticationManager", ->
it "should not return the user", ->
@callback.calledWith(null, null).should.equal true
+ describe "when the hashed password matches but the number of rounds is too low", ->
+ beforeEach (done) ->
+ @user.hashedPassword = @hashedPassword = "asdfjadflasdf"
+ @bcrypt.compare = sinon.stub().callsArgWith(2, null, true)
+ @bcrypt.getRounds = sinon.stub().returns 7
+ @AuthenticationManager.setUserPassword = sinon.stub().callsArgWith(2, null)
+ @AuthenticationManager.authenticate email: @email, @unencryptedPassword, (error, user) =>
+ @callback(error, user)
+ done()
+
+ it "should look up the correct user in the database", ->
+ @User.findOne.calledWith(email: @email).should.equal true
+
+ it "should check that the passwords match", ->
+ @bcrypt.compare
+ .calledWith(@unencryptedPassword, @hashedPassword)
+ .should.equal true
+
+ it "should check the number of rounds", ->
+ @bcrypt.getRounds.called.should.equal true
+
+ it "should set the users password (with a higher number of rounds)", ->
+ @AuthenticationManager.setUserPassword
+ .calledWith("user-id", @unencryptedPassword)
+ .should.equal true
+
+ it "should return the user", ->
+ @callback.calledWith(null, @user).should.equal true
+
describe "when the user does not exist in the database", ->
beforeEach ->
@User.findOne = sinon.stub().callsArgWith(1, null, null)
@@ -87,7 +118,7 @@ describe "AuthenticationManager", ->
it "should hash the password", ->
@bcrypt.genSalt
- .calledWith(7)
+ .calledWith(12)
.should.equal true
@bcrypt.hash
.calledWith(@password, @salt)
diff --git a/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramControllerTests.coffee b/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramControllerTests.coffee
new file mode 100644
index 0000000000..742eb6db23
--- /dev/null
+++ b/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramControllerTests.coffee
@@ -0,0 +1,140 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = path.join __dirname, "../../../../app/js/Features/BetaProgram/BetaProgramController"
+expect = require("chai").expect
+
+describe "BetaProgramController", ->
+
+ beforeEach ->
+ @user =
+ _id: @user_id = "a_simple_id"
+ email: "user@example.com"
+ features: {}
+ betaProgram: false
+ @req =
+ query: {}
+ session:
+ user: @user
+ @BetaProgramController = SandboxedModule.require modulePath, requires:
+ "./BetaProgramHandler": @BetaProgramHandler = {
+ optIn: sinon.stub()
+ optOut: sinon.stub()
+ },
+ "../User/UserLocator": @UserLocator = {
+ findById: sinon.stub()
+ },
+ "settings-sharelatex": @settings = {
+ languages: {}
+ }
+ "logger-sharelatex": @logger = {
+ log: sinon.stub()
+ err: sinon.stub()
+ error: sinon.stub()
+ }
+ @res =
+ send: sinon.stub()
+ redirect: sinon.stub()
+ render: sinon.stub()
+ @next = sinon.stub()
+
+ describe "optIn", ->
+
+ beforeEach ->
+ @BetaProgramHandler.optIn.callsArgWith(1, null)
+
+ it "should redirect to '/beta/participate'", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @res.redirect.callCount.should.equal 1
+ @res.redirect.firstCall.args[0].should.equal "/beta/participate"
+
+ it "should not call next with an error", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @next.callCount.should.equal 0
+
+ it "should not call next with an error", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @next.callCount.should.equal 0
+
+ it "should call BetaProgramHandler.optIn", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @BetaProgramHandler.optIn.callCount.should.equal 1
+
+ describe "when BetaProgramHandler.opIn produces an error", ->
+
+ beforeEach ->
+ @BetaProgramHandler.optIn.callsArgWith(1, new Error('woops'))
+
+ it "should not redirect to '/beta/participate'", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @res.redirect.callCount.should.equal 0
+
+ it "should produce an error", () ->
+ @BetaProgramController.optIn @req, @res, @next
+ @next.callCount.should.equal 1
+ @next.firstCall.args[0].should.be.instanceof Error
+
+ describe "optOut", ->
+
+ beforeEach ->
+ @BetaProgramHandler.optOut.callsArgWith(1, null)
+
+ it "should redirect to '/beta/participate'", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @res.redirect.callCount.should.equal 1
+ @res.redirect.firstCall.args[0].should.equal "/beta/participate"
+
+ it "should not call next with an error", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @next.callCount.should.equal 0
+
+ it "should not call next with an error", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @next.callCount.should.equal 0
+
+ it "should call BetaProgramHandler.optOut", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @BetaProgramHandler.optOut.callCount.should.equal 1
+
+ describe "when BetaProgramHandler.optOut produces an error", ->
+
+ beforeEach ->
+ @BetaProgramHandler.optOut.callsArgWith(1, new Error('woops'))
+
+ it "should not redirect to '/beta/participate'", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @res.redirect.callCount.should.equal 0
+
+ it "should produce an error", () ->
+ @BetaProgramController.optOut @req, @res, @next
+ @next.callCount.should.equal 1
+ @next.firstCall.args[0].should.be.instanceof Error
+
+
+ describe "optInPage", ->
+
+ beforeEach ->
+ @UserLocator.findById.callsArgWith(1, null, @user)
+
+ it "should render the opt-in page", () ->
+ @BetaProgramController.optInPage @req, @res, @next
+ @res.render.callCount.should.equal 1
+ args = @res.render.firstCall.args
+ args[0].should.equal 'beta_program/opt_in'
+
+
+ describe "when UserLocator.findById produces an error", ->
+
+ beforeEach ->
+ @UserLocator.findById.callsArgWith(1, new Error('woops'))
+
+ it "should not render the opt-in page", () ->
+ @BetaProgramController.optInPage @req, @res, @next
+ @res.render.callCount.should.equal 0
+
+ it "should produce an error", () ->
+ @BetaProgramController.optInPage @req, @res, @next
+ @next.callCount.should.equal 1
+ @next.firstCall.args[0].should.be.instanceof Error
diff --git a/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramHandlerTests.coffee b/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramHandlerTests.coffee
new file mode 100644
index 0000000000..affa9f38a5
--- /dev/null
+++ b/services/web/test/UnitTests/coffee/BetaProgram/BetaProgramHandlerTests.coffee
@@ -0,0 +1,100 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+modulePath = path.join __dirname, '../../../../app/js/Features/BetaProgram/BetaProgramHandler'
+sinon = require("sinon")
+expect = require("chai").expect
+
+
+describe 'BetaProgramHandler', ->
+
+ beforeEach ->
+ @user_id = "some_id"
+ @user =
+ _id: @user_id
+ email: 'user@example.com'
+ features: {}
+ betaProgram: false
+ save: sinon.stub().callsArgWith(0, null)
+ @handler = SandboxedModule.require modulePath, requires:
+ "../../models/User": {
+ User:
+ findById: sinon.stub().callsArgWith(1, null, @user)
+ },
+ "logger-sharelatex": @logger = {
+ log: sinon.stub()
+ err: sinon.stub()
+ },
+ "../../infrastructure/Metrics": @logger = {
+ inc: sinon.stub()
+ }
+
+
+ describe "optIn", ->
+
+ beforeEach ->
+ @user.betaProgram = false
+ @call = (callback) =>
+ @handler.optIn @user_id, callback
+
+ it "should set betaProgram = true on user object", (done) ->
+ @call (err) =>
+ @user.betaProgram.should.equal true
+ done()
+
+ it "should call user.save", (done) ->
+ @call (err) =>
+ @user.save.callCount.should.equal 1
+ done()
+
+ it "should not produce an error", (done) ->
+ @call (err) =>
+ expect(err).to.equal null
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ describe "when user.save produces an error", ->
+
+ beforeEach ->
+ @user.save.callsArgWith(0, new Error('woops'))
+
+ it "should produce an error", (done) ->
+ @call (err) =>
+ expect(err).to.not.equal null
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe "optOut", ->
+
+ beforeEach ->
+ @user.betaProgram = true
+ @call = (callback) =>
+ @handler.optOut @user_id, callback
+
+ it "should set betaProgram = true on user object", (done) ->
+ @call (err) =>
+ @user.betaProgram.should.equal false
+ done()
+
+ it "should call user.save", (done) ->
+ @call (err) =>
+ @user.save.callCount.should.equal 1
+ done()
+
+ it "should not produce an error", (done) ->
+ @call (err) =>
+ expect(err).to.equal null
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ describe "when user.save produces an error", ->
+
+ beforeEach ->
+ @user.save.callsArgWith(0, new Error('woops'))
+
+ it "should produce an error", (done) ->
+ @call (err) =>
+ expect(err).to.not.equal null
+ expect(err).to.be.instanceof Error
+ done()
diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
index 632b43e7f2..37e39aaacb 100644
--- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
@@ -77,17 +77,19 @@ describe "CollaboratorsHandler", ->
{ id: "read-only-ref-2", privilegeLevel: "readOnly" }
{ id: "read-write-ref-1", privilegeLevel: "readAndWrite" }
{ id: "read-write-ref-2", privilegeLevel: "readAndWrite" }
+ { id: "doesnt-exist", privilegeLevel: "readAndWrite" }
])
@UserGetter.getUser = sinon.stub()
@UserGetter.getUser.withArgs("read-only-ref-1").yields(null, { _id: "read-only-ref-1" })
@UserGetter.getUser.withArgs("read-only-ref-2").yields(null, { _id: "read-only-ref-2" })
@UserGetter.getUser.withArgs("read-write-ref-1").yields(null, { _id: "read-write-ref-1" })
@UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" })
+ @UserGetter.getUser.withArgs("doesnt-exist").yields(null, null)
@CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback
it "should return an array of members with their privilege levels", ->
@callback
- .calledWith(undefined, [
+ .calledWith(null, [
{ user: { _id: "read-only-ref-1" }, privilegeLevel: "readOnly" }
{ user: { _id: "read-only-ref-2" }, privilegeLevel: "readOnly" }
{ user: { _id: "read-write-ref-1" }, privilegeLevel: "readAndWrite" }
@@ -274,6 +276,19 @@ describe "CollaboratorsHandler", ->
it "should not add any users to the proejct", ->
@CollaboratorHandler.addUserIdToProject.called.should.equal false
-
-
-
+ describe "removeUserFromAllProjects", ->
+ beforeEach (done) ->
+ @CollaboratorHandler.getProjectsUserIsCollaboratorOf = sinon.stub()
+ @CollaboratorHandler.getProjectsUserIsCollaboratorOf.withArgs(@user_id, { _id: 1 }).yields(
+ null,
+ [ { _id: "read-and-write-0" }, { _id: "read-and-write-1" }, null ],
+ [ { _id: "read-only-0" }, { _id: "read-only-1" }, null ]
+ )
+ @CollaboratorHandler.removeUserFromProject = sinon.stub().yields()
+ @CollaboratorHandler.removeUserFromAllProjets @user_id, done
+
+ it "should remove the user from each project", ->
+ for project_id in ["read-and-write-0", "read-and-write-1", "read-only-0", "read-only-1"]
+ @CollaboratorHandler.removeUserFromProject
+ .calledWith(project_id, @user_id)
+ .should.equal true
\ No newline at end of file
diff --git a/services/web/test/UnitTests/coffee/Compile/ClsiFormatCheckerTests.coffee b/services/web/test/UnitTests/coffee/Compile/ClsiFormatCheckerTests.coffee
new file mode 100644
index 0000000000..d2f1c5ef3a
--- /dev/null
+++ b/services/web/test/UnitTests/coffee/Compile/ClsiFormatCheckerTests.coffee
@@ -0,0 +1,151 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/Compile/ClsiFormatChecker.js"
+SandboxedModule = require('sandboxed-module')
+
+describe "ClsiFormatChecker", ->
+ beforeEach ->
+ @ClsiFormatChecker = SandboxedModule.require modulePath, requires:
+ "settings-sharelatex": @settings ={}
+ "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }
+ @project_id = "project-id"
+
+
+ describe "checkRecoursesForProblems", ->
+
+ beforeEach ->
+ @resources = [{
+ path: "main.tex"
+ content: "stuff"
+ }, {
+ path: "chapters/chapter1"
+ content: "other stuff"
+ }, {
+ path: "stuff/image/image.png"
+ url: "http:somewhere.com/project/#{@project_id}/file/1234124321312"
+ modified: "more stuff"
+ }]
+
+ it "should call _checkForDuplicatePaths and _checkForConflictingPaths", (done)->
+
+ @ClsiFormatChecker._checkForConflictingPaths = sinon.stub().callsArgWith(1, null)
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().callsArgWith(1)
+ @ClsiFormatChecker.checkRecoursesForProblems @resources, (err, problems)=>
+ @ClsiFormatChecker._checkForConflictingPaths.called.should.equal true
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal true
+ done()
+
+ it "should remove undefined errors", (done)->
+ @ClsiFormatChecker._checkForConflictingPaths = sinon.stub().callsArgWith(1, null, [])
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().callsArgWith(1, null, {})
+ @ClsiFormatChecker.checkRecoursesForProblems @resources, (err, problems)=>
+ expect(problems).to.not.exist
+ expect(problems).to.not.exist
+ done()
+
+ it "should keep populated arrays", (done)->
+ @ClsiFormatChecker._checkForConflictingPaths = sinon.stub().callsArgWith(1, null, [{path:"somewhere/main.tex"}])
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().callsArgWith(1, null, {})
+ @ClsiFormatChecker.checkRecoursesForProblems @resources, (err, problems)=>
+ problems.conflictedPaths[0].path.should.equal "somewhere/main.tex"
+ expect(problems.sizeCheck).to.not.exist
+ done()
+
+ it "should keep populated object", (done)->
+ @ClsiFormatChecker._checkForConflictingPaths = sinon.stub().callsArgWith(1, null, [])
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().callsArgWith(1, null, {resources:[{"a.tex"},{"b.tex"}], totalSize:1000000})
+ @ClsiFormatChecker.checkRecoursesForProblems @resources, (err, problems)=>
+ problems.sizeCheck.resources.length.should.equal 2
+ problems.sizeCheck.totalSize.should.equal 1000000
+ expect(problems.conflictedPaths).to.not.exist
+ done()
+
+ describe "_checkForConflictingPaths", ->
+
+ beforeEach ->
+
+ @resources.push({
+ path: "chapters/chapter1.tex"
+ content: "other stuff"
+ })
+
+ @resources.push({
+ path: "chapters.tex"
+ content: "other stuff"
+ })
+
+ it "should flag up when a nested file has folder with same subpath as file elsewhere", (done)->
+ @resources.push({
+ path: "stuff/image"
+ url: "http://somwhere.com"
+ })
+
+ @ClsiFormatChecker._checkForConflictingPaths @resources, (err, conflictPathErrors)->
+ conflictPathErrors.length.should.equal 1
+ conflictPathErrors[0].path.should.equal "stuff/image"
+ done()
+
+ it "should flag up when a root level file has folder with same subpath as file elsewhere", (done)->
+ @resources.push({
+ path: "stuff"
+ content: "other stuff"
+ })
+
+ @ClsiFormatChecker._checkForConflictingPaths @resources, (err, conflictPathErrors)->
+ conflictPathErrors.length.should.equal 1
+ conflictPathErrors[0].path.should.equal "stuff"
+ done()
+
+ it "should not flag up when the file is a substring of a path", (done)->
+ @resources.push({
+ path: "stuf"
+ content: "other stuff"
+ })
+
+ @ClsiFormatChecker._checkForConflictingPaths @resources, (err, conflictPathErrors)->
+ conflictPathErrors.length.should.equal 0
+ done()
+
+
+ describe "_checkDocsAreUnderSizeLimit", ->
+
+ it "should error when there is more than 5mb of data", (done)->
+
+ @resources.push({
+ path: "massive.tex"
+ content: require("crypto").randomBytes(1000 * 1000 * 5).toString("hex")
+ })
+
+ while @resources.length < 20
+ @resources.push({path:"chapters/chapter1.tex",url: "http://somwhere.com"})
+
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit @resources, (err, sizeError)->
+ sizeError.totalSize.should.equal 10000016
+ sizeError.resources.length.should.equal 10
+ sizeError.resources[0].path.should.equal "massive.tex"
+ sizeError.resources[0].size.should.equal 1000 * 1000 * 10
+ done()
+
+
+ it "should return nothing when project is correct size", (done)->
+
+ @resources.push({
+ path: "massive.tex"
+ content: require("crypto").randomBytes(1000 * 1000 * 1).toString("hex")
+ })
+
+ while @resources.length < 20
+ @resources.push({path:"chapters/chapter1.tex",url: "http://somwhere.com"})
+
+ @ClsiFormatChecker._checkDocsAreUnderSizeLimit @resources, (err, sizeError)->
+ expect(sizeError).to.not.exist
+ done()
+
+
+
+
+
+
+
diff --git a/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee
index 9889bcb87b..2fb980b461 100644
--- a/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee
@@ -12,6 +12,8 @@ describe "ClsiManager", ->
getCookieJar: sinon.stub().callsArgWith(1, null, @jar)
setServerId: sinon.stub().callsArgWith(2)
_getServerId:sinon.stub()
+ @ClsiFormatChecker =
+ checkRecoursesForProblems:sinon.stub().callsArgWith(1)
@ClsiManager = SandboxedModule.require modulePath, requires:
"settings-sharelatex": @settings =
apis:
@@ -27,7 +29,9 @@ describe "ClsiManager", ->
"./ClsiCookieManager": @ClsiCookieManager
"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }
"request": @request = sinon.stub()
+ "./ClsiFormatChecker": @ClsiFormatChecker
@project_id = "project-id"
+ @user_id = "user-id"
@callback = sinon.stub()
describe "sendRequest", ->
@@ -37,20 +41,22 @@ describe "ClsiManager", ->
describe "with a successful compile", ->
beforeEach ->
- @ClsiManager._postToClsi = sinon.stub().callsArgWith(3, null, {
+ @ClsiManager._postToClsi = sinon.stub().callsArgWith(4, null, {
compile:
status: @status = "success"
outputFiles: [{
- url: "#{@settings.apis.clsi.url}/project/#{@project_id}/output/output.pdf"
+ url: "#{@settings.apis.clsi.url}/project/#{@project_id}/user/#{@user_id}/build/1234/output/output.pdf"
+ path: "output.pdf"
type: "pdf"
build: 1234
},{
- url: "#{@settings.apis.clsi.url}/project/#{@project_id}/output/output.log"
+ url: "#{@settings.apis.clsi.url}/project/#{@project_id}/user/#{@user_id}/build/1234/output/output.log"
+ path: "output.log"
type: "log"
build: 1234
}]
})
- @ClsiManager.sendRequest @project_id, {compileGroup:"standard"}, @callback
+ @ClsiManager.sendRequest @project_id, @user_id, {compileGroup:"standard"}, @callback
it "should build the request", ->
@ClsiManager._buildRequest
@@ -59,15 +65,17 @@ describe "ClsiManager", ->
it "should send the request to the CLSI", ->
@ClsiManager._postToClsi
- .calledWith(@project_id, @request, "standard")
+ .calledWith(@project_id, @user_id, @request, "standard")
.should.equal true
it "should call the callback with the status and output files", ->
outputFiles = [{
+ url: "/project/#{@project_id}/user/#{@user_id}/build/1234/output/output.pdf"
path: "output.pdf"
type: "pdf"
build: 1234
},{
+ url: "/project/#{@project_id}/user/#{@user_id}/build/1234/output/output.log"
path: "output.log"
type: "log"
build: 1234
@@ -76,11 +84,11 @@ describe "ClsiManager", ->
describe "with a failed compile", ->
beforeEach ->
- @ClsiManager._postToClsi = sinon.stub().callsArgWith(3, null, {
+ @ClsiManager._postToClsi = sinon.stub().callsArgWith(4, null, {
compile:
status: @status = "failure"
})
- @ClsiManager.sendRequest @project_id, {}, @callback
+ @ClsiManager.sendRequest @project_id, @user_id, {}, @callback
it "should call the callback with a failure statue", ->
@callback.calledWith(null, @status).should.equal true
@@ -91,11 +99,11 @@ describe "ClsiManager", ->
describe "with the standard compileGroup", ->
beforeEach ->
- @ClsiManager.deleteAuxFiles @project_id, {compileGroup: "standard"}, @callback
+ @ClsiManager.deleteAuxFiles @project_id, @user_id, {compileGroup: "standard"}, @callback
it "should call the delete method in the standard CLSI", ->
@ClsiManager._makeRequest
- .calledWith(@project_id, { method:"DELETE", url:"#{@settings.apis.clsi.url}/project/#{@project_id}"})
+ .calledWith(@project_id, { method:"DELETE", url:"#{@settings.apis.clsi.url}/project/#{@project_id}/user/#{@user_id}"})
.should.equal true
it "should call the callback", ->
@@ -235,10 +243,10 @@ describe "ClsiManager", ->
describe "successfully", ->
beforeEach ->
@ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 204}, @body = { mock: "foo" })
- @ClsiManager._postToClsi @project_id, @req, "standard", @callback
+ @ClsiManager._postToClsi @project_id, @user_id, @req, "standard", @callback
it 'should send the request to the CLSI', ->
- url = "#{@settings.apis.clsi.url}/project/#{@project_id}/compile"
+ url = "#{@settings.apis.clsi.url}/project/#{@project_id}/user/#{@user_id}/compile"
@ClsiManager._makeRequest.calledWith(@project_id, {
method: "POST",
url: url
@@ -251,7 +259,7 @@ describe "ClsiManager", ->
describe "when the CLSI returns an error", ->
beforeEach ->
@ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 500}, @body = { mock: "foo" })
- @ClsiManager._postToClsi @project_id, @req, "standard", @callback
+ @ClsiManager._postToClsi @project_id, @user_id, @req, "standard", @callback
it "should call the callback with the body and the error", ->
@callback.calledWith(new Error("CLSI returned non-success code: 500"), @body).should.equal true
@@ -261,37 +269,36 @@ describe "ClsiManager", ->
beforeEach ->
@ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 200}, @body = { mock: "foo" })
@ClsiManager._buildRequest = sinon.stub().callsArgWith(2, null, @req = { compile: { rootResourcePath: "rootfile.text", options: {} } })
- @ClsiManager._getCompilerUrl = sinon.stub().returns "compiler.url"
describe "with root file", ->
beforeEach ->
- @ClsiManager.wordCount @project_id, false, {}, @callback
+ @ClsiManager.wordCount @project_id, @user_id, false, {}, @callback
it "should call wordCount with root file", ->
@ClsiManager._makeRequest
- .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=rootfile.text" })
- .should.equal true
+ .calledWith(@project_id, {method: "GET", url: "http://clsi.example.com/project/#{@project_id}/user/#{@user_id}/wordcount", qs: {file: "rootfile.text",image:undefined}})
+ .should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "with param file", ->
beforeEach ->
- @ClsiManager.wordCount @project_id, "main.tex", {}, @callback
+ @ClsiManager.wordCount @project_id, @user_id, "main.tex", {}, @callback
it "should call wordCount with param file", ->
@ClsiManager._makeRequest
- .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex" })
+ .calledWith(@project_id, { method: "GET", url: "http://clsi.example.com/project/#{@project_id}/user/#{@user_id}/wordcount", qs:{file:"main.tex",image:undefined}})
.should.equal true
describe "with image", ->
beforeEach ->
@req.compile.options.imageName = @image = "example.com/mock/image"
- @ClsiManager.wordCount @project_id, "main.tex", {}, @callback
+ @ClsiManager.wordCount @project_id, @user_id, "main.tex", {}, @callback
it "should call wordCount with file and image", ->
@ClsiManager._makeRequest
- .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex&image=#{encodeURIComponent(@image)}" })
+ .calledWith(@project_id, { method: "GET", url: "http://clsi.example.com/project/#{@project_id}/user/#{@user_id}/wordcount", qs:{file:"main.tex",image:@image}})
.should.equal true
@@ -323,17 +330,3 @@ describe "ClsiManager", ->
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
index 6bae5803c4..d7a13578e2 100644
--- a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
@@ -45,13 +45,13 @@ describe "CompileController", ->
@next = sinon.stub()
@req = new MockRequest()
@res = new MockResponse()
+ @AuthenticationController.getLoggedInUserId = sinon.stub().callsArgWith(1, null, @user_id = "mock-user-id")
describe "compile", ->
beforeEach ->
@req.params =
Project_id: @project_id
@req.session = {}
- @AuthenticationController.getLoggedInUserId = sinon.stub().callsArgWith(1, null, @user_id = "mock-user-id")
@CompileManager.compile = sinon.stub().callsArgWith(3, null, @status = "success", @outputFiles = ["mock-output-files"])
describe "when not an auto compile", ->
@@ -139,7 +139,7 @@ describe "CompileController", ->
.should.equal true
it "should proxy the PDF from the CLSI", ->
- @CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/output/output.pdf", @req, @res, @next).should.equal true
+ @CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/user/#{@user_id}/output/output.pdf", @req, @res, @next).should.equal true
describe "when the pdf is not going to be used in pdfjs viewer", ->
@@ -206,16 +206,6 @@ describe "CompileController", ->
@CompileManager.getProjectCompileLimits = sinon.stub().callsArgWith(1, null, {compileGroup: "priority"})
@CompileController.proxyToClsi(@project_id, @url = "/test", @req, @res, @next)
- it "should proxy to the priority url if the user has the feature", ()->
- @request
- .calledWith(
- jar:@jar
- method: @req.method
- url: "#{@settings.apis.clsi_priority.url}#{@url}",
- timeout: 60 * 1000
- )
- .should.equal true
-
describe "user with standard priority via query string", ->
beforeEach ->
@req.query = {compileGroup: 'standard'}
@@ -239,20 +229,6 @@ describe "CompileController", ->
it "should bind an error handle to the request proxy", ->
@proxy.on.calledWith("error").should.equal true
- describe "user with priority compile via query string", ->
- beforeEach ->
- @req.query = {compileGroup: 'priority'}
- @CompileController.proxyToClsi(@project_id, @url = "/test", @req, @res, @next)
-
- it "should proxy to the priority url if the user has the feature", ()->
- @request
- .calledWith(
- jar:@jar
- method: @req.method
- url: "#{@settings.apis.clsi_priority.url}#{@url}",
- timeout: 60 * 1000
- )
- .should.equal true
describe "user with non-existent priority via query string", ->
beforeEach ->
@@ -316,25 +292,7 @@ describe "CompileController", ->
it "should bind an error handle to the request proxy", ->
@proxy.on.calledWith("error").should.equal true
- describe "user with priority compile", ->
- beforeEach ->
- @CompileManager.getProjectCompileLimits = sinon.stub().callsArgWith(1, null, {compileGroup: "priority"})
- @CompileController.proxyToClsi(@project_id, @url = "/test", @req, @res, @next)
- it "should proxy to the priority url if the user has the feature", ()->
- @request
- .calledWith(
- jar:@jar
- method: @req.method
- url: "#{@settings.apis.clsi_priority.url}#{@url}",
- timeout: 60 * 1000
- headers: {
- 'Range': '123-456'
- 'If-Range': 'abcdef'
- 'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT'
- }
- )
- .should.equal true
describe "user with build parameter via query string", ->
beforeEach ->
@@ -359,7 +317,7 @@ describe "CompileController", ->
describe "deleteAuxFiles", ->
beforeEach ->
- @CompileManager.deleteAuxFiles = sinon.stub().callsArg(1)
+ @CompileManager.deleteAuxFiles = sinon.stub().callsArg(2)
@req.params =
Project_id: @project_id
@res.sendStatus = sinon.stub()
@@ -392,12 +350,14 @@ describe "CompileController", ->
it "should proxy the res to the clsi with correct url", (done)->
@CompileController.compileAndDownloadPdf @req, @res
+ sinon.assert.calledWith @CompileController.proxyToClsi, @project_id, "/project/#{@project_id}/output/output.pdf", @req, @res
+
@CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/output/output.pdf", @req, @res).should.equal true
done()
describe "wordCount", ->
beforeEach ->
- @CompileManager.wordCount = sinon.stub().callsArgWith(2, null, {content:"body"})
+ @CompileManager.wordCount = sinon.stub().callsArgWith(3, null, {content:"body"})
@req.params =
Project_id: @project_id
@res.send = sinon.stub()
@@ -406,7 +366,7 @@ describe "CompileController", ->
it "should proxy to the CLSI", ->
@CompileManager.wordCount
- .calledWith(@project_id, false)
+ .calledWith(@project_id, @user_id, false)
.should.equal true
it "should return a 200 and body", ->
diff --git a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
index 814b4f258b..849e9e8ccc 100644
--- a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
@@ -42,7 +42,7 @@ describe "CompileManager", ->
@CompileManager._ensureRootDocumentIsSet = sinon.stub().callsArgWith(1, null)
@DocumentUpdaterHandler.flushProjectToMongo = sinon.stub().callsArgWith(1, null)
@CompileManager.getProjectCompileLimits = sinon.stub().callsArgWith(1, null, @limits)
- @ClsiManager.sendRequest = sinon.stub().callsArgWith(2, null, @status = "mock-status", @outputFiles = "mock output files", @output = "mock output")
+ @ClsiManager.sendRequest = sinon.stub().callsArgWith(3, null, @status = "mock-status", @outputFiles = "mock output files", @output = "mock output")
describe "succesfully", ->
beforeEach ->
@@ -71,7 +71,7 @@ describe "CompileManager", ->
it "should run the compile with the compile limits", ->
@ClsiManager.sendRequest
- .calledWith(@project_id, {
+ .calledWith(@project_id, @user_id, {
timeout: @limits.timeout
})
.should.equal true
@@ -135,8 +135,8 @@ describe "CompileManager", ->
describe "deleteAuxFiles", ->
beforeEach ->
@CompileManager.getProjectCompileLimits = sinon.stub().callsArgWith 1, null, @limits = { compileGroup: "mock-compile-group" }
- @ClsiManager.deleteAuxFiles = sinon.stub().callsArg(2)
- @CompileManager.deleteAuxFiles @project_id, @callback
+ @ClsiManager.deleteAuxFiles = sinon.stub().callsArg(3)
+ @CompileManager.deleteAuxFiles @project_id, @user_id, @callback
it "should look up the compile group to use", ->
@CompileManager.getProjectCompileLimits
@@ -145,7 +145,7 @@ describe "CompileManager", ->
it "should delete the aux files", ->
@ClsiManager.deleteAuxFiles
- .calledWith(@project_id, @limits)
+ .calledWith(@project_id, @user_id, @limits)
.should.equal true
it "should call the callback", ->
@@ -260,8 +260,8 @@ describe "CompileManager", ->
describe "wordCount", ->
beforeEach ->
@CompileManager.getProjectCompileLimits = sinon.stub().callsArgWith 1, null, @limits = { compileGroup: "mock-compile-group" }
- @ClsiManager.wordCount = sinon.stub().callsArg(3)
- @CompileManager.wordCount @project_id, false, @callback
+ @ClsiManager.wordCount = sinon.stub().callsArg(4)
+ @CompileManager.wordCount @project_id, @user_id, false, @callback
it "should look up the compile group to use", ->
@CompileManager.getProjectCompileLimits
@@ -270,7 +270,7 @@ describe "CompileManager", ->
it "should call wordCount for project", ->
@ClsiManager.wordCount
- .calledWith(@project_id, false, @limits)
+ .calledWith(@project_id, @user_id, false, @limits)
.should.equal true
it "should call the callback", ->
diff --git a/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee
index 6f10b351df..cfca6dfebe 100644
--- a/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee
@@ -12,10 +12,7 @@ describe 'NotificationsHandler', ->
notificationUrl = "notification.sharelatex.testing"
beforeEach ->
- @request =
- post: sinon.stub().callsArgWith(1)
- del: sinon.stub().callsArgWith(1)
- get: sinon.stub()
+ @request = sinon.stub().callsArgWith(1)
@handler = SandboxedModule.require modulePath, requires:
"settings-sharelatex": apis:{notifications:{url:notificationUrl}}
"request":@request
@@ -26,18 +23,19 @@ describe 'NotificationsHandler', ->
describe "getUserNotifications", ->
it 'should get unread notifications', (done)->
stubbedNotifications = [{_id: notification_id, user_id: user_id}]
- @request.get.callsArgWith(1, null, {statusCode:200}, stubbedNotifications)
+ @request.callsArgWith(1, null, {statusCode:200}, stubbedNotifications)
@handler.getUserNotifications user_id, (err, unreadNotifications)=>
stubbedNotifications.should.deep.equal unreadNotifications
getOpts =
uri: "#{notificationUrl}/user/#{user_id}"
json:true
timeout:1000
- @request.get.calledWith(getOpts).should.equal true
+ method: "GET"
+ @request.calledWith(getOpts).should.equal true
done()
it 'should return empty arrays if there are no notifications', ->
- @request.get.callsArgWith(1, null, {statusCode:200}, null)
+ @request.callsArgWith(1, null, {statusCode:200}, null)
@handler.getUserNotifications user_id, (err, unreadNotifications)=>
unreadNotifications.length.should.equal 0
@@ -52,7 +50,8 @@ describe 'NotificationsHandler', ->
json:
key:@key
timeout:1000
- @request.del.calledWith(opts).should.equal true
+ method: "DELETE"
+ @request.calledWith(opts).should.equal true
done()
@@ -64,7 +63,7 @@ describe 'NotificationsHandler', ->
it "should post the message over", (done)->
@handler.createNotification user_id, @key, @templateKey, @messageOpts, =>
- args = @request.post.args[0][0]
+ args = @request.args[0][0]
args.uri.should.equal "#{notificationUrl}/user/#{user_id}"
args.timeout.should.equal 1000
expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts}
diff --git a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
index 27852e3e07..89c6479734 100644
--- a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
+++ b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
@@ -17,6 +17,8 @@ describe "PasswordResetController", ->
setNewUserPassword:sinon.stub()
@RateLimiter =
addCount: sinon.stub()
+ @UserSessionsManager =
+ revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
@PasswordResetController = SandboxedModule.require modulePath, requires:
"settings-sharelatex":@settings
"./PasswordResetHandler":@PasswordResetHandler
@@ -24,6 +26,7 @@ describe "PasswordResetController", ->
"../../infrastructure/RateLimiter":@RateLimiter
"../Authentication/AuthenticationController": @AuthenticationController = {}
"../User/UserGetter": @UserGetter = {}
+ "../User/UserSessionsManager": @UserSessionsManager
@email = "bob@bob.com "
@token = "my security token that was emailed to me"
@@ -134,7 +137,14 @@ describe "PasswordResetController", ->
@req.session.should.not.have.property 'resetToken'
done()
@PasswordResetController.setNewUserPassword @req, @res
-
+
+ it 'should clear sessions', (done) ->
+ @PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true)
+ @res.sendStatus = (code)=>
+ @UserSessionsManager.revokeAllUserSessions.callCount.should.equal 1
+ done()
+ @PasswordResetController.setNewUserPassword @req, @res
+
it "should login user if login_after is set", (done) ->
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")
diff --git a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
index 601f7867b0..6a99ed8150 100644
--- a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
+++ b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
@@ -27,13 +27,15 @@ describe 'ProjectDeleter', ->
removeProjectFromAllTags: sinon.stub().callsArgWith(2)
@ProjectGetter =
getProject:sinon.stub()
+ @CollaboratorsHandler =
+ removeUserFromAllProjets: sinon.stub().yields()
@deleter = SandboxedModule.require modulePath, requires:
"../Editor/EditorController": @editorController
'../../models/Project':{Project:@Project}
'../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler
"../Tags/TagsHandler":@TagsHandler
"../FileStore/FileStoreHandler": @FileStoreHandler = {}
- "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {}
+ "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
"./ProjectGetter": @ProjectGetter
'logger-sharelatex':
log:->
@@ -74,6 +76,12 @@ describe 'ProjectDeleter', ->
@Project.remove.calledWith(owner_ref:user_id).should.equal true
done()
+ it "should remove all the projects the user is a collaborator of", (done)->
+ user_id = 1234
+ @deleter.deleteUsersProjects user_id, =>
+ @CollaboratorsHandler.removeUserFromAllProjets.calledWith(user_id).should.equal true
+ done()
+
describe "deleteProject", ->
beforeEach (done) ->
@project_id = "mock-project-id-123"
diff --git a/services/web/test/UnitTests/coffee/References/ReferencesControllerTests.coffee b/services/web/test/UnitTests/coffee/References/ReferencesControllerTests.coffee
index 16266c0820..37a448d34a 100644
--- a/services/web/test/UnitTests/coffee/References/ReferencesControllerTests.coffee
+++ b/services/web/test/UnitTests/coffee/References/ReferencesControllerTests.coffee
@@ -32,6 +32,7 @@ describe "ReferencesController", ->
shouldBroadcast: false
@res = new MockResponse()
@res.json = sinon.stub()
+ @res.send = sinon.stub()
@res.sendStatus = sinon.stub()
@fakeResponseData =
projectId: @projectId,
@@ -113,6 +114,31 @@ describe "ReferencesController", ->
@res.json.calledWith(@fakeResponseData).should.equal true
done()
+ describe 'there is no dataaaaaaa', ->
+
+ beforeEach ->
+ @ReferencesHandler.indexAll.callsArgWith(1)
+ @call = (callback) =>
+ @controller.indexAll @req, @res
+ callback()
+
+ it 'should not call EditorRealTimeController.emitToRoom', (done) ->
+ @call () =>
+ @EditorRealTimeController.emitToRoom.callCount.should.equal 0
+ done()
+
+ it 'should not produce an error', (done) ->
+ @call () =>
+ @res.sendStatus.callCount.should.equal 0
+ @res.sendStatus.calledWith(500).should.equal false
+ @res.sendStatus.calledWith(400).should.equal false
+ done()
+
+ it 'should send a response with an empty keys list', (done) ->
+ @call () =>
+ @res.json.called.should.equal true
+ @res.json.calledWith({projectId: @projectId, keys: []}).should.equal true
+ done()
describe 'index', ->
diff --git a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee
index 581d5cd6da..94a1edb5f2 100644
--- a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee
+++ b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee
@@ -127,7 +127,23 @@ describe "LimitationsManager", ->
@LimitationsManager.userHasSubscription @user, (err, hasSubOrIsGroupMember, subscription)->
subscription.should.deep.equal stubbedSubscription
done()
-
+
+ describe "when user has a custom account", ->
+
+ beforeEach ->
+ @fakeSubscription = {customAccount: true}
+ @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @fakeSubscription)
+
+ it 'should return true', (done) ->
+ @LimitationsManager.userHasSubscription @user, (err, hasSubscription, subscription)->
+ hasSubscription.should.equal true
+ done()
+
+ it 'should return the subscription', (done) ->
+ @LimitationsManager.userHasSubscription @user, (err, hasSubscription, subscription)=>
+ subscription.should.deep.equal @fakeSubscription
+ done()
+
describe "userIsMemberOfGroupSubscription", ->
beforeEach ->
@SubscriptionLocator.getMemberSubscriptions = sinon.stub()
diff --git a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee
index e05040772d..5a5e9fc40c 100644
--- a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee
+++ b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee
@@ -1,9 +1,10 @@
should = require('chai').should()
+expect = require('chai').expect
sinon = require 'sinon'
crypto = require 'crypto'
querystring = require 'querystring'
-RecurlyWrapper = require "../../../../app/js/Features/Subscription/RecurlyWrapper"
-Settings = require "settings-sharelatex"
+modulePath = "../../../../app/js/Features/Subscription/RecurlyWrapper"
+SandboxedModule = require('sandboxed-module')
tk = require("timekeeper")
fixtures =
@@ -97,22 +98,37 @@ mockApiRequest = (options, callback) ->
describe "RecurlyWrapper", ->
- beforeEach ->
- Settings.plans = [{
- planCode: "collaborator"
- name: "Collaborator"
- features:
- collaborators: -1
- versioning: true
- }]
- Settings.defaultPlanCode =
- collaborators: 0
- versioning: false
+
+ before ->
+ @settings =
+ plans: [{
+ planCode: "collaborator"
+ name: "Collaborator"
+ features:
+ collaborators: -1
+ versioning: true
+ }]
+ defaultPlanCode:
+ collaborators: 0
+ versioning: false
+ apis:
+ recurly:
+ apiKey: 'nonsense'
+ privateKey: 'private_nonsense'
+
+ @RecurlyWrapper = RecurlyWrapper = SandboxedModule.require modulePath, requires:
+ "settings-sharelatex": @settings
+ "logger-sharelatex":
+ err: sinon.stub()
+ error: sinon.stub()
+ log: sinon.stub()
+ "request": sinon.stub()
describe "sign", ->
+
before (done) ->
tk.freeze Date.now() # freeze the time for these tests
- RecurlyWrapper.sign({
+ @RecurlyWrapper.sign({
subscription :
plan_code : "gold"
name : "$$$"
@@ -127,7 +143,7 @@ describe "RecurlyWrapper", ->
it "should be signed correctly", ->
signed = @signature.split("|")[0]
query = @signature.split("|")[1]
- crypto.createHmac("sha1", Settings.apis.recurly.privateKey).update(query).digest("hex").should.equal signed
+ crypto.createHmac("sha1", @settings.apis.recurly.privateKey).update(query).digest("hex").should.equal signed
it "should be url escaped", ->
query = @signature.split("|")[1]
@@ -149,38 +165,39 @@ describe "RecurlyWrapper", ->
describe "_parseXml", ->
it "should convert different data types into correct representations", (done) ->
- xml =
- "" +
- "" +
- " " +
- " " +
- " gold" +
- " Gold plan" +
- " " +
- " 44f83d7cba354d5b84812419f923ea96" +
- " active" +
- " 800" +
- " EUR" +
- " 1" +
- " 2011-05-27T07:00:00Z" +
- " " +
- " " +
- " 2011-06-27T07:00:00Z" +
- " 2011-07-27T07:00:00Z" +
- " " +
- " " +
- " " +
- " " +
- " ipaddresses" +
- " 10" +
- " 150" +
- " " +
- " " +
- " " +
- " " +
- " " +
- ""
- RecurlyWrapper._parseXml xml, (error, data) ->
+ xml = """
+
+
+
+
+ gold
+ Gold plan
+
+ 44f83d7cba354d5b84812419f923ea96
+ active
+ 800
+ EUR
+ 1
+ 2011-05-27T07:00:00Z
+
+
+ 2011-06-27T07:00:00Z
+ 2011-07-27T07:00:00Z
+
+
+
+
+ ipaddresses
+ 10
+ 150
+
+
+
+
+
+
+ """
+ @RecurlyWrapper._parseXml xml, (error, data) ->
data.subscription.plan.plan_code.should.equal "gold"
data.subscription.plan.name.should.equal "Gold plan"
data.subscription.uuid.should.equal "44f83d7cba354d5b84812419f923ea96"
@@ -188,32 +205,37 @@ describe "RecurlyWrapper", ->
data.subscription.unit_amount_in_cents.should.equal 800
data.subscription.currency.should.equal "EUR"
data.subscription.quantity.should.equal 1
+
data.subscription.activated_at.should.deep.equal new Date("2011-05-27T07:00:00Z")
should.equal data.subscription.canceled_at, null
should.equal data.subscription.expires_at, null
+
data.subscription.current_period_started_at.should.deep.equal new Date("2011-06-27T07:00:00Z")
+
data.subscription.current_period_ends_at.should.deep.equal new Date("2011-07-27T07:00:00Z")
should.equal data.subscription.trial_started_at, null
should.equal data.subscription.trial_ends_at, null
- data.subscription.subscription_add_ons.should.deep.equal [{
+
+ data.subscription.subscription_add_ons[0].should.deep.equal {
add_on_code: "ipaddresses"
quantity: "10"
unit_amount_in_cents: "150"
- }]
+ }
data.subscription.account.url.should.equal "https://api.recurly.com/v2/accounts/1"
data.subscription.url.should.equal "https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96"
data.subscription.plan.url.should.equal "https://api.recurly.com/v2/plans/gold"
done()
-
+
describe "getSubscription", ->
+
describe "with proper subscription id", ->
before ->
- @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest)
- RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", (error, recurlySubscription) =>
+ @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest)
+ @RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", (error, recurlySubscription) =>
@recurlySubscription = recurlySubscription
after ->
- RecurlyWrapper.apiRequest.restore()
-
+ @RecurlyWrapper.apiRequest.restore()
+
it "should look up the subscription at the normal API end point", ->
@apiRequest.args[0][0].url.should.equal "subscriptions/44f83d7cba354d5b84812419f923ea96"
@@ -222,12 +244,12 @@ describe "RecurlyWrapper", ->
describe "with ReculyJS token", ->
before ->
- @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest)
- RecurlyWrapper.getSubscription "70db44b10f5f4b238669480c9903f6f5", {recurlyJsResult: true}, (error, recurlySubscription) =>
+ @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest)
+ @RecurlyWrapper.getSubscription "70db44b10f5f4b238669480c9903f6f5", {recurlyJsResult: true}, (error, recurlySubscription) =>
@recurlySubscription = recurlySubscription
after ->
- RecurlyWrapper.apiRequest.restore()
-
+ @RecurlyWrapper.apiRequest.restore()
+
it "should return the subscription", ->
@recurlySubscription.uuid.should.equal "44f83d7cba354d5b84812419f923ea96"
@@ -236,30 +258,30 @@ describe "RecurlyWrapper", ->
describe "with includeAccount", ->
beforeEach ->
- @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest)
- RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", {includeAccount: true}, (error, recurlySubscription) =>
+ @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest)
+ @RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", {includeAccount: true}, (error, recurlySubscription) =>
@recurlySubscription = recurlySubscription
afterEach ->
- RecurlyWrapper.apiRequest.restore()
+ @RecurlyWrapper.apiRequest.restore()
it "should request the account from the API", ->
@apiRequest.args[1][0].url.should.equal "accounts/104"
-
+
it "should populate the account attribute", ->
@recurlySubscription.account.account_code.should.equal "104"
-
+
describe "updateSubscription", ->
beforeEach (done) ->
@recurlySubscriptionId = "subscription-id-123"
- @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) =>
+ @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) =>
@requestOptions = options
callback null, {}, fixtures["subscriptions/44f83d7cba354d5b84812419f923ea96"]
- RecurlyWrapper.updateSubscription @recurlySubscriptionId, { plan_code : "silver", timeframe: "now" }, (error, recurlySubscription) =>
+ @RecurlyWrapper.updateSubscription @recurlySubscriptionId, { plan_code : "silver", timeframe: "now" }, (error, recurlySubscription) =>
@recurlySubscription = recurlySubscription
done()
afterEach ->
- RecurlyWrapper.apiRequest.restore()
+ @RecurlyWrapper.apiRequest.restore()
it "should send an update request to the API", ->
@apiRequest.called.should.equal true
@@ -275,59 +297,723 @@ describe "RecurlyWrapper", ->
it "should return the updated subscription", ->
should.exist @recurlySubscription
@recurlySubscription.plan.plan_code.should.equal "gold"
-
+
describe "cancelSubscription", ->
beforeEach (done) ->
@recurlySubscriptionId = "subscription-id-123"
- @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) =>
+ @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) =>
options.url.should.equal "subscriptions/#{@recurlySubscriptionId}/cancel"
options.method.should.equal "put"
callback()
- RecurlyWrapper.cancelSubscription(@recurlySubscriptionId, done)
+ @RecurlyWrapper.cancelSubscription(@recurlySubscriptionId, done)
afterEach ->
- RecurlyWrapper.apiRequest.restore()
+ @RecurlyWrapper.apiRequest.restore()
it "should send a cancel request to the API", ->
@apiRequest.called.should.equal true
-
+
describe "reactivateSubscription", ->
beforeEach (done) ->
@recurlySubscriptionId = "subscription-id-123"
- @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) =>
+ @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) =>
options.url.should.equal "subscriptions/#{@recurlySubscriptionId}/reactivate"
options.method.should.equal "put"
callback()
- RecurlyWrapper.reactivateSubscription(@recurlySubscriptionId, done)
+ @RecurlyWrapper.reactivateSubscription(@recurlySubscriptionId, done)
afterEach ->
- RecurlyWrapper.apiRequest.restore()
+ @RecurlyWrapper.apiRequest.restore()
it "should send a cancel request to the API", ->
@apiRequest.called.should.equal true
-
-
+
+
describe "redeemCoupon", ->
beforeEach (done) ->
@recurlyAccountId = "account-id-123"
@coupon_code = "312321312"
- @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) =>
+ @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) =>
options.url.should.equal "coupons/#{@coupon_code}/redeem"
options.body.indexOf("#{@recurlyAccountId}").should.not.equal -1
options.body.indexOf("USD").should.not.equal -1
options.method.should.equal "post"
callback()
- RecurlyWrapper.redeemCoupon(@recurlyAccountId, @coupon_code, done)
+ @RecurlyWrapper.redeemCoupon(@recurlyAccountId, @coupon_code, done)
afterEach ->
- RecurlyWrapper.apiRequest.restore()
+ @RecurlyWrapper.apiRequest.restore()
it "should send the request to redem the coupon", ->
@apiRequest.called.should.equal true
-
+
+ describe "_addressToXml", ->
+
+ beforeEach ->
+ @address =
+ address1: "addr_one"
+ address2: "addr_two"
+ country: "some_country"
+ state: "some_state"
+ postal_code: "some_zip"
+ nonsenseKey: "rubbish"
+
+ it 'should generate the correct xml', () ->
+ result = @RecurlyWrapper._addressToXml @address
+ should.equal(
+ result,
+ """
+
+ addr_one
+ addr_two
+ some_country
+ some_state
+ some_zip
+ \n
+ """
+ )
+
+ describe 'createSubscription', ->
+
+ beforeEach ->
+ @user =
+ _id: 'some_id'
+ email: 'user@example.com'
+ @subscriptionDetails =
+ currencyCode: "EUR"
+ plan_code: "some_plan_code"
+ coupon_code: ""
+ isPaypal: true
+ address:
+ address1: "addr_one"
+ address2: "addr_two"
+ country: "some_country"
+ state: "some_state"
+ zip: "some_zip"
+ @subscription = {}
+ @recurly_token_id = "a-token-id"
+ @call = (callback) =>
+ @RecurlyWrapper.createSubscription(@user, @subscriptionDetails, @recurly_token_id, callback)
+ describe 'when paypal', ->
+ beforeEach ->
+ @subscriptionDetails.isPaypal = true
+ @_createPaypalSubscription = sinon.stub(@RecurlyWrapper, '_createPaypalSubscription')
+ @_createPaypalSubscription.callsArgWith(3, null, @subscription)
+
+ afterEach ->
+ @_createPaypalSubscription.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.equal null
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should produce a subscription object', (done) ->
+ @call (err, sub) =>
+ expect(sub).to.deep.equal @subscription
+ done()
+
+ it 'should call _createPaypalSubscription', (done) ->
+ @call (err, sub) =>
+ @_createPaypalSubscription.callCount.should.equal 1
+ done()
+
+ describe "when _createPaypalSubscription produces an error", ->
+
+ beforeEach ->
+ @_createPaypalSubscription.callsArgWith(3, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when not paypal', ->
+
+ beforeEach ->
+ @subscriptionDetails.isPaypal = false
+ @_createCreditCardSubscription = sinon.stub(@RecurlyWrapper, '_createCreditCardSubscription')
+ @_createCreditCardSubscription.callsArgWith(3, null, @subscription)
+
+ afterEach ->
+ @_createCreditCardSubscription.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.equal null
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should produce a subscription object', (done) ->
+ @call (err, sub) =>
+ expect(sub).to.deep.equal @subscription
+ done()
+
+ it 'should call _createCreditCardSubscription', (done) ->
+ @call (err, sub) =>
+ @_createCreditCardSubscription.callCount.should.equal 1
+ done()
+
+ describe "when _createCreditCardSubscription produces an error", ->
+
+ beforeEach ->
+ @_createCreditCardSubscription.callsArgWith(3, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+
+ describe '_createCreditCardSubscription', ->
+
+ beforeEach ->
+ @user =
+ _id: 'some_id'
+ email: 'user@example.com'
+ @subscriptionDetails =
+ currencyCode: "EUR"
+ plan_code: "some_plan_code"
+ coupon_code: ""
+ isPaypal: true
+ address:
+ address1: "addr_one"
+ address2: "addr_two"
+ country: "some_country"
+ state: "some_state"
+ zip: "some_zip"
+ @subscription = {}
+ @recurly_token_id = "a-token-id"
+ @apiRequest = sinon.stub(@RecurlyWrapper, 'apiRequest')
+ @response =
+ statusCode: 200
+ @body = "is_bad"
+ @apiRequest.callsArgWith(1, null, @response, @body)
+ @_parseSubscriptionXml = sinon.stub(@RecurlyWrapper, '_parseSubscriptionXml')
+ @_parseSubscriptionXml.callsArgWith(1, null, @subscription)
+ @call = (callback) =>
+ @RecurlyWrapper._createCreditCardSubscription(@user, @subscriptionDetails, @recurly_token_id, callback)
+
+ afterEach ->
+ @apiRequest.restore()
+ @_parseSubscriptionXml.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should produce a subscription', (done) ->
+ @call (err, sub) =>
+ expect(sub).to.equal @subscription
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, sub) =>
+ @apiRequest.callCount.should.equal 1
+ done()
+
+ it 'should call _parseSubscriptionXml', (done) ->
+ @call (err, sub) =>
+ @_parseSubscriptionXml.callCount.should.equal 1
+ done()
+
+ describe 'when api request produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, sub) =>
+ @apiRequest.callCount.should.equal 1
+ done()
+
+ it 'should not _parseSubscriptionXml', (done) ->
+ @call (err, sub) =>
+ @_parseSubscriptionXml.callCount.should.equal 0
+ done()
+
+ describe 'when parse xml produces an error', ->
+
+ beforeEach ->
+ @_parseSubscriptionXml.callsArgWith(1, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe '_createPaypalSubscription', ->
+
+ beforeEach ->
+ @checkAccountExists = sinon.stub(@RecurlyWrapper._paypal, 'checkAccountExists')
+ @createAccount = sinon.stub(@RecurlyWrapper._paypal, 'createAccount')
+ @createBillingInfo = sinon.stub(@RecurlyWrapper._paypal, 'createBillingInfo')
+ @setAddress = sinon.stub(@RecurlyWrapper._paypal, 'setAddress')
+ @createSubscription = sinon.stub(@RecurlyWrapper._paypal, 'createSubscription')
+ @user =
+ _id: 'some_id'
+ email: 'user@example.com'
+ @subscriptionDetails =
+ currencyCode: "EUR"
+ plan_code: "some_plan_code"
+ coupon_code: ""
+ isPaypal: true
+ address:
+ address1: "addr_one"
+ address2: "addr_two"
+ country: "some_country"
+ state: "some_state"
+ zip: "some_zip"
+ @subscription = {}
+ @recurly_token_id = "a-token-id"
+
+ # set up data callbacks
+ user = @user
+ subscriptionDetails = @subscriptionDetails
+ recurly_token_id = @recurly_token_id
+
+ @checkAccountExists.callsArgWith(1, null,
+ {user, subscriptionDetails, recurly_token_id,
+ userExists: false, account: {accountCode: 'xx'}}
+ )
+ @createAccount.callsArgWith(1, null,
+ {user, subscriptionDetails, recurly_token_id,
+ userExists: false, account: {accountCode: 'xx'}}
+ )
+ @createBillingInfo.callsArgWith(1, null,
+ {user, subscriptionDetails, recurly_token_id,
+ userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}}
+ )
+ @setAddress.callsArgWith(1, null,
+ {user, subscriptionDetails, recurly_token_id,
+ userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}}
+ )
+ @createSubscription.callsArgWith(1, null,
+ {user, subscriptionDetails, recurly_token_id,
+ userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}, subscription: @subscription}
+ )
+
+ @call = (callback) =>
+ @RecurlyWrapper._createPaypalSubscription @user, @subscriptionDetails, @recurly_token_id, callback
+
+ afterEach ->
+ @checkAccountExists.restore()
+ @createAccount.restore()
+ @createBillingInfo.restore()
+ @setAddress.restore()
+ @createSubscription.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should produce a subscription object', (done) ->
+ @call (err, sub) =>
+ expect(sub).to.not.equal null
+ expect(sub).to.equal @subscription
+ done()
+
+ it 'should call each of the paypal stages', (done) ->
+ @call (err, sub) =>
+ @checkAccountExists.callCount.should.equal 1
+ @createAccount.callCount.should.equal 1
+ @createBillingInfo.callCount.should.equal 1
+ @setAddress.callCount.should.equal 1
+ @createSubscription.callCount.should.equal 1
+ done()
+
+ describe 'when one of the paypal stages produces an error', ->
+
+ beforeEach ->
+ @createAccount.callsArgWith(1, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err, sub) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ it 'should stop calling the paypal stages after the error', (done) ->
+ @call (err, sub) =>
+ @checkAccountExists.callCount.should.equal 1
+ @createAccount.callCount.should.equal 1
+ @createBillingInfo.callCount.should.equal 0
+ @setAddress.callCount.should.equal 0
+ @createSubscription.callCount.should.equal 0
+ done()
+
+ describe 'paypal actions', ->
+
+ beforeEach ->
+ @apiRequest = sinon.stub(@RecurlyWrapper, 'apiRequest')
+ @_parseAccountXml = sinon.spy(@RecurlyWrapper, '_parseAccountXml')
+ @_parseBillingInfoXml = sinon.spy(@RecurlyWrapper, '_parseBillingInfoXml')
+ @_parseSubscriptionXml = sinon.spy(@RecurlyWrapper, '_parseSubscriptionXml')
+ @cache =
+ user: @user = {_id: 'some_id'}
+ recurly_token_id: @recurly_token_id = "some_token"
+ subscriptionDetails: @subscriptionDetails =
+ currencyCode: "EUR"
+ plan_code: "some_plan_code"
+ coupon_code: ""
+ isPaypal: true
+ address:
+ address1: "addr_one"
+ address2: "addr_two"
+ country: "some_country"
+ state: "some_state"
+ zip: "some_zip"
+
+ afterEach ->
+ @apiRequest.restore()
+ @_parseAccountXml.restore()
+ @_parseBillingInfoXml.restore()
+ @_parseSubscriptionXml.restore()
+
+ describe '_paypal.checkAccountExists', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @RecurlyWrapper._paypal.checkAccountExists @cache, callback
+
+ describe 'when the account exists', ->
+
+ beforeEach ->
+ resultXml = 'abc'
+ @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml)
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ done()
+
+ it 'should call _parseAccountXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseAccountXml.callCount.should.equal 1
+ done()
+
+ it 'should add the account to the cumulative result', (done) ->
+ @call (err, result) =>
+ expect(result.account).to.not.equal null
+ expect(result.account).to.not.equal undefined
+ expect(result.account).to.deep.equal {
+ account_code: 'abc'
+ }
+ done()
+
+ it 'should set userExists to true', (done) ->
+ @call (err, result) =>
+ expect(result.userExists).to.equal true
+ done()
+
+ describe 'when the account does not exist', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('not found'), {statusCode: 404}, '')
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ @apiRequest.firstCall.args[0].method.should.equal 'GET'
+ done()
+
+ it 'should not call _parseAccountXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseAccountXml.callCount.should.equal 0
+ done()
+
+ it 'should not add the account to result', (done) ->
+ @call (err, result) =>
+ expect(result.account).to.equal undefined
+ done()
+
+ it 'should set userExists to false', (done) ->
+ @call (err, result) =>
+ expect(result.userExists).to.equal false
+ done()
+
+ describe 'when apiRequest produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500})
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe '_paypal.createAccount', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @RecurlyWrapper._paypal.createAccount @cache, callback
+
+ describe 'when address is missing from subscriptionDetails', ->
+
+ beforeEach ->
+ @cache.subscriptionDetails.address = null
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when account already exists', ->
+
+ beforeEach ->
+ @cache.userExists = true
+ @cache.account =
+ account_code: 'abc'
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should produce cache object', (done) ->
+ @call (err, result) =>
+ expect(result).to.deep.equal @cache
+ expect(result.account).to.deep.equal {
+ account_code: 'abc'
+ }
+ done()
+
+ it 'should not call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 0
+ done()
+
+ it 'should not call _parseAccountXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseAccountXml.callCount.should.equal 0
+ done()
+
+ describe 'when account does not exist', ->
+
+ beforeEach ->
+ @cache.userExists = false
+ resultXml = 'abc'
+ @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml)
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ @apiRequest.firstCall.args[0].method.should.equal 'POST'
+ done()
+
+ it 'should call _parseAccountXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseAccountXml.callCount.should.equal 1
+ done()
+
+ describe 'when apiRequest produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500})
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe '_paypal.createBillingInfo', ->
+
+ beforeEach ->
+ @cache.account =
+ account_code: 'abc'
+ @call = (callback) =>
+ @RecurlyWrapper._paypal.createBillingInfo @cache, callback
+
+ describe 'when account_code is missing from cache', ->
+
+ beforeEach ->
+ @cache.account.account_code = null
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when all goes well', ->
+
+ beforeEach ->
+ resultXml = '1'
+ @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml)
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ @apiRequest.firstCall.args[0].method.should.equal 'POST'
+ done()
+
+ it 'should call _parseBillingInfoXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseBillingInfoXml.callCount.should.equal 1
+ done()
+
+ it 'should set billingInfo on cache', (done) ->
+ @call (err, result) =>
+ expect(result.billingInfo).to.deep.equal {
+ a: "1"
+ }
+ done()
+
+ describe 'when apiRequest produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500})
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe '_paypal.setAddress', ->
+
+ beforeEach ->
+ @cache.account =
+ account_code: 'abc'
+ @cache.billingInfo = {}
+ @call = (callback) =>
+ @RecurlyWrapper._paypal.setAddress @cache, callback
+
+ describe 'when account_code is missing from cache', ->
+
+ beforeEach ->
+ @cache.account.account_code = null
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when address is missing from subscriptionDetails', ->
+
+ beforeEach ->
+ @cache.subscriptionDetails.address = null
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when all goes well', ->
+
+ beforeEach ->
+ resultXml = 'London'
+ @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml)
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ @apiRequest.firstCall.args[0].method.should.equal 'PUT'
+ done()
+
+ it 'should call _parseBillingInfoXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseBillingInfoXml.callCount.should.equal 1
+ done()
+
+ it 'should set billingInfo on cache', (done) ->
+ @call (err, result) =>
+ expect(result.billingInfo).to.deep.equal {
+ city: 'London'
+ }
+ done()
+
+ describe 'when apiRequest produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500})
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe '_paypal.createSubscription', ->
+
+ beforeEach ->
+ @cache.account =
+ account_code: 'abc'
+ @cache.billingInfo = {}
+ @call = (callback) =>
+ @RecurlyWrapper._paypal.createSubscription @cache, callback
+
+ describe 'when all goes well', ->
+
+ beforeEach ->
+ resultXml = '1'
+ @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml)
+
+ it 'should not produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call apiRequest', (done) ->
+ @call (err, result) =>
+ @apiRequest.callCount.should.equal 1
+ @apiRequest.firstCall.args[0].method.should.equal 'POST'
+ done()
+
+ it 'should call _parseSubscriptionXml', (done) ->
+ @call (err, result) =>
+ @RecurlyWrapper._parseSubscriptionXml.callCount.should.equal 1
+ done()
+
+ it 'should set subscription on cache', (done) ->
+ @call (err, result) =>
+ expect(result.subscription).to.deep.equal {
+ a: "1"
+ }
+ done()
+
+ describe 'when apiRequest produces an error', ->
+
+ beforeEach ->
+ @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500})
+
+ it 'should produce an error', (done) ->
+ @call (err, result) =>
+ expect(err).to.be.instanceof Error
+ done()
diff --git a/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee b/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
index 829cbf17fb..19d17e7ac3 100644
--- a/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
+++ b/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
@@ -19,9 +19,9 @@ describe "UserController", ->
save:sinon.stub().callsArgWith(0)
ace:{}
- @UserDeleter =
+ @UserDeleter =
deleteUser: sinon.stub().callsArgWith(1)
- @UserLocator =
+ @UserLocator =
findById: sinon.stub().callsArgWith(1, null, @user)
@User =
findById: sinon.stub().callsArgWith(1, null, @user)
@@ -36,14 +36,18 @@ describe "UserController", ->
setUserPassword: sinon.stub()
@ReferalAllocator =
allocate:sinon.stub()
- @SubscriptionDomainHandler =
+ @SubscriptionDomainHandler =
autoAllocate:sinon.stub()
@UserUpdater =
changeEmailAddress:sinon.stub()
@settings =
siteUrl: "sharelatex.example.com"
- @UserHandler =
+ @UserHandler =
populateGroupLicenceInvite:sinon.stub().callsArgWith(1)
+ @UserSessionsManager =
+ trackSession: sinon.stub()
+ untrackSession: sinon.stub()
+ revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
@UserController = SandboxedModule.require modulePath, requires:
"./UserLocator": @UserLocator
"./UserDeleter": @UserDeleter
@@ -56,15 +60,19 @@ describe "UserController", ->
"../Referal/ReferalAllocator":@ReferalAllocator
"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
"./UserHandler":@UserHandler
+ "./UserSessionsManager": @UserSessionsManager
"settings-sharelatex": @settings
- "logger-sharelatex": {log:->}
+ "logger-sharelatex":
+ log:->
+ err:->
"../../infrastructure/Metrics": inc:->
- @req =
- session:
+ @req =
+ session:
destroy:->
user :
_id : @user_id
+ email:"old@something.com"
body:{}
@res =
send: sinon.stub()
@@ -154,6 +162,20 @@ describe "UserController", ->
done()
@UserController.updateUserSettings @req, @res
+ it "should update the email on the session", (done)->
+ @req.body.email = @newEmail.toUpperCase()
+ @UserUpdater.changeEmailAddress.callsArgWith(2)
+ callcount = 0
+ @User.findById = (id, cb)=>
+ if ++callcount == 2
+ @user.email = @newEmail
+ cb(null, @user)
+ @res.sendStatus = (code)=>
+ code.should.equal 200
+ @req.session.user.email.should.equal @newEmail
+ done()
+ @UserController.updateUserSettings @req, @res
+
it "should call populateGroupLicenceInvite", (done)->
@req.body.email = @newEmail.toUpperCase()
@UserUpdater.changeEmailAddress.callsArgWith(2)
@@ -181,12 +203,12 @@ describe "UserController", ->
@UserRegistrationHandler.registerNewUserAndSendActivationEmail = sinon.stub().callsArgWith(1, null, @user, @url = "mock/url")
@req.body.email = @user.email = @email = "email@example.com"
@UserController.register @req, @res
-
+
it "should register the user and send them an email", ->
@UserRegistrationHandler.registerNewUserAndSendActivationEmail
.calledWith(@email)
.should.equal true
-
+
it "should return the user and activation url", ->
@res.json
.calledWith({
@@ -216,7 +238,7 @@ describe "UserController", ->
@res.send = =>
@AuthenticationManager.setUserPassword.called.should.equal false
done()
- @UserController.changePassword @req, @res
+ @UserController.changePassword @req, @res
it "should set the new password if they do match", (done)->
@AuthenticationManager.authenticate.callsArgWith(2, null, @user)
@@ -228,5 +250,3 @@ describe "UserController", ->
@AuthenticationManager.setUserPassword.calledWith(@user._id, "newpass").should.equal true
done()
@UserController.changePassword @req, @res
-
-
diff --git a/services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee b/services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee
index 5088075656..c2f4ea292f 100644
--- a/services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee
+++ b/services/web/test/UnitTests/coffee/User/UserCreatorTests.coffee
@@ -20,6 +20,7 @@ describe "UserCreator", ->
@UserCreator = SandboxedModule.require modulePath, requires:
"../../models/User": User:@UserModel
"./UserLocator":@UserLocator
+ "logger-sharelatex":{log:->}
@email = "bob.oswald@gmail.com"
@@ -53,5 +54,36 @@ describe "UserCreator", ->
assert.equal user.first_name, "bob.oswald"
done()
+ it "should use the start of the email if the first name is empty string", (done)->
+ opts =
+ email:@email
+ holdingAccount:true
+ first_name:""
+ @UserCreator.createNewUser opts, (err, user)=>
+ assert.equal user.email, @email
+ assert.equal user.holdingAccount, true
+ assert.equal user.first_name, "bob.oswald"
+ done()
+ it "should use the first name if passed", (done)->
+ opts =
+ email:@email
+ holdingAccount:true
+ first_name:"fiiirstname"
+ @UserCreator.createNewUser opts, (err, user)=>
+ assert.equal user.email, @email
+ assert.equal user.holdingAccount, true
+ assert.equal user.first_name, "fiiirstname"
+ done()
+
+ it "should use the last name if passed", (done)->
+ opts =
+ email:@email
+ holdingAccount:true
+ last_name:"lastNammmmeee"
+ @UserCreator.createNewUser opts, (err, user)=>
+ assert.equal user.email, @email
+ assert.equal user.holdingAccount, true
+ assert.equal user.last_name, "lastNammmmeee"
+ done()
diff --git a/services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee
index 60ae1719a7..ec220e27f8 100644
--- a/services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee
+++ b/services/web/test/UnitTests/coffee/User/UserRegistrationHandlerTests.coffee
@@ -106,19 +106,19 @@ describe "UserRegistrationHandler", ->
it "should create a new user", (done)->
@handler.registerNewUser @passingRequest, (err)=>
- @UserCreator.createNewUser.calledWith({email:@passingRequest.email, holdingAccount:false}).should.equal true
+ @UserCreator.createNewUser.calledWith({email:@passingRequest.email, holdingAccount:false, first_name:@passingRequest.first_name, last_name:@passingRequest.last_name}).should.equal true
done()
it 'lower case email', (done)->
@passingRequest.email = "soMe@eMail.cOm"
@handler.registerNewUser @passingRequest, (err)=>
- @UserCreator.createNewUser.calledWith({email:@passingRequest.email.toLowerCase(), holdingAccount:false}).should.equal true
+ @UserCreator.createNewUser.args[0][0].email.should.equal "some@email.com"
done()
it 'trim white space from email', (done)->
@passingRequest.email = " some@email.com "
@handler.registerNewUser @passingRequest, (err)=>
- @UserCreator.createNewUser.calledWith({email:"some@email.com", holdingAccount:false}).should.equal true
+ @UserCreator.createNewUser.args[0][0].email.should.equal "some@email.com"
done()
diff --git a/services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee b/services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
new file mode 100644
index 0000000000..9a09aa88f0
--- /dev/null
+++ b/services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
@@ -0,0 +1,484 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/User/UserSessionsManager.js"
+SandboxedModule = require('sandboxed-module')
+
+describe 'UserSessionsManager', ->
+
+ beforeEach ->
+ @user =
+ _id: "abcd"
+ email: "user@example.com"
+ @sessionId = 'some_session_id'
+
+ @rclient =
+ multi: sinon.stub()
+ exec: sinon.stub()
+ get: sinon.stub()
+ del: sinon.stub()
+ sadd: sinon.stub()
+ srem: sinon.stub()
+ smembers: sinon.stub()
+ expire: sinon.stub()
+ @rclient.multi.returns(@rclient)
+ @rclient.get.returns(@rclient)
+ @rclient.del.returns(@rclient)
+ @rclient.sadd.returns(@rclient)
+ @rclient.srem.returns(@rclient)
+ @rclient.smembers.returns(@rclient)
+ @rclient.expire.returns(@rclient)
+ @rclient.exec.callsArgWith(0, null)
+
+ @redis =
+ createClient: () => @rclient
+ @logger =
+ err: sinon.stub()
+ error: sinon.stub()
+ log: sinon.stub()
+ @settings =
+ redis:
+ web: {}
+ @UserSessionsManager = SandboxedModule.require modulePath, requires:
+ "redis-sharelatex": @redis
+ "logger-sharelatex": @logger
+ "settings-sharelatex": @settings
+
+ describe '_sessionSetKey', ->
+
+ it 'should build the correct key', ->
+ result = @UserSessionsManager._sessionSetKey(@user)
+ result.should.equal 'UserSessions:abcd'
+
+ describe '_sessionKey', ->
+
+ it 'should build the correct key', ->
+ result = @UserSessionsManager._sessionKey(@sessionId)
+ result.should.equal 'sess:some_session_id'
+
+ describe 'trackSession', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.trackSession @user, @sessionId, callback
+ @rclient.exec.callsArgWith(0, null)
+ @_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+ afterEach ->
+ @_checkSessions.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ done()
+
+ it 'should call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 1
+ @rclient.sadd.callCount.should.equal 1
+ @rclient.expire.callCount.should.equal 1
+ @rclient.exec.callCount.should.equal 1
+ done()
+
+ it 'should call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 1
+ done()
+
+ describe 'when rclient produces an error', ->
+
+ beforeEach ->
+ @rclient.exec.callsArgWith(0, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ describe 'when no user is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.trackSession null, @sessionId, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 0
+ @rclient.sadd.callCount.should.equal 0
+ @rclient.expire.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ describe 'when no sessionId is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.trackSession @user, null, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 0
+ @rclient.sadd.callCount.should.equal 0
+ @rclient.expire.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ describe 'untrackSession', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.untrackSession @user, @sessionId, callback
+ @rclient.exec.callsArgWith(0, null)
+ @_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+ afterEach ->
+ @_checkSessions.restore()
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal undefined
+ done()
+
+ it 'should call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 1
+ @rclient.srem.callCount.should.equal 1
+ @rclient.expire.callCount.should.equal 1
+ @rclient.exec.callCount.should.equal 1
+ done()
+
+ it 'should call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 1
+ done()
+
+ describe 'when rclient produces an error', ->
+
+ beforeEach ->
+ @rclient.exec.callsArgWith(0, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ describe 'when no user is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.untrackSession null, @sessionId, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 0
+ @rclient.srem.callCount.should.equal 0
+ @rclient.expire.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ describe 'when no sessionId is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.untrackSession @user, null, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.multi.callCount.should.equal 0
+ @rclient.srem.callCount.should.equal 0
+ @rclient.expire.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ it 'should not call _checkSessions', (done) ->
+ @call (err) =>
+ @_checkSessions.callCount.should.equal 0
+ done()
+
+ ##
+ describe 'revokeAllUserSessions', ->
+
+ beforeEach ->
+ @sessionKeys = ['sess:one', 'sess:two']
+ @retain = []
+ @rclient.smembers.callsArgWith(1, null, @sessionKeys)
+ @rclient.exec.callsArgWith(0, null)
+ @call = (callback) =>
+ @UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.multi.callCount.should.equal 1
+
+ @rclient.del.callCount.should.equal 1
+ expect(@rclient.del.firstCall.args[0]).to.deep.equal @sessionKeys
+
+ @rclient.srem.callCount.should.equal 1
+ expect(@rclient.srem.firstCall.args[1]).to.deep.equal @sessionKeys
+
+ @rclient.exec.callCount.should.equal 1
+ done()
+
+ describe 'when a session is retained', ->
+
+ beforeEach ->
+ @sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four']
+ @retain = ['two']
+ @rclient.smembers.callsArgWith(1, null, @sessionKeys)
+ @rclient.exec.callsArgWith(0, null)
+ @call = (callback) =>
+ @UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.multi.callCount.should.equal 1
+ @rclient.del.callCount.should.equal 1
+ @rclient.srem.callCount.should.equal 1
+ @rclient.exec.callCount.should.equal 1
+ done()
+
+ it 'should remove all sessions except for the retained one', (done) ->
+ @call (err) =>
+ expect(@rclient.del.firstCall.args[0]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+ expect(@rclient.srem.firstCall.args[1]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+ done()
+
+ describe 'when rclient produces an error', ->
+
+ beforeEach ->
+ @rclient.exec.callsArgWith(0, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when no user is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.revokeAllUserSessions null, @retain, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 0
+ @rclient.multi.callCount.should.equal 0
+ @rclient.del.callCount.should.equal 0
+ @rclient.srem.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ describe 'when there are no keys to delete', ->
+
+ beforeEach ->
+ @rclient.smembers.callsArgWith(1, null, [])
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not do the delete operation', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.multi.callCount.should.equal 0
+ @rclient.del.callCount.should.equal 0
+ @rclient.srem.callCount.should.equal 0
+ @rclient.exec.callCount.should.equal 0
+ done()
+
+ describe 'touch', ->
+
+ beforeEach ->
+ @rclient.expire.callsArgWith(2, null)
+ @call = (callback) =>
+ @UserSessionsManager.touch @user, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should call rclient.expire', (done) ->
+ @call (err) =>
+ @rclient.expire.callCount.should.equal 1
+ done()
+
+ describe 'when rclient produces an error', ->
+
+ beforeEach ->
+ @rclient.expire.callsArgWith(2, new Error('woops'))
+
+ it 'should produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ describe 'when no user is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager.touch null, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call expire', (done) ->
+ @call (err) =>
+ @rclient.expire.callCount.should.equal 0
+ done()
+
+ describe '_checkSessions', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager._checkSessions @user, callback
+ @sessionKeys = ['one', 'two']
+ @rclient.smembers.callsArgWith(1, null, @sessionKeys)
+ @rclient.get.callsArgWith(1, null, 'some-value')
+ @rclient.srem.callsArgWith(2, null, {})
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal undefined
+ done()
+
+ it 'should call the appropriate redis methods', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.get.callCount.should.equal 2
+ @rclient.srem.callCount.should.equal 0
+ done()
+
+ describe 'when one of the keys is not present in redis', ->
+
+ beforeEach ->
+ @rclient.get.onCall(0).callsArgWith(1, null, 'some-val')
+ @rclient.get.onCall(1).callsArgWith(1, null, null)
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal undefined
+ done()
+
+ it 'should remove that key from the set', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.get.callCount.should.equal 2
+ @rclient.srem.callCount.should.equal 1
+ @rclient.srem.firstCall.args[1].should.equal 'two'
+ done()
+
+ describe 'when no user is supplied', ->
+
+ beforeEach ->
+ @call = (callback) =>
+ @UserSessionsManager._checkSessions null, callback
+
+ it 'should not produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.not.be.instanceof Error
+ expect(err).to.equal null
+ done()
+
+ it 'should not call redis methods', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 0
+ @rclient.get.callCount.should.equal 0
+ done()
+
+ describe 'when one of the get operations produces an error', ->
+
+ beforeEach ->
+ @rclient.get.onCall(0).callsArgWith(1, new Error('woops'), null)
+ @rclient.get.onCall(1).callsArgWith(1, null, null)
+
+ it 'should produce an error', (done) ->
+ @call (err) =>
+ expect(err).to.be.instanceof Error
+ done()
+
+ it 'should call the right redis methods, bailing out early', (done) ->
+ @call (err) =>
+ @rclient.smembers.callCount.should.equal 1
+ @rclient.get.callCount.should.equal 1
+ @rclient.srem.callCount.should.equal 0
+ done()
diff --git a/services/web/test/acceptance/coffee/SessionTests.coffee b/services/web/test/acceptance/coffee/SessionTests.coffee
new file mode 100644
index 0000000000..cff5b66406
--- /dev/null
+++ b/services/web/test/acceptance/coffee/SessionTests.coffee
@@ -0,0 +1,253 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+redis = require "./helpers/redis"
+
+describe "Sessions", ->
+ before (done) ->
+ @timeout(20000)
+ @user1 = new User()
+ @site_admin = new User({email: "admin@example.com"})
+ async.series [
+ (cb) => @user1.login cb
+ (cb) => @user1.logout cb
+ ], done
+
+ describe "one session", ->
+
+ it "should have one session in UserSessions set", (done) ->
+ async.series(
+ [
+ (next) =>
+ redis.clearUserSessions @user1, next
+
+ # login, should add session to set
+ , (next) =>
+ @user1.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 1
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # should be able to access settings page
+ , (next) =>
+ @user1.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 200
+ next()
+
+ # logout, should remove session from set
+ , (next) =>
+ @user1.logout (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 0
+ next()
+
+ ], (err, result) =>
+ if err
+ throw err
+ done()
+ )
+
+ describe "two sessions", ->
+
+ before ->
+ # set up second session for this user
+ @user2 = new User()
+ @user2.email = @user1.email
+ @user2.password = @user1.password
+
+ it "should have two sessions in UserSessions set", (done) ->
+ async.series(
+ [
+ (next) =>
+ redis.clearUserSessions @user1, next
+
+ # login, should add session to set
+ , (next) =>
+ @user1.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 1
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # login again, should add the second session to set
+ , (next) =>
+ @user2.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 2
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # both should be able to access settings page
+ , (next) =>
+ @user1.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 200
+ next()
+
+ , (next) =>
+ @user2.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 200
+ next()
+
+ # logout first session, should remove session from set
+ , (next) =>
+ @user1.logout (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 1
+ next()
+
+ # first session should not have access to settings page
+ , (next) =>
+ @user1.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 302
+ next()
+
+ # second session should still have access to settings
+ , (next) =>
+ @user2.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 200
+ next()
+
+ # logout second session, should remove last session from set
+ , (next) =>
+ @user2.logout (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 0
+ next()
+
+ # second session should not have access to settings page
+ , (next) =>
+ @user2.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 302
+ next()
+
+ ], (err, result) =>
+ if err
+ throw err
+ done()
+ )
+
+ describe 'three sessions, password reset', ->
+
+ before ->
+ # set up second session for this user
+ @user2 = new User()
+ @user2.email = @user1.email
+ @user2.password = @user1.password
+ @user3 = new User()
+ @user3.email = @user1.email
+ @user3.password = @user1.password
+
+ it "should erase both sessions when password is reset", (done) ->
+ async.series(
+ [
+ (next) =>
+ redis.clearUserSessions @user1, next
+
+ # login, should add session to set
+ , (next) =>
+ @user1.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 1
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # login again, should add the second session to set
+ , (next) =>
+ @user2.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 2
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # login third session, should add the second session to set
+ , (next) =>
+ @user3.login (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 3
+ expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+ expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+ next()
+
+ # password reset from second session, should erase two of the three sessions
+ , (next) =>
+ @user2.changePassword (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user2, (err, sessions) =>
+ expect(sessions.length).to.equal 1
+ next()
+
+ # users one and three should not be able to access settings page
+ , (next) =>
+ @user1.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 302
+ next()
+
+ , (next) =>
+ @user3.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 302
+ next()
+
+ # user two should still be logged in, and able to access settings page
+ , (next) =>
+ @user2.getUserSettingsPage (err, statusCode) =>
+ expect(err).to.equal null
+ expect(statusCode).to.equal 200
+ next()
+
+ # logout second session, should remove last session from set
+ , (next) =>
+ @user2.logout (err) ->
+ next(err)
+
+ , (next) =>
+ redis.getUserSessions @user1, (err, sessions) =>
+ expect(sessions.length).to.equal 0
+ next()
+
+ ], (err, result) =>
+ if err
+ throw err
+ done()
+ )
diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee
index c13a45499d..888473578e 100644
--- a/services/web/test/acceptance/coffee/helpers/User.coffee
+++ b/services/web/test/acceptance/coffee/helpers/User.coffee
@@ -27,11 +27,28 @@ class User
db.users.findOne {email: @email}, (error, user) =>
return callback(error) if error?
@id = user?._id?.toString()
+ @_id = user?._id?.toString()
callback()
-
+
+ logout: (callback = (error) ->) ->
+ @getCsrfToken (error) =>
+ return callback(error) if error?
+ @request.get {
+ url: "/logout"
+ json:
+ email: @email
+ password: @password
+ }, (error, response, body) =>
+ return callback(error) if error?
+ db.users.findOne {email: @email}, (error, user) =>
+ return callback(error) if error?
+ @id = user?._id?.toString()
+ @_id = user?._id?.toString()
+ callback()
+
ensure_admin: (callback = (error) ->) ->
db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
-
+
createProject: (name, callback = (error, project_id) ->) ->
@request.post {
url: "/project/new",
@@ -74,4 +91,30 @@ class User
})
callback()
-module.exports = User
\ No newline at end of file
+ changePassword: (callback = (error) ->) ->
+ @getCsrfToken (error) =>
+ return callback(error) if error?
+ @request.post {
+ url: "/user/password/update"
+ json:
+ currentPassword: @password
+ newPassword1: @password
+ newPassword2: @password
+ }, (error, response, body) =>
+ return callback(error) if error?
+ db.users.findOne {email: @email}, (error, user) =>
+ return callback(error) if error?
+ callback()
+
+ getUserSettingsPage: (callback = (error, statusCode) ->) ->
+ @getCsrfToken (error) =>
+ return callback(error) if error?
+ @request.get {
+ url: "/user/settings"
+ }, (error, response, body) =>
+ return callback(error) if error?
+ callback(null, response.statusCode)
+
+
+
+module.exports = User
diff --git a/services/web/test/acceptance/coffee/helpers/redis.coffee b/services/web/test/acceptance/coffee/helpers/redis.coffee
new file mode 100644
index 0000000000..2611e4fc57
--- /dev/null
+++ b/services/web/test/acceptance/coffee/helpers/redis.coffee
@@ -0,0 +1,27 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports =
+
+ getUserSessions: (user, callback=(err, sessionsSet)->) ->
+ rclient.smembers "UserSessions:#{user._id}", (err, result) ->
+ return callback(err, result)
+
+ clearUserSessions: (user, callback=(err)->) ->
+ sessionSetKey = "UserSessions:#{user._id}"
+ rclient.smembers sessionSetKey, (err, sessionKeys) ->
+ if err
+ return callback(err)
+ if sessionKeys.length == 0
+ return callback(null)
+ rclient.multi()
+ .del(sessionKeys)
+ .srem(sessionSetKey, sessionKeys)
+ .exec (err, result) ->
+ if err
+ return callback(err)
+ callback(null)