Merge branch 'master' into pr-v2-history-ui

This commit is contained in:
Paulo Reis 2018-05-30 11:24:33 +01:00
commit 637c492e6e
120 changed files with 5320 additions and 3091 deletions

View file

@ -101,7 +101,7 @@ pipeline {
}
}
steps {
sh 'make minify'
sh 'WEBPACK_ENV=production make minify'
}
}

View file

@ -9,7 +9,7 @@ COFFEE := node_modules/.bin/coffee $(COFFEE_OPTIONS)
GRUNT := node_modules/.bin/grunt
APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee')
FRONT_END_COFFEE_FILES := $(shell find public/coffee -name '*.coffee')
TEST_COFFEE_FILES := $(shell find test -name '*.coffee')
TEST_COFFEE_FILES := $(shell find test/*/coffee -name '*.coffee')
MODULE_MAIN_COFFEE_FILES := $(shell find modules -type f -wholename '*main/index.coffee')
MODULE_IDE_COFFEE_FILES := $(shell find modules -type f -wholename '*ide/index.coffee')
COFFEE_FILES := app.coffee $(APP_COFFEE_FILES) $(FRONT_END_COFFEE_FILES) $(TEST_COFFEE_FILES)
@ -187,6 +187,9 @@ test: test_unit test_frontend test_acceptance
test_unit:
npm -q run test:unit -- ${MOCHA_ARGS}
test_unit_app:
npm -q run test:unit:app -- ${MOCHA_ARGS}
test_frontend: test_clean # stop service
$(MAKE) compile
docker-compose ${DOCKER_COMPOSE_FLAGS} up test_frontend

View file

@ -3,11 +3,11 @@ web-sharelatex
web-sharelatex is the front-end web service of the open-source web-based collaborative LaTeX editor,
[ShareLaTeX](https://www.sharelatex.com).
It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains
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
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
@ -20,7 +20,7 @@ Image processing tasks are commented out in the gruntfile and the needed package
New Docker-based build process
------------------------------
Note that the Grunt workflow from above should still work, but we are transitioning to a
Note that the Grunt workflow from above should still work, but we are transitioning to a
Docker based testing workflow, which is documented below:
### Running the app
@ -43,7 +43,6 @@ Unit tests can be run in the `test_unit` container defined in `docker-compose.te
The makefile contains a short cut to run these:
```
make install # Only needs running once, or when npm packages are updated
make unit_test
```
@ -60,19 +59,18 @@ Acceptance tests are run against a live service, which runs in the `acceptance_t
To run the tests out-of-the-box, the makefile defines:
```
make install # Only needs running once, or when npm packages are updated
make acceptance_test
make test_acceptance
```
However, during development it is often useful to leave the service running for rapid iteration on the acceptance tests. This can be done with:
```
make acceptance_test_start_service
make acceptance_test_run # Run as many times as needed during development
make acceptance_test_stop_service
make test_acceptance_app_start_service
make test_acceptance_app_run # Run as many times as needed during development
make test_acceptance_app_stop_service
```
`make acceptance_test` just runs these three commands in sequence.
`make test_acceptance` just runs these three commands in sequence and then runs `make test_acceptance_modules` which performs the tests for each module in the `modules` directory. (Note that there is not currently an equivalent to the `-start` / `-run` x _n_ / `-stop` series for modules.)
During development it is often useful to only run a subset of tests, which can be configured with arguments to the mocha CLI:
@ -112,12 +110,3 @@ We gratefully acknowledge [IconShock](http://www.iconshock.com) for use of the i
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`

View file

@ -1,5 +1,5 @@
BetaProgramHandler = require './BetaProgramHandler'
UserLocator = require "../User/UserLocator"
UserGetter = require "../User/UserGetter"
Settings = require "settings-sharelatex"
logger = require 'logger-sharelatex'
AuthenticationController = require '../Authentication/AuthenticationController'
@ -30,7 +30,7 @@ module.exports = BetaProgramController =
optInPage: (req, res, next)->
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log {user_id}, "showing beta participation page for user"
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
if err
logger.err {err, user_id}, "error fetching user"
return next(err)

View file

@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
if Settings.restrictInvitesToExistingAccounts == true
logger.log {email}, "checking if user exists with this email"
UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
return callback(err) if err?
userExists = user? and user?._id?
callback(null, userExists)

View file

@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
email = invite.email
UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
if err?
logger.err {projectId, email}, "error checking if user exists"
return callback(err)

View file

@ -18,15 +18,16 @@ module.exports = CompileManager =
timer.done()
_callback(args...)
@_checkIfAutoCompileLimitHasBeenHit options.isAutoCompile, "everyone", (err, canCompile)->
if !canCompile
return callback null, "autocompile-backoff", []
logger.log project_id: project_id, user_id: user_id, "compiling project"
CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
return callback(error) if error?
if recentlyCompiled
logger.warn {project_id, user_id}, "project was recently compiled so not continuing"
return callback null, "too-recently-compiled", []
logger.log project_id: project_id, user_id: user_id, "compiling project"
CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
return callback(error) if error?
if recentlyCompiled
logger.warn {project_id, user_id}, "project was recently compiled so not continuing"
return callback null, "too-recently-compiled", []
CompileManager._checkIfAutoCompileLimitHasBeenHit options.isAutoCompile, "everyone", (err, canCompile)->
if !canCompile
return callback null, "autocompile-backoff", []
CompileManager._ensureRootDocumentIsSet project_id, (error) ->
return callback(error) if error?

View file

@ -47,6 +47,13 @@ UnsupportedExportRecordsError = (message) ->
return error
UnsupportedExportRecordsError.prototype.__proto___ = Error.prototype
V1HistoryNotSyncedError = (message) ->
error = new Error(message)
error.name = "V1HistoryNotSyncedError"
error.__proto__ = V1HistoryNotSyncedError.prototype
return error
V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
ProjectHistoryDisabledError = (message) ->
error = new Error(message)
error.name = "ProjectHistoryDisabledError "
@ -62,4 +69,5 @@ module.exports = Errors =
UnsupportedFileTypeError: UnsupportedFileTypeError
UnsupportedBrandError: UnsupportedBrandError
UnsupportedExportRecordsError: UnsupportedExportRecordsError
V1HistoryNotSyncedError: V1HistoryNotSyncedError
ProjectHistoryDisabledError: ProjectHistoryDisabledError

View file

@ -0,0 +1,19 @@
ExportsHandler = require("./ExportsHandler")
AuthenticationController = require("../Authentication/AuthenticationController")
logger = require("logger-sharelatex")
module.exports =
exportProject: (req, res) ->
{project_id, brand_variation_id} = req.params
user_id = AuthenticationController.getLoggedInUserId(req)
ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
return next(err) if err?
logger.log
user_id:user_id
project_id: project_id
brand_variation_id:brand_variation_id
export_v1_id:export_data.v1_id
"exported project"
res.send export_v1_id: export_data.v1_id

View file

@ -0,0 +1,93 @@
ProjectGetter = require('../Project/ProjectGetter')
ProjectLocator = require('../Project/ProjectLocator')
UserGetter = require('../User/UserGetter')
logger = require('logger-sharelatex')
settings = require 'settings-sharelatex'
async = require 'async'
request = require 'request'
request = request.defaults()
settings = require 'settings-sharelatex'
module.exports = ExportsHandler = self =
exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) ->
self._buildExport project_id, user_id, brand_variation_id, (err, export_data) ->
return callback(err) if err?
self._requestExport export_data, (err, export_v1_id) ->
return callback(err) if err?
export_data.v1_id = export_v1_id
# TODO: possibly store the export data in Mongo
callback null, export_data
_buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
jobs =
project: (cb) ->
ProjectGetter.getProject project_id, cb
# TODO: when we update async, signature will change from (cb, results) to (results, cb)
rootDoc: [ 'project', (cb, results) ->
ProjectLocator.findRootDoc {project: results.project, project_id: project_id}, cb
]
user: (cb) ->
UserGetter.getUser user_id, {first_name: 1, last_name: 1, email: 1}, cb
historyVersion: (cb) ->
self._requestVersion project_id, cb
async.auto jobs, (err, results) ->
if err?
logger.err err:err, project_id:project_id, user_id:user_id, brand_variation_id:brand_variation_id, "error building project export"
return callback(err)
{project, rootDoc, user, historyVersion} = results
if !rootDoc[1]?
err = new Error("cannot export project without root doc")
logger.err err:err, project_id: project_id
return callback(err)
export_data =
project:
id: project_id
rootDocPath: rootDoc[1]?.fileSystem
historyId: project.overleaf?.history?.id
historyVersion: historyVersion
user:
id: user_id
firstName: user.first_name
lastName: user.last_name
email: user.email
orcidId: null # until v2 gets ORCID
destination:
brandVariationId: brand_variation_id
options:
callbackUrl: null # for now, until we want v1 to call us back
callback null, export_data
_requestExport: (export_data, callback=(err, export_v1_id) ->) ->
request.post {
url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports"
auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
json: export_data
}, (err, res, body) ->
if err?
logger.err err:err, export:export_data, "error making request to v1 export"
callback err
else if 200 <= res.statusCode < 300
callback null, body.exportId
else
err = new Error("v1 export returned a failure status code: #{res.statusCode}")
logger.err err:err, export:export_data, "v1 export returned failure status code: #{res.statusCode}"
callback err
_requestVersion: (project_id, callback=(err, export_v1_id) ->) ->
request.get {
url: "#{settings.apis.project_history.url}/project/#{project_id}/version"
json: true
}, (err, res, body) ->
if err?
logger.err err:err, project_id:project_id, "error making request to project history"
callback err
else if res.statusCode >= 200 and res.statusCode < 300
callback null, body.version
else
err = new Error("project history version returned a failure status code: #{res.statusCode}")
logger.err err:err, project_id:project_id, "project history version returned failure status code: #{res.statusCode}"
callback err

View file

@ -9,7 +9,7 @@ logger = require("logger-sharelatex")
module.exports =
generateAndEmailResetToken:(email, callback = (error, exists) ->)->
UserGetter.getUser email:email, (err, user)->
UserGetter.getUserByMainEmail email, (err, user)->
if err then return callback(err)
if !user? or user.holdingAccount
logger.err email:email, "user could not be found for password reset"

View file

@ -157,7 +157,7 @@ module.exports = ProjectController =
hasSubscription: (cb)->
LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
user: (cb) ->
User.findById user_id, "featureSwitches overleaf awareOfV2", cb
User.findById user_id, "featureSwitches overleaf awareOfV2 features", cb
}, (err, results)->
if err?
logger.err err:err, "error getting data for project list page"
@ -172,6 +172,7 @@ module.exports = ProjectController =
user = results.user
warnings = ProjectController._buildWarningsList results.v1Projects
ProjectController._injectProjectOwners projects, (error, projects) ->
return next(error) if error?
viewModel = {
@ -193,6 +194,14 @@ module.exports = ProjectController =
else
viewModel.showUserDetailsArea = false
paidUser = user.features?.github and user.features?.dropbox # use a heuristic for paid account
freeUserProportion = 0.10
sampleFreeUser = parseInt(user._id.toString().slice(-2), 16) < freeUserProportion * 255
showFrontWidget = paidUser or sampleFreeUser
logger.log {paidUser, sampleFreeUser, showFrontWidget}, 'deciding whether to show front widget'
if showFrontWidget
viewModel.frontChatWidgetRoomId = Settings.overleaf?.front_chat_widget_room_id
res.render 'project/list', viewModel
timer.done()
@ -290,6 +299,8 @@ module.exports = ProjectController =
autoPairDelimiters: user.ace.autoPairDelimiters
pdfViewer : user.ace.pdfViewer
syntaxValidation: user.ace.syntaxValidation
fontFamily: user.ace.fontFamily
lineHeight: user.ace.lineHeight
}
trackChangesState: project.track_changes
privilegeLevel: privilegeLevel
@ -302,6 +313,7 @@ module.exports = ProjectController =
maxDocLength: Settings.max_doc_length
useV2History: !!project.overleaf?.history?.display
showRichText: req.query?.rt == 'true'
showPublishModal: req.query?.pm == 'true'
timer.done()
_buildProjectList: (allProjects, v1Projects = [])->

View file

@ -16,38 +16,42 @@ AnalyticsManger = require("../Analytics/AnalyticsManager")
module.exports = ProjectCreationHandler =
createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
metrics.inc("project-creation")
if arguments.length == 3
callback = projectHistoryId
projectHistoryId = null
callback = attributes
attributes = null
ProjectDetailsHandler.validateProjectName projectName, (error) ->
return callback(error) if error?
logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
if projectHistoryId?
ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, (error, project) ->
if attributes?
ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
return callback(error) if error?
AnalyticsManger.recordEvent(
owner_id, 'project-imported', { projectId: project._id, projectHistoryId: projectHistoryId }
owner_id, 'project-imported', { projectId: project._id, attributes: attributes }
)
callback(error, project)
else
HistoryManager.initializeProject (error, history) ->
return callback(error) if error?
ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, (error, project) ->
attributes = overleaf: history: id: history?.overleaf_id
ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
return callback(error) if error?
AnalyticsManger.recordEvent(
owner_id, 'project-created', { projectId: project._id }
)
callback(error, project)
_createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
_createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
rootFolder = new Folder {'name':'rootFolder'}
project = new Project
owner_ref : new ObjectId(owner_id)
name : projectName
project.overleaf.history.id = projectHistoryId
attributes.owner_ref = new ObjectId(owner_id)
attributes.name = projectName
project = new Project attributes
Object.assign(project, attributes)
if Settings.apis?.project_history?.displayHistoryForNewProjects
project.overleaf.history.display = true
if Settings.currentImageName?

View file

@ -405,14 +405,41 @@ module.exports = ProjectEntityUpdateHandler = self =
DocumentUpdaterHandler.resyncProjectHistory project_id, projectHistoryId, docs, files, callback
_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
self._updateProjectStructureWithDeletedEntity project, entity, entityType, path, userId, (error) ->
return callback(error) if error?
if(entityType.indexOf("file") != -1)
self._cleanUpFile project, entity, path, userId, callback
else if (entityType.indexOf("doc") != -1)
self._cleanUpDoc project, entity, path, userId, callback
else if (entityType.indexOf("folder") != -1)
self._cleanUpFolder project, entity, path, userId, callback
else
callback()
# Note: the _cleanUpEntity code and _updateProjectStructureWithDeletedEntity
# methods both need to recursively iterate over the entities in folder.
# These are currently using separate implementations of the recursion. In
# future, these could be simplified using a common project entity iterator.
_updateProjectStructureWithDeletedEntity: (project, entity, entityType, entityPath, userId, callback = (error) ->) ->
# compute the changes to the project structure
if(entityType.indexOf("file") != -1)
self._cleanUpFile project, entity, path, userId, callback
changes = oldFiles: [ {file: entity, path: entityPath} ]
else if (entityType.indexOf("doc") != -1)
self._cleanUpDoc project, entity, path, userId, callback
changes = oldDocs: [ {doc: entity, path: entityPath} ]
else if (entityType.indexOf("folder") != -1)
self._cleanUpFolder project, entity, path, userId, callback
else
callback()
changes = {oldDocs: [], oldFiles: []}
_recurseFolder = (folder, folderPath) ->
for doc in folder.docs
changes.oldDocs.push {doc, path: path.join(folderPath, doc.name)}
for file in folder.fileRefs
changes.oldFiles.push {file, path: path.join(folderPath, file.name)}
for childFolder in folder.folders
_recurseFolder(childFolder, path.join(folderPath, childFolder.name))
_recurseFolder entity, entityPath
# now send the project structure changes to the docupdater
project_id = project._id.toString()
projectHistoryId = project.overleaf?.history?.id
DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
_cleanUpDoc: (project, doc, path, userId, callback = (error) ->) ->
project_id = project._id.toString()
@ -429,21 +456,10 @@ module.exports = ProjectEntityUpdateHandler = self =
return callback(error) if error?
DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) ->
return callback(error) if error?
DocstoreManager.deleteDoc project_id, doc_id, (error) ->
return callback(error) if error?
changes = oldDocs: [ {doc, path} ]
projectHistoryId = project.overleaf?.history?.id
DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
DocstoreManager.deleteDoc project_id, doc_id, callback
_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, (error) ->
return callback(error) if error?
project_id = project._id.toString()
projectHistoryId = project.overleaf?.history?.id
changes = oldFiles: [ {file, path} ]
# we are now keeping a copy of every file versio so we no longer delete
# the file from the filestore
DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, callback
_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
jobs = []

View file

@ -1,8 +1,8 @@
_ = require("underscore")
logger = require('logger-sharelatex')
User = require('../../models/User').User
SubscriptionLocator = require "../Subscription/SubscriptionLocator"
Settings = require "settings-sharelatex"
FeaturesUpdater = require "../Subscription/FeaturesUpdater"
module.exports = ReferalAllocator =
allocate: (referal_id, new_user_id, referal_source, referal_medium, callback = ->)->
@ -25,50 +25,6 @@ module.exports = ReferalAllocator =
if err?
logger.err err:err, referal_id:referal_id, new_user_id:new_user_id, "something went wrong allocating referal"
return callback(err)
ReferalAllocator.assignBonus user._id, callback
FeaturesUpdater.refreshFeatures user._id, callback
else
callback()
assignBonus: (user_id, callback = (error) ->) ->
query = _id: user_id
User.findOne query, (error, user) ->
return callback(error) if error
return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
if user.refered_user_count? and user.refered_user_count > 0
newFeatures = ReferalAllocator._calculateFeatures(user)
if _.isEqual newFeatures, user.features
return callback()
User.update query, { $set: features: newFeatures }, callback
else
callback()
_calculateFeatures : (user)->
bonusLevel = ReferalAllocator._getBonusLevel(user)
currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
betterBonusFeatures = {}
_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
currentLevel = user?.features?[key]
if _.isBoolean(currentLevel) and currentLevel == false
betterBonusFeatures[key] = bonusLevel
if _.isNumber(currentLevel)
if currentLevel == -1
return
bonusIsGreaterThanCurrent = currentLevel < bonusLevel
if bonusIsGreaterThanCurrent or bonusLevel == -1
betterBonusFeatures[key] = bonusLevel
newFeatures = _.extend(currentFeatures, betterBonusFeatures)
return newFeatures
_getBonusLevel: (user)->
highestBonusLevel = 0
_.each _.keys(Settings.bonus_features), (level)->
levelIsLessThanUser = level <= user.refered_user_count
levelIsMoreThanCurrentHighest = level >= highestBonusLevel
if levelIsLessThanUser and levelIsMoreThanCurrentHighest
highestBonusLevel = level
return highestBonusLevel

View file

@ -0,0 +1,44 @@
_ = require("underscore")
logger = require('logger-sharelatex')
User = require('../../models/User').User
Settings = require "settings-sharelatex"
module.exports = ReferalFeatures =
getBonusFeatures: (user_id, callback = (error) ->) ->
query = _id: user_id
User.findOne query, (error, user) ->
return callback(error) if error
return callback(new Error("user not found #{user_id} for assignBonus")) if !user?
logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
if user.refered_user_count? and user.refered_user_count > 0
newFeatures = ReferalFeatures._calculateFeatures(user)
callback null, newFeatures
else
callback null, {}
_calculateFeatures : (user)->
bonusLevel = ReferalFeatures._getBonusLevel(user)
currentFeatures = _.clone(user.features) #need to clone because we exend with underscore later
betterBonusFeatures = {}
_.each Settings.bonus_features["#{bonusLevel}"], (bonusLevel, key)->
currentLevel = user?.features?[key]
if _.isBoolean(currentLevel) and currentLevel == false
betterBonusFeatures[key] = bonusLevel
if _.isNumber(currentLevel)
if currentLevel == -1
return
bonusIsGreaterThanCurrent = currentLevel < bonusLevel
if bonusIsGreaterThanCurrent or bonusLevel == -1
betterBonusFeatures[key] = bonusLevel
newFeatures = _.extend(currentFeatures, betterBonusFeatures)
return newFeatures
_getBonusLevel: (user)->
highestBonusLevel = 0
_.each _.keys(Settings.bonus_features), (level)->
levelIsLessThanUser = level <= user.refered_user_count
levelIsMoreThanCurrentHighest = level >= highestBonusLevel
if levelIsLessThanUser and levelIsMoreThanCurrentHighest
highestBonusLevel = level
return highestBonusLevel

View file

@ -46,7 +46,8 @@ module.exports = ReferencesHandler =
_isFullIndex: (project, callback = (err, result) ->) ->
UserGetter.getUser project.owner_ref, { features: true }, (err, owner) ->
return callback(err) if err?
callback(null, owner?.features?.references == true)
features = owner?.features
callback(null, features?.references == true || features?.referencesSearch == true)
indexAll: (projectId, callback=(err, data)->) ->
ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) ->

View file

@ -0,0 +1,83 @@
async = require("async")
PlansLocator = require("./PlansLocator")
_ = require("underscore")
SubscriptionLocator = require("./SubscriptionLocator")
UserFeaturesUpdater = require("./UserFeaturesUpdater")
Settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
ReferalFeatures = require("../Referal/ReferalFeatures")
V1SubscriptionManager = require("./V1SubscriptionManager")
oneMonthInSeconds = 60 * 60 * 24 * 30
module.exports = FeaturesUpdater =
refreshFeatures: (user_id, callback)->
jobs =
individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
v1Features: (cb) -> FeaturesUpdater._getV1Features user_id, cb
bonusFeatures: (cb) -> ReferalFeatures.getBonusFeatures user_id, cb
async.series jobs, (err, results)->
if err?
logger.err err:err, user_id:user_id,
"error getting subscription or group for refreshFeatures"
return callback(err)
{individualFeatures, groupFeatureSets, v1Features, bonusFeatures} = results
logger.log {user_id, individualFeatures, groupFeatureSets, v1Features, bonusFeatures}, 'merging user features'
featureSets = groupFeatureSets.concat [individualFeatures, v1Features, bonusFeatures]
features = _.reduce(featureSets, FeaturesUpdater._mergeFeatures, Settings.defaultFeatures)
logger.log {user_id, features}, 'updating user features'
UserFeaturesUpdater.updateFeatures user_id, features, callback
_getIndividualFeatures: (user_id, callback = (error, features = {}) ->) ->
SubscriptionLocator.getUsersSubscription user_id, (err, sub)->
callback err, FeaturesUpdater._subscriptionToFeatures(sub)
_getGroupFeatureSets: (user_id, callback = (error, featureSets = []) ->) ->
SubscriptionLocator.getGroupSubscriptionsMemberOf user_id, (err, subs) ->
callback err, (subs or []).map FeaturesUpdater._subscriptionToFeatures
_getV1Features: (user_id, callback = (error, features = {}) ->) ->
V1SubscriptionManager.getPlanCodeFromV1 user_id, (err, planCode) ->
callback err, FeaturesUpdater._planCodeToFeatures(planCode)
_mergeFeatures: (featuresA, featuresB) ->
features = Object.assign({}, featuresA)
for key, value of featuresB
# Special merging logic for non-boolean features
if key == 'compileGroup'
if features['compileGroup'] == 'priority' or featuresB['compileGroup'] == 'priority'
features['compileGroup'] = 'priority'
else
features['compileGroup'] = 'standard'
else if key == 'collaborators'
if features['collaborators'] == -1 or featuresB['collaborators'] == -1
features['collaborators'] = -1
else
features['collaborators'] = Math.max(
features['collaborators'] or 0,
featuresB['collaborators'] or 0
)
else if key == 'compileTimeout'
features['compileTimeout'] = Math.max(
features['compileTimeout'] or 0,
featuresB['compileTimeout'] or 0
)
else
# Boolean keys, true is better
features[key] = features[key] or featuresB[key]
return features
_subscriptionToFeatures: (subscription) ->
FeaturesUpdater._planCodeToFeatures(subscription?.planCode)
_planCodeToFeatures: (planCode) ->
if !planCode?
return {}
plan = PlansLocator.findLocalPlanInSettings planCode
if !plan?
return {}
else
return plan.features

View file

@ -9,6 +9,7 @@ logger = require('logger-sharelatex')
GeoIpLookup = require("../../infrastructure/GeoIpLookup")
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
UserGetter = require "../User/UserGetter"
FeaturesUpdater = require './FeaturesUpdater'
module.exports = SubscriptionController =
@ -237,3 +238,9 @@ module.exports = SubscriptionController =
return next(error) if error?
req.body = body
next()
refreshUserFeatures: (req, res, next) ->
{user_id} = req.params
FeaturesUpdater.refreshFeatures user_id, (error) ->
return next(error) if error?
res.sendStatus 200

View file

@ -2,7 +2,7 @@ async = require("async")
_ = require("underscore")
SubscriptionUpdater = require("./SubscriptionUpdater")
SubscriptionLocator = require("./SubscriptionLocator")
UserLocator = require("../User/UserLocator")
UserGetter = require("../User/UserGetter")
LimitationsManager = require("./LimitationsManager")
logger = require("logger-sharelatex")
OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
@ -21,7 +21,7 @@ module.exports = SubscriptionGroupHandler =
if limitReached
logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
return callback(limitReached:limitReached)
UserLocator.findByEmail newEmail, (err, user)->
UserGetter.getUserByMainEmail newEmail, (err, user)->
return callback(err) if err?
if user?
SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@ -50,7 +50,7 @@ module.exports = SubscriptionGroupHandler =
users.push buildEmailInviteViewModel(email)
jobs = _.map subscription.member_ids, (user_id)->
return (cb)->
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
if err? or !user?
users.push _id:user_id
return cb()

View file

@ -28,8 +28,8 @@ module.exports =
getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)->
Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
getGroupSubscriptionMemberOf: (user_id, callback)->
Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
getGroupSubscriptionsMemberOf: (user_id, callback)->
Subscription.find {member_ids: user_id}, {_id:1, planCode:1}, callback
getGroupsWithEmailInvite: (email, callback) ->
Subscription.find { invited_emails: email }, callback

View file

@ -46,4 +46,6 @@ module.exports =
webRouter.get "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
webRouter.post "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan
# Currently used in acceptance tests only, as a way to trigger the syncing logic
publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures

View file

@ -2,26 +2,26 @@ async = require("async")
_ = require("underscore")
Subscription = require('../../models/Subscription').Subscription
SubscriptionLocator = require("./SubscriptionLocator")
UserFeaturesUpdater = require("./UserFeaturesUpdater")
PlansLocator = require("./PlansLocator")
Settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
ObjectId = require('mongoose').Types.ObjectId
ReferalAllocator = require("../Referal/ReferalAllocator")
ObjectId = require('mongoose').Types.ObjectId
FeaturesUpdater = require('./FeaturesUpdater')
oneMonthInSeconds = 60 * 60 * 24 * 30
module.exports = SubscriptionUpdater =
syncSubscription: (recurlySubscription, adminUser_id, callback) ->
logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist"
SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
return callback(err) if err?
if subscription?
logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist"
SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
else
logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does not exist, creating a new one"
SubscriptionUpdater._createNewSubscription adminUser_id, (err, subscription)->
return callback(err) if err?
SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
addUserToGroup: (adminUser_id, user_id, callback)->
@ -34,7 +34,7 @@ module.exports = SubscriptionUpdater =
if err?
logger.err err:err, searchOps:searchOps, insertOperation:insertOperation, "error findy and modify add user to group"
return callback(err)
UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
FeaturesUpdater.refreshFeatures user_id, callback
addEmailInviteToGroup: (adminUser_id, email, callback) ->
logger.log {adminUser_id, email}, "adding email into mongo subscription"
@ -53,7 +53,7 @@ module.exports = SubscriptionUpdater =
if err?
logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
return callback(err)
SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
FeaturesUpdater.refreshFeatures user_id, callback
removeEmailInviteFromGroup: (adminUser_id, email, callback)->
Subscription.update {
@ -62,9 +62,6 @@ module.exports = SubscriptionUpdater =
invited_emails: email
}, callback
refreshSubscription: (user_id, callback=(err)->) ->
SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
deleteSubscription: (subscription_id, callback = (error) ->) ->
SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
return callback(err) if err?
@ -72,7 +69,7 @@ module.exports = SubscriptionUpdater =
logger.log {subscription_id, affected_user_ids}, "deleting subscription and downgrading users"
Subscription.remove {_id: ObjectId(subscription_id)}, (err) ->
return callback(err) if err?
async.mapSeries affected_user_ids, SubscriptionUpdater._setUsersMinimumFeatures, callback
async.mapSeries affected_user_ids, FeaturesUpdater.refreshFeatures, callback
_createNewSubscription: (adminUser_id, callback)->
logger.log adminUser_id:adminUser_id, "creating new subscription"
@ -100,43 +97,5 @@ module.exports = SubscriptionUpdater =
allIds = _.union subscription.member_ids, [subscription.admin_id]
jobs = allIds.map (user_id)->
return (cb)->
SubscriptionUpdater._setUsersMinimumFeatures user_id, cb
FeaturesUpdater.refreshFeatures user_id, cb
async.series jobs, callback
_setUsersMinimumFeatures: (user_id, callback)->
jobs =
subscription: (cb)->
SubscriptionLocator.getUsersSubscription user_id, cb
groupSubscription: (cb)->
SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb
v1PlanCode: (cb) ->
Modules = require '../../infrastructure/Modules'
Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) ->
cb(err, results?[0] || null)
async.series jobs, (err, results)->
if err?
logger.err err:err, user_id:user_id,
"error getting subscription or group for _setUsersMinimumFeatures"
return callback(err)
{subscription, groupSubscription, v1PlanCode} = results
# Group Subscription
if groupSubscription? and groupSubscription.planCode?
logger.log user_id:user_id, "using group which user is memor of for features"
UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback
# Personal Subscription
else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
logger.log user_id:user_id, "using users subscription plan code for features"
UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
# V1 Subscription
else if v1PlanCode?
logger.log user_id: user_id, "using the V1 plan for features"
UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback
# Default
else
logger.log user_id:user_id, "using default features for user with no subscription or group"
UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)->
if err?
logger.err err:err, user_id:user_id, "Error setting minimum user feature"
return callback(err)
ReferalAllocator.assignBonus user_id, callback

View file

@ -1,15 +1,12 @@
logger = require("logger-sharelatex")
User = require('../../models/User').User
PlansLocator = require("./PlansLocator")
module.exports =
updateFeatures: (user_id, plan_code, callback = (err, features)->)->
updateFeatures: (user_id, features, callback = (err, features)->)->
conditions = _id:user_id
update = {}
plan = PlansLocator.findLocalPlanInSettings(plan_code)
logger.log user_id:user_id, features:plan.features, plan_code:plan_code, "updating users features"
update["features.#{key}"] = value for key, value of plan.features
logger.log user_id:user_id, features:features, "updating users features"
update["features.#{key}"] = value for key, value of features
User.update conditions, update, (err)->
callback err, plan.features
callback err, features

View file

@ -0,0 +1,50 @@
UserGetter = require "../User/UserGetter"
request = require "request"
settings = require "settings-sharelatex"
logger = require "logger-sharelatex"
module.exports = V1SubscriptionManager =
# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
# For this to work, we need plans in settings with plan-codes:
# - 'v1_pro'
# - 'v1_pro_plus'
# - 'v1_student'
# - 'v1_free'
getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
return callback(err) if err?
v1Id = user?.overleaf?.id
if !v1Id?
logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
return callback(null, null)
V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
return callback(err) if err?
planName = body?.plan_name
logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
if planName in ['pro', 'pro_plus', 'student', 'free']
planName = "v1_#{planName}"
else
# Throw away 'anonymous', etc as being equivalent to null
planName = null
return callback(null, planName)
_v1PlanRequest: (v1Id, callback=(err, body)->) ->
if !settings?.apis?.v1
return callback null, null
request {
method: 'GET',
url: settings.apis.v1.url +
"/api/v1/sharelatex/users/#{v1Id}/plan_code"
auth:
user: settings.apis.v1.user
pass: settings.apis.v1.pass
sendImmediately: true
json: true,
timeout: 5 * 1000
}, (error, response, body) ->
return callback(error) if error?
if 200 <= response.statusCode < 300
return callback null, body
else
return callback new Error("non-success code from v1: #{response.statusCode}")

View file

@ -3,7 +3,7 @@ Path = require("path")
module.exports = FileTypeManager =
TEXT_EXTENSIONS : [
"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy"
"tex", "latex", "sty", "cls", "bst", "bib", "bibtex", "txt", "tikz", "rtex", "md", "asy", "latexmkrc"
]
IGNORE_EXTENSIONS : [
@ -34,7 +34,7 @@ module.exports = FileTypeManager =
extension = parts.slice(-1)[0]
if extension?
extension = extension.toLowerCase()
binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1)
binaryFile = (@TEXT_EXTENSIONS.indexOf(extension) == -1 or parts.length <= 1) and parts[0] != 'latexmkrc'
if binaryFile
return callback null, true
@ -52,13 +52,10 @@ module.exports = FileTypeManager =
if extension?
extension = extension.toLowerCase()
ignore = false
if name[0] == "."
if name[0] == "." and extension != 'latexmkrc'
ignore = true
if @IGNORE_EXTENSIONS.indexOf(extension) != -1
ignore = true
if @IGNORE_FILENAMES.indexOf(name) != -1
ignore = true
callback null, ignore

View file

@ -1,6 +1,6 @@
UserHandler = require("./UserHandler")
UserDeleter = require("./UserDeleter")
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
User = require("../../models/User").User
newsLetterManager = require('../Newsletter/NewsletterManager')
UserRegistrationHandler = require("./UserRegistrationHandler")
@ -45,7 +45,7 @@ module.exports = UserController =
unsubscribe: (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req)
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
newsLetterManager.unsubscribe user, ->
res.send()
@ -81,6 +81,11 @@ module.exports = UserController =
user.ace.pdfViewer = req.body.pdfViewer
if req.body.syntaxValidation?
user.ace.syntaxValidation = req.body.syntaxValidation
if req.body.fontFamily?
user.ace.fontFamily = req.body.fontFamily
if req.body.lineHeight?
user.ace.lineHeight = req.body.lineHeight
user.save (err)->
newEmail = req.body.email?.trim().toLowerCase()
if !newEmail? or newEmail == user.email or req.externalAuthenticationSystemUsed()

View file

@ -1,19 +1,10 @@
User = require("../../models/User").User
UserLocator = require("./UserLocator")
logger = require("logger-sharelatex")
metrics = require('metrics-sharelatex')
module.exports = UserCreator =
getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
self = @
UserLocator.findByEmail email, (err, user)->
if user?
callback(err, user)
else
self.createNewUser email:email, holdingAccount:true, callback
createNewUser: (opts, callback)->
logger.log opts:opts, "creating new user"
user = new User()

View file

@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
module.exports = UserGetter =
getUser: (query, projection, callback = (error, user) ->) ->
if query?.email?
return callback(new Error("Don't use getUser to find user by email"), null)
if arguments.length == 2
callback = projection
projection = {}
@ -19,6 +21,13 @@ module.exports = UserGetter =
db.users.findOne query, projection, callback
getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
email = email.trim()
if arguments.length == 2
callback = projection
projection = {}
db.users.findOne email: email, projection, callback
getUsers: (user_ids, projection, callback = (error, users) ->) ->
try
user_ids = user_ids.map (u) -> ObjectId(u.toString())
@ -39,6 +48,7 @@ module.exports = UserGetter =
[
'getUser',
'getUserByMainEmail',
'getUsers',
'getUserOrUserStubById'
].map (method) ->

View file

@ -1,21 +0,0 @@
mongojs = require("../../infrastructure/mongojs")
metrics = require("metrics-sharelatex")
db = mongojs.db
ObjectId = mongojs.ObjectId
logger = require('logger-sharelatex')
module.exports = UserLocator =
findByEmail: (email, callback)->
email = email.trim()
db.users.findOne email:email, (err, user)->
callback(err, user)
findById: (_id, callback)->
db.users.findOne _id:ObjectId(_id+""), callback
[
'findById',
'findByEmail'
].map (method) ->
metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger

View file

@ -1,4 +1,3 @@
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
UserSessionsManager = require("./UserSessionsManager")
ErrorController = require("../Errors/ErrorController")
@ -61,7 +60,7 @@ module.exports =
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log user: user_id, "loading settings page"
shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
UserLocator.findById user_id, (err, user)->
UserGetter.getUser user_id, (err, user)->
return next(err) if err?
res.render 'user/settings',
title:'account_settings'

View file

@ -1,6 +1,7 @@
sanitize = require('sanitizer')
User = require("../../models/User").User
UserCreator = require("./UserCreator")
UserGetter = require("./UserGetter")
AuthenticationManager = require("../Authentication/AuthenticationManager")
NewsLetterManager = require("../Newsletter/NewsletterManager")
async = require("async")
@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler =
if !requestIsValid
return callback(new Error("request is not valid"))
userDetails.email = userDetails.email?.trim()?.toLowerCase()
User.findOne email:userDetails.email, (err, user)->
UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
if err?
return callback err
if user?.holdingAccount == false

View file

@ -3,7 +3,7 @@ mongojs = require("../../infrastructure/mongojs")
metrics = require("metrics-sharelatex")
db = mongojs.db
ObjectId = mongojs.ObjectId
UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
module.exports = UserUpdater =
updateUser: (query, update, callback = (error) ->) ->
@ -18,7 +18,7 @@ module.exports = UserUpdater =
changeEmailAddress: (user_id, newEmail, callback)->
self = @
logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
UserLocator.findByEmail newEmail, (error, user) ->
UserGetter.getUserByMainEmail newEmail, (error, user) ->
if user?
return callback({message:"alread_exists"})
self.updateUser user_id.toString(), {

View file

@ -183,6 +183,9 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
# Don't include the query string parameters, otherwise Google
# treats ?nocdn=true as the canonical version
res.locals.currentUrl = Url.parse(req.originalUrl).pathname
res.locals.capitalize = (string) ->
return "" if string.length == 0
return string.charAt(0).toUpperCase() + string.slice(1)
next()
webRouter.use (req, res, next)->
@ -321,5 +324,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
chatMessageBorderLightness : if isOl then "40%" else "70%"
chatMessageBgSaturation : if isOl then "85%" else "60%"
chatMessageBgLightness : if isOl then "40%" else "97%"
defaultFontFamily : if isOl then 'lucida' else 'monaco'
defaultLineHeight : if isOl then 'normal' else 'compact'
renderAnnouncements : !isOl
next()

View file

@ -14,9 +14,11 @@ module.exports = Features =
return Settings.enableGithubSync
when 'v1-return-message'
return Settings.accountMerge? and Settings.overleaf?
when 'publish-modal'
return Settings.showPublishModal
when 'v2-banner'
return Settings.showV2Banner
when 'custom-togglers'
return Settings.overleaf?
when 'templates'
return !Settings.overleaf?
else
throw new Error("unknown feature: #{feature}")

View file

@ -3,20 +3,37 @@ Settings = require('settings-sharelatex')
RedisWrapper = require("./RedisWrapper")
rclient = RedisWrapper.client("lock")
logger = require "logger-sharelatex"
os = require "os"
crypto = require "crypto"
HOST = os.hostname()
PID = process.pid
RND = crypto.randomBytes(4).toString('hex')
COUNT = 0
module.exports = LockManager =
LOCK_TEST_INTERVAL: 50 # 50ms between each test of the lock
MAX_TEST_INTERVAL: 1000 # back off to 1s between each test of the lock
MAX_LOCK_WAIT_TIME: 10000 # 10s maximum time to spend trying to get the lock
REDIS_LOCK_EXPIRY: 30 # seconds. Time until lock auto expires in redis
SLOW_EXECUTION_THRESHOLD: 5000 # 5s, if execution takes longer than this then log
# Use a signed lock value as described in
# http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
# to prevent accidental unlocking by multiple processes
randomLock : () ->
time = Date.now()
return "locked:host=#{HOST}:pid=#{PID}:random=#{RND}:time=#{time}:count=#{COUNT++}"
unlockScript: 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'
runWithLock: (namespace, id, runner = ( (releaseLock = (error) ->) -> ), callback = ( (error) -> )) ->
# This error is defined here so we get a useful stacktrace
slowExecutionError = new Error "slow execution during lock"
timer = new metrics.Timer("lock.#{namespace}")
key = "lock:web:#{namespace}:#{id}"
LockManager._getLock key, namespace, (error) ->
LockManager._getLock key, namespace, (error, lockValue) ->
return callback(error) if error?
# The lock can expire in redis but the process carry on. This setTimout call
@ -27,7 +44,7 @@ module.exports = LockManager =
exceededLockTimeout = setTimeout countIfExceededLockTimeout, LockManager.REDIS_LOCK_EXPIRY * 1000
runner (error1, values...) ->
LockManager._releaseLock key, (error2) ->
LockManager._releaseLock key, lockValue, (error2) ->
clearTimeout exceededLockTimeout
timeTaken = new Date - timer.start
@ -39,19 +56,21 @@ module.exports = LockManager =
return callback(error) if error?
callback null, values...
_tryLock : (key, namespace, callback = (err, isFree)->)->
rclient.set key, "locked", "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
_tryLock : (key, namespace, callback = (err, isFree, lockValue)->)->
lockValue = LockManager.randomLock()
rclient.set key, lockValue, "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
return callback(err) if err?
if gotLock == "OK"
metrics.inc "lock.#{namespace}.try.success"
callback err, true
callback err, true, lockValue
else
metrics.inc "lock.#{namespace}.try.failed"
logger.log key: key, redis_response: gotLock, "lock is locked"
callback err, false
_getLock: (key, namespace, callback = (error) ->) ->
_getLock: (key, namespace, callback = (error, lockValue) ->) ->
startTime = Date.now()
testInterval = LockManager.LOCK_TEST_INTERVAL
attempts = 0
do attempt = () ->
if Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME
@ -59,13 +78,23 @@ module.exports = LockManager =
return callback(new Error("Timeout"))
attempts += 1
LockManager._tryLock key, namespace, (error, gotLock) ->
LockManager._tryLock key, namespace, (error, gotLock, lockValue) ->
return callback(error) if error?
if gotLock
metrics.gauge "lock.#{namespace}.get.success.tries", attempts
callback(null)
callback(null, lockValue)
else
setTimeout attempt, LockManager.LOCK_TEST_INTERVAL
setTimeout attempt, testInterval
# back off when the lock is taken to avoid overloading
testInterval = Math.min(testInterval * 2, LockManager.MAX_TEST_INTERVAL)
_releaseLock: (key, callback)->
rclient.del key, callback
_releaseLock: (key, lockValue, callback)->
rclient.eval LockManager.unlockScript, 1, key, lockValue, (err, result) ->
if err?
return callback(err)
else if result? and result isnt 1 # successful unlock should release exactly one key
logger.error {key:key, lockValue:lockValue, redis_err:err, redis_result:result}, "unlocking error"
metrics.inc "unlock-error"
return callback(new Error("tried to release timed out lock"))
else
callback(null,result)

View file

@ -30,7 +30,8 @@ module.exports = Modules =
for module in @modules
for view, partial of module.viewIncludes or {}
@viewIncludes[view] ||= []
@viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")
@viewIncludes[view].push pug.compileFile(filePath, doctype: "html")
moduleIncludes: (view, locals) ->
compiledPartials = Modules.viewIncludes[view] or []

View file

@ -20,14 +20,16 @@ UserSchema = new Schema
loginCount : {type : Number, default: 0}
holdingAccount : {type : Boolean, default: false}
ace : {
mode : {type : String, default: 'none'}
theme : {type : String, default: 'textmate'}
fontSize : {type : Number, default:'12'}
autoComplete: {type : Boolean, default: true}
autoPairDelimiters: {type : Boolean, default: true}
spellCheckLanguage : {type : String, default: "en"}
pdfViewer : {type : String, default: "pdfjs"}
syntaxValidation : {type : Boolean}
mode : {type : String, default: 'none'}
theme : {type : String, default: 'textmate'}
fontSize : {type : Number, default:'12'}
autoComplete : {type : Boolean, default: true}
autoPairDelimiters : {type : Boolean, default: true}
spellCheckLanguage : {type : String, default: "en"}
pdfViewer : {type : String, default: "pdfjs"}
syntaxValidation : {type : Boolean}
fontFamily : {type : String}
lineHeight : {type : String}
}
features : {
collaborators: { type:Number, default: Settings.defaultFeatures.collaborators }

View file

@ -26,6 +26,7 @@ HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
FileStoreController = require("./Features/FileStore/FileStoreController")
HistoryController = require("./Features/History/HistoryController")
ExportsController = require("./Features/Exports/ExportsController")
PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
ChatController = require("./Features/Chat/ChatController")
@ -206,6 +207,7 @@ module.exports = class Router
webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

View file

@ -1,67 +0,0 @@
script(type='text/ng-template', id='supportModalTemplate')
.modal-header
button.close(
type="button"
data-dismiss="modal"
ng-click="close()"
) &times;
h3 #{translate("contact_us")}
.modal-body.contact-us-modal
form(name="contactForm")
span(ng-show="sent == false")
.alert.alert-danger(ng-show="error") Something went wrong sending your request :(
label
| #{translate("subject")}
.form-group
input.field.text.medium.span8.form-control(
name="subject",
required
ng-model="form.subject",
ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
maxlength='255',
tabindex='1',
onkeyup='')
.contact-suggestions(ng-show="suggestions.length")
p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>__kb__</a>", 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")
input.field.text.medium.span8.form-control(
name="email",
required
ng-model="form.email",
ng-init="form.email = '"+getUserEmail()+"'",
type='email', spellcheck='false',
value='',
maxlength='255',
tabindex='2')
label#title12.desc
| #{translate("project_url")} (#{translate("optional")})
.form-group
input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
label.desc
| #{translate("contact_message_label")}
.form-group
textarea.field.text.medium.span8.form-control(
name="body",
required
ng-model="form.message",
type='text',
value='',
tabindex='4',
onkeyup=''
)
.form-group.text-center
input.btn-success.btn.btn-lg(
type='submit',
ng-disabled="contactForm.$invalid || sending",
ng-click="contactUs()"
value=translate("contact_us")
)
span(ng-show="sent")
p #{translate("request_sent_thank_you")}

View file

@ -98,7 +98,8 @@ html(itemscope, itemtype='http://schema.org/Product')
- if (settings.overleaf && settings.overleaf.useOLFreeTrial)
script.
window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial'
window.useV2TrialUrl = true
body
if(settings.recaptcha)
@ -146,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
src=buildJsPath('libs/require.js', {hashedPath:true})
)
include contact-us-modal
!= moduleIncludes("contactModal", locals)
include v1-tooltip
include sentry

View file

@ -157,7 +157,10 @@ block requirejs
},
"ace/ext-language_tools": {
"deps": ["ace/ace"]
}
},
"ace/keybinding-vim": {
"deps": ["ace/ace"]
},
},
"config":{
"moment":{

View file

@ -58,6 +58,7 @@ div.full-size(
read-only="!permissions.write",
file-name="editor.open_doc_name",
on-ctrl-enter="recompileViaKey",
on-save="recompileViaKey",
on-ctrl-j="toggleReviewPanel",
on-ctrl-shift-c="addNewCommentFromKbdShortcut",
on-ctrl-shift-a="toggleTrackChangesFromKbdShortcut",
@ -68,6 +69,8 @@ div.full-size(
track-changes= "editor.trackChanges",
doc-id="editor.open_doc_id"
renderer-data="reviewPanel.rendererData"
font-family="settings.fontFamily || ui.defaultFontFamily"
line-height="settings.lineHeight || ui.defaultLineHeight"
)
!= moduleIncludes('editor:body', locals)

View file

@ -150,6 +150,26 @@ aside#left-menu.full-size(
each size in ['10','11','12','13','14','16','20','24']
option(value=size) #{size}px
.form-controls
label(for="fontFamily") #{translate("font_family")}
select(
name="fontFamily"
ng-model="settings.fontFamily"
)
option(value="", disabled) #{translate("default")}
each fontFamily in ['monaco', 'lucida']
option(value=fontFamily) #{capitalize(fontFamily)}
.form-controls
label(for="lineHeight") #{translate("line_height")}
select(
name="lineHeight"
ng-model="settings.lineHeight"
)
option(value="", disabled) #{translate("default")}
each lineHeight in ['compact', 'normal', 'wide']
option(value=lineHeight) #{translate(lineHeight)}
.form-controls
label(for="pdfViewer") #{translate("pdf_viewer")}
select(

View file

@ -371,40 +371,40 @@ div.full-size.pdf(ng-controller="PdfController")
a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")
| #{translate("learn_how_to_make_documents_compile_quickly")}
.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
p(ng-if="project.owner._id == user.id")
strong #{translate("upgrade_for_faster_compiles")}
p(ng-if="project.owner._id != user.id")
strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
p Plus:
div
ul.list-unstyled
li
i.fa.fa-check &nbsp;
| #{translate("unlimited_projects")}
li
i.fa.fa-check &nbsp;
| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
li
i.fa.fa-check &nbsp;
| #{translate("full_doc_history")}
li
i.fa.fa-check &nbsp;
| #{translate("sync_to_dropbox")}
li
i.fa.fa-check &nbsp;
| #{translate("sync_to_github")}
li
i.fa.fa-check &nbsp;
|#{translate("compile_larger_projects")}
p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
a.btn.btn-success.row-spaced-small(
href
ng-class="buttonClass"
ng-click="startFreeTrial('compile-timeout')"
) #{translate("start_free_trial")}
if settings.enableSubscriptions
.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
p(ng-if="project.owner._id == user.id")
strong #{translate("upgrade_for_faster_compiles")}
p(ng-if="project.owner._id != user.id")
strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
p Plus:
div
ul.list-unstyled
li
i.fa.fa-check &nbsp;
| #{translate("unlimited_projects")}
li
i.fa.fa-check &nbsp;
| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
li
i.fa.fa-check &nbsp;
| #{translate("full_doc_history")}
li
i.fa.fa-check &nbsp;
| #{translate("sync_to_dropbox")}
li
i.fa.fa-check &nbsp;
| #{translate("sync_to_github")}
li
i.fa.fa-check &nbsp;
|#{translate("compile_larger_projects")}
p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id")
a.btn.btn-success.row-spaced-small(
href
ng-class="buttonClass"
ng-click="startFreeTrial('compile-timeout')"
) #{translate("start_free_trial")}
.alert.alert-danger(ng-show="pdf.autoCompileDisabled")
p

View file

@ -1,4 +1,4 @@
- if (settings.overleaf && settings.overleaf.front_chat_widget_room_id != null)
- if (frontChatWidgetRoomId)
script.
window.FCSP = '#{settings.overleaf.front_chat_widget_room_id}';
script(src="https://chat-assets.frontapp.com/v1/chat.bundle.js")
window.FCSP = '#{frontChatWidgetRoomId}';
script(src="https://chat-assets.frontapp.com/v1/chat.bundle.js")

View file

@ -38,4 +38,7 @@
tooltip-append-to-body="true"
)
.col-xs-4
span.last-modified {{project.lastUpdated | formatDate}}
if settings.overleaf
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
else
span.last-modified {{project.lastUpdated | formatDate}}

View file

@ -1,4 +1,4 @@
if (user.awareOfV2 && !settings.overleaf)
if hasFeature('v2-banner')
.userNotifications
ul.list-unstyled.notifications-list(ng-controller="OverleafV2NotificationController", ng-show="visible")
li.notification_entry

View file

@ -32,14 +32,16 @@
ng-click="downloadSelectedProjects()"
)
i.fa.fa-cloud-download
- var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete")
- var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o"
a.btn.btn-default(
href,
tooltip=translate('delete'),
tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`,
tooltip-placement="bottom",
tooltip-append-to-body="true",
ng-click="openArchiveProjectsModal()"
)
i.fa.fa-trash-o
i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`)
.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
a.btn.btn-default.dropdown-toggle(

View file

@ -41,7 +41,7 @@
li(ng-class="{active: (filter == 'shared')}", ng-click="filterProjects('shared')")
a(href) #{translate("shared_with_you")}
li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')")
a(href) #{translate("deleted_projects")}
a(href) #{settings.overleaf ? translate("archived_projects") : translate("deleted_projects")}
if isShowingV1Projects
li(ng-class="{active: (filter == 'v1')}", ng-click="filterProjects('v1')")
a(href) #{translate("v1_projects")}

View file

@ -22,4 +22,4 @@
span.owner {{ownerName()}}
.col-xs-4
span.last-modified {{project.lastUpdated | formatDate}}
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

View file

@ -59,24 +59,29 @@ block content
.col-md-12.text-centered
small #{translate("no_members")}
hr
div(ng-if="users.length < groupSize", ng-cloak)
hr
p
.small #{translate("add_more_members")}
form.form
.row
.col-xs-6
input.form-control(
name="email",
type="text",
placeholder="jane@example.com, joe@example.com",
ng-model="inputs.emails",
on-enter="addMembers()"
)
.col-xs-4
button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
.col-xs-2
a(href="/subscription/group/export") Export CSV
p.small #{translate("add_more_members")}
form.form
.row
.col-xs-6
input.form-control(
name="email",
type="text",
placeholder="jane@example.com, joe@example.com",
ng-model="inputs.emails",
on-enter="addMembers()"
)
.col-xs-4
button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
.col-xs-2
a(href="/subscription/group/export") Export CSV
div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak)
.row
.col-xs-2.col-xs-offset-10
a(href="/subscription/group/export") Export CSV
script(type="text/javascript").
window.users = !{JSON.stringify(users)};
@ -84,5 +89,3 @@ block content

6
services/web/bin/unit_test_app Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
set -e;
MOCHA="node_modules/.bin/mocha --exit --recursive --reporter spec"
$MOCHA "$@" test/unit/js

View file

@ -156,6 +156,10 @@ module.exports = settings =
url: process.env['LINKED_URL_PROXY']
thirdpartyreferences:
url: "http://#{process.env['THIRD_PARTY_REFERENCES_HOST'] or 'localhost'}:3046"
v1:
url: "http://#{process.env['V1_HOST'] or 'localhost'}:5000"
user: 'overleaf'
pass: 'password'
templates:
user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@ -363,7 +367,7 @@ module.exports = settings =
appName: "ShareLaTeX (Community Edition)"
adminEmail: "placeholder@example.com"
brandPrefix: "" # Set to 'ol-' for overleaf styles
nav:

View file

@ -17,6 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis
- mongo

View file

@ -8,7 +8,8 @@
"exec": "make compile || exit 1",
"watch": [
"public/coffee/",
"public/stylesheets/"
"public/stylesheets/",
"modules/**/public/coffee/"
],
"ext": "coffee less"
}

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@
"test:acceptance:dir": "npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@",
"test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js",
"test:unit": "npm -q run compile && bin/unit_test $@",
"test:unit:app": "npm -q run compile && bin/unit_test_app $@",
"test:frontend": "karma start",
"compile": "make compile",
"start": "npm -q run compile && node $NODE_APP_OPTIONS app.js",
@ -27,6 +28,7 @@
"dependencies": {
"archiver": "0.9.0",
"async": "0.6.2",
"backbone": "^1.3.3",
"base64-stream": "^0.1.2",
"basic-auth-connect": "^1.0.0",
"bcrypt": "1.0.1",
@ -37,16 +39,20 @@
"cookie": "^0.2.3",
"cookie-parser": "1.3.5",
"csurf": "^1.8.3",
"d3": "^3.5.16",
"dateformat": "1.0.4-1.2.3",
"daterangepicker": "^2.1.27",
"express": "4.13.0",
"express-http-proxy": "^1.1.0",
"express-session": "^1.14.2",
"fs-extra": "^4.0.2",
"fuse.js": "^3.0.0",
"handlebars": "^4.0.11",
"heapdump": "^0.3.7",
"helmet": "^3.8.1",
"http-proxy": "^1.8.1",
"jade": "~1.3.1",
"jquery": "^1.11.1",
"jsonwebtoken": "^8.0.1",
"ldapjs": "^0.7.1",
"lodash": "^4.13.1",
@ -65,6 +71,7 @@
"nodemailer-mandrill-transport": "^1.2.0",
"nodemailer-sendgrid-transport": "^0.2.0",
"nodemailer-ses-transport": "^1.3.0",
"nvd3": "^1.8.6",
"optimist": "0.6.1",
"passport": "^0.3.2",
"passport-ldapauth": "^0.6.0",
@ -134,6 +141,7 @@
"grunt-postcss": "^0.8.0",
"grunt-sed": "^0.1.1",
"grunt-shell": "^2.1.0",
"handlebars-loader": "^1.7.0",
"karma": "^2.0.0",
"karma-chai-sinon": "^0.1.5",
"karma-chrome-launcher": "^2.2.0",

View file

@ -17,3 +17,7 @@ define [
App.filter "relativeDate", () ->
(date) ->
moment(date).calendar()
App.filter "fromNowDate", () ->
(date) ->
moment(date).fromNow()

View file

@ -80,6 +80,8 @@ define [
miniReviewPanelVisible: false
chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen
chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
defaultFontFamily: window.uiConfig.defaultFontFamily
defaultLineHeight: window.uiConfig.defaultLineHeight
}
$scope.user = window.user

View file

@ -1,5 +1,6 @@
define [
"ide/editor/Document"
"ide/editor/components/spellMenu"
"ide/editor/directives/aceEditor"
"ide/editor/directives/toggleSwitch"
"ide/editor/controllers/SavingNotificationController"

View file

@ -0,0 +1,34 @@
define ["base"], (App) ->
App.component "spellMenu", {
bindings: {
open: "<"
top: "<"
left: "<"
highlight: "<"
replaceWord: "&"
learnWord: "&"
}
template: """
<div
class="dropdown context-menu spell-check-menu"
ng-show="$ctrl.open"
ng-style="{top: $ctrl.top, left: $ctrl.left}"
ng-class="{open: $ctrl.open}"
>
<ul class="dropdown-menu">
<li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
<a
href
ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
>
{{ suggestion }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
</li>
</ul>
</div>
"""
}

View file

@ -3,9 +3,11 @@ define [
"ace/ace"
"ace/ext-searchbox"
"ace/ext-modelist"
"ace/keybinding-vim"
"ide/editor/directives/aceEditor/undo/UndoManager"
"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
"ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter"
"ide/editor/directives/aceEditor/highlights/HighlightsManager"
"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
@ -14,9 +16,10 @@ define [
"ide/graphics/services/graphics"
"ide/preamble/services/preamble"
"ide/files/services/files"
], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
EditSession = ace.require('ace/edit_session').EditSession
ModeList = ace.require('ace/ext/modelist')
Vim = ace.require('ace/keyboard/vim').Vim
# set the path for ace workers if using a CDN (from editor.pug)
if window.aceWorkerPath != ""
@ -60,6 +63,7 @@ define [
onCtrlJ: "=" # Toggle the review panel
onCtrlShiftC: "=" # Add a new comment
onCtrlShiftA: "=" # Toggle track-changes on/off
onSave: "=" # Cmd/Ctrl-S or :w in Vim
syntaxValidation: "="
reviewPanel: "="
eventsBridge: "="
@ -67,6 +71,8 @@ define [
trackChangesEnabled: "="
docId: "="
rendererData: "="
lineHeight: "="
fontFamily: "="
}
link: (scope, element, attrs) ->
# Don't freak out if we're already in an apply callback
@ -98,7 +104,8 @@ define [
if scope.spellCheck # only enable spellcheck when explicitly required
spellCheckCache = $cacheFactory.get("spellCheck-#{scope.name}") || $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http, $q)
spellCheckManager = new SpellCheckManager(scope, spellCheckCache, $http, $q, new SpellCheckAdapter(editor))
undoManager = new UndoManager(scope, editor, element)
highlightsManager = new HighlightsManager(scope, editor, element)
cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@ -106,16 +113,26 @@ define [
metadataManager = new MetadataManager(scope, editor, element, metadata)
autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
# Prevert Ctrl|Cmd-S from triggering save dialog
editor.commands.addCommand
name: "save",
bindKey: win: "Ctrl-S", mac: "Command-S"
exec: () ->
readOnly: true
scope.$watch "onSave", (callback) ->
if callback?
Vim.defineEx 'write', 'w', callback
editor.commands.addCommand
name: "save",
bindKey: win: "Ctrl-S", mac: "Command-S"
exec: callback
readOnly: true
# Not technically 'save', but Ctrl-. recompiles in OL v1
# so maintain compatibility
editor.commands.addCommand
name: "recompile_v1",
bindKey: win: "Ctrl-.", mac: "Ctrl-."
exec: callback
readOnly: true
editor.commands.removeCommand "transposeletters"
editor.commands.removeCommand "showSettingsMenu"
editor.commands.removeCommand "foldall"
# For European keyboards, the / is above 7 so needs Shift pressing.
# This comes through as Command-Shift-/ on OS X, which is mapped to
# toggleBlockComment.
@ -266,6 +283,29 @@ define [
"font-size": value + "px"
})
scope.$watch "fontFamily", (value) ->
if value?
switch value
when 'monaco'
editor.setOption('fontFamily', '"Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", monospace')
when 'lucida'
editor.setOption('fontFamily', '"Lucida Console", monospace')
else
editor.setOption('fontFamily', null)
scope.$watch "lineHeight", (value) ->
if value?
switch value
when 'compact'
editor.container.style.lineHeight = 1.33
when 'normal'
editor.container.style.lineHeight = 1.6
when 'wide'
editor.container.style.lineHeight = 2
else
editor.container.style.lineHeight = 1.6
editor.renderer.updateFontSize()
scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
if old_sharejs_doc?
detachFromAce(old_sharejs_doc)
@ -323,6 +363,23 @@ define [
session.setScrollTop(session.getScrollTop() + 1)
session.setScrollTop(session.getScrollTop() - 1)
onSessionChangeForSpellCheck = (e) ->
spellCheckManager.onSessionChange()
e.oldSession?.getDocument().off "change", spellCheckManager.onChange
e.session.getDocument().on "change", spellCheckManager.onChange
e.oldSession?.off "changeScrollTop", spellCheckManager.onScroll
e.session.on "changeScrollTop", spellCheckManager.onScroll
initSpellCheck = () ->
spellCheckManager.init()
editor.on 'changeSession', onSessionChangeForSpellCheck
onSessionChangeForSpellCheck({ session: editor.getSession() }) # Force initial setup
editor.on 'nativecontextmenu', spellCheckManager.onContextMenu
tearDownSpellCheck = () ->
editor.off 'changeSession', onSessionChangeForSpellCheck
editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
attachToAce = (sharejs_doc) ->
lines = sharejs_doc.getSnapshot().split("\n")
session = editor.getSession()
@ -368,6 +425,7 @@ define [
editor.initing = false
# now ready to edit document
editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
initSpellCheck()
resetScrollMargins()
@ -429,6 +487,7 @@ define [
scope.$on '$destroy', () ->
if scope.sharejsDoc?
tearDownSpellCheck()
detachFromAce(scope.sharejsDoc)
session = editor.getSession()
session?.destroy()
@ -450,22 +509,14 @@ define [
>Dismiss</a>
</div>
<div class="ace-editor-body"></div>
<div
class="dropdown context-menu spell-check-menu"
ng-show="spellingMenu.open"
ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
ng-class="{open: spellingMenu.open}"
>
<ul class="dropdown-menu">
<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
</li>
</ul>
</div>
<spell-menu
open="spellMenu.open"
top="spellMenu.top"
left="spellMenu.left"
highlight="spellMenu.highlight"
replace-word="replaceWord(highlight, suggestion)"
learn-word="learnWord(highlight)"
></spell-menu>
<div
class="annotation-label"
ng-show="annotationLabel.show"

View file

@ -4,144 +4,93 @@ define [
Range = ace.require("ace/range").Range
class Highlight
constructor: (options) ->
@row = options.row
@column = options.column
constructor: (@markerId, @range, options) ->
@word = options.word
@suggestions = options.suggestions
class HighlightedWordManager
constructor: (@editor) ->
@reset()
reset: () ->
@highlights = rows: []
addHighlight: (highlight) ->
unless highlight instanceof Highlight
highlight = new Highlight(highlight)
range = new Range(
highlight.row, highlight.column,
highlight.row, highlight.column + highlight.word.length
)
highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", 'text', false
@highlights.rows[highlight.row] ||= []
@highlights.rows[highlight.row].push highlight
reset: () ->
@highlights?.forEach (highlight) =>
@editor.getSession().removeMarker(highlight.markerId)
@highlights = []
addHighlight: (options) ->
session = @editor.getSession()
doc = session.getDocument()
# Set up Range that will automatically update it's positions when the
# document changes
range = new Range()
range.start = doc.createAnchor({
row: options.row,
column: options.column
})
range.end = doc.createAnchor({
row: options.row,
column: options.column + options.word.length
})
# Prevent range from adding newly typed characters to the end of the word.
# This makes it appear as if the spelling error continues to the next word
# even after a space
range.end.$insertRight = true
markerId = session.addMarker range, "spelling-highlight", 'text', false
@highlights.push new Highlight(markerId, range, options)
removeHighlight: (highlight) ->
@editor.getSession().removeMarker(highlight.markerId)
for h, i in @highlights.rows[highlight.row]
if h == highlight
@highlights.rows[highlight.row].splice(i, 1)
@highlights = @highlights.filter (hl) ->
hl != highlight
removeWord: (word) ->
toRemove = []
for row in @highlights.rows
for highlight in (row || [])
if highlight.word == word
toRemove.push(highlight)
for highlight in toRemove
@removeHighlight highlight
@highlights.filter (highlight) ->
highlight.word == word
.forEach (highlight) =>
@removeHighlight(highlight)
moveHighlight: (highlight, position) ->
@removeHighlight highlight
highlight.row = position.row
highlight.column = position.column
@addHighlight highlight
clearRows: (from, to) ->
from ||= 0
to ||= @highlights.rows.length - 1
for row in @highlights.rows.slice(from, to + 1)
for highlight in (row || []).slice(0)
@removeHighlight highlight
insertRows: (offset, number) ->
# rows are inserted after offset. i.e. offset row is not modified
affectedHighlights = []
for row in @highlights.rows.slice(offset)
affectedHighlights.push(highlight) for highlight in (row || [])
for highlight in affectedHighlights
@moveHighlight highlight,
row: highlight.row + number
column: highlight.column
removeRows: (offset, number) ->
# offset is the first row to delete
affectedHighlights = []
for row in @highlights.rows.slice(offset)
affectedHighlights.push(highlight) for highlight in (row || [])
for highlight in affectedHighlights
if highlight.row >= offset + number
@moveHighlight highlight,
row: highlight.row - number
column: highlight.column
else
@removeHighlight highlight
clearRow: (row) ->
@highlights.filter (highlight) ->
highlight.range.start.row == row
.forEach (highlight) =>
@removeHighlight(highlight)
findHighlightWithinRange: (range) ->
rows = @highlights.rows.slice(range.start.row, range.end.row + 1)
for row in rows
for highlight in (row || [])
if @_doesHighlightOverlapRange(highlight, range.start, range.end)
return highlight
return null
applyChange: (change) ->
start = change.start
end = change.end
if change.action == "insert"
if start.row != end.row
rowsAdded = end.row - start.row
@insertRows start.row + 1, rowsAdded
# make a copy since we're going to modify in place
oldHighlights = (@highlights.rows[start.row] || []).slice(0)
for highlight in oldHighlights
if highlight.column > start.column
# insertion was fully before this highlight
@moveHighlight highlight,
row: end.row
column: highlight.column + (end.column - start.column)
else if highlight.column + highlight.word.length >= start.column
# insertion was inside this highlight
@removeHighlight highlight
else if change.action == "remove"
if start.row == end.row
oldHighlights = (@highlights.rows[start.row] || []).slice(0)
else
rowsRemoved = end.row - start.row
oldHighlights =
(@highlights.rows[start.row] || []).concat(
(@highlights.rows[end.row] || [])
)
@removeRows start.row + 1, rowsRemoved
for highlight in oldHighlights
if @_doesHighlightOverlapRange highlight, start, end
@removeHighlight highlight
else if @_isHighlightAfterRange highlight, start, end
@moveHighlight highlight,
row: start.row
column: highlight.column - (end.column - start.column)
_.find @highlights, (highlight) =>
@_doesHighlightOverlapRange highlight, range.start, range.end
_doesHighlightOverlapRange: (highlight, start, end) ->
highlightRow = highlight.range.start.row
highlightStartColumn = highlight.range.start.column
highlightEndColumn = highlight.range.end.column
highlightIsAllBeforeRange =
highlight.row < start.row or
(highlight.row == start.row and highlight.column + highlight.word.length <= start.column)
highlightRow < start.row or
(highlightRow == start.row and highlightEndColumn <= start.column)
highlightIsAllAfterRange =
highlight.row > end.row or
(highlight.row == end.row and highlight.column >= end.column)
highlightRow > end.row or
(highlightRow == end.row and highlightStartColumn >= end.column)
!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
_isHighlightAfterRange: (highlight, start, end) ->
return true if highlight.row > end.row
return false if highlight.row < end.row
highlight.column >= end.column
clearHighlightTouchingRange: (range) ->
highlight = _.find @highlights, (hl) =>
@_doesHighlightTouchRange hl, range.start, range.end
if highlight
@removeHighlight highlight
_doesHighlightTouchRange: (highlight, start, end) ->
highlightRow = highlight.range.start.row
highlightStartColumn = highlight.range.start.column
highlightEndColumn = highlight.range.end.column
rangeStartIsWithinHighlight =
highlightStartColumn <= start.column and
highlightEndColumn >= start.column
rangeEndIsWithinHighlight =
highlightStartColumn <= end.column and
highlightEndColumn >= end.column
highlightRow == start.row and
(rangeStartIsWithinHighlight or rangeEndIsWithinHighlight)

View file

@ -0,0 +1,56 @@
define [
"ace/ace"
"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
], (Ace, HighlightedWordManager) ->
Range = ace.require('ace/range').Range
class SpellCheckAdapter
constructor: (@editor) ->
@highlightedWordManager = new HighlightedWordManager(@editor)
getLines: () ->
@editor.getValue().split('\n')
normalizeChangeEvent: (e) -> e
getCoordsFromContextMenuEvent: (e) ->
e.domEvent.stopPropagation()
return {
x: e.domEvent.clientX,
y: e.domEvent.clientY
}
preventContextMenuEventDefault: (e) ->
e.domEvent.preventDefault()
getHighlightFromCoords: (coords) ->
position = @editor.renderer.screenToTextCoordinates(coords.x, coords.y)
@highlightedWordManager.findHighlightWithinRange({
start: position
end: position
})
selectHighlightedWord: (highlight) ->
row = highlight.range.start.row
startColumn = highlight.range.start.column
endColumn = highlight.range.end.column
@editor.getSession().getSelection().setSelectionRange(
new Range(
row, startColumn,
row, endColumn
)
)
replaceWord: (highlight, newWord) =>
row = highlight.range.start.row
startColumn = highlight.range.start.column
endColumn = highlight.range.end.column
@editor.getSession().replace(new Range(
row, startColumn,
row, endColumn
), newWord)
# Bring editor back into focus after clicking on suggestion
@editor.focus()

View file

@ -1,129 +1,88 @@
define [
"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
"ace/ace"
], (HighlightedWordManager) ->
Range = ace.require("ace/range").Range
define [], () ->
class SpellCheckManager
constructor: (@$scope, @editor, @element, @cache, @$http, @$q) ->
$(document.body).append @element.find(".spell-check-menu")
constructor: (@$scope, @cache, @$http, @$q, @adapter) ->
@$scope.spellMenu = {
open: false
top: '0px'
left: '0px'
suggestions: []
}
@inProgressRequest = null
@updatedLines = []
@highlightedWordManager = new HighlightedWordManager(@editor)
@$scope.$watch "spellCheckLanguage", (language, oldLanguage) =>
@$scope.$watch 'spellCheckLanguage', (language, oldLanguage) =>
if language != oldLanguage and oldLanguage?
@runFullCheck()
onChange = (e) =>
@runCheckOnChange(e)
onScroll = () =>
@closeContextMenu()
@$scope.replaceWord = @adapter.replaceWord
@$scope.learnWord = @learnWord
@editor.on "changeSession", (e) =>
@highlightedWordManager.reset()
if @inProgressRequest?
@inProgressRequest.abort()
if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
@runSpellCheckSoon(200)
e.oldSession?.getDocument().off "change", onChange
e.session.getDocument().on "change", onChange
e.oldSession?.off "changeScrollTop", onScroll
e.session.on "changeScrollTop", onScroll
@$scope.spellingMenu = {left: '0px', top: '0px'}
@editor.on "nativecontextmenu", (e) =>
e.domEvent.stopPropagation();
@closeContextMenu(e.domEvent)
@openContextMenu(e.domEvent)
$(document).on "click", (e) =>
if e.which != 3 # Ignore if this was a right click
@closeContextMenu(e)
$(document).on 'click', (e) =>
@closeContextMenu() if e.which != 3 # Ignore if right click
return true
@$scope.replaceWord = (highlight, suggestion) =>
@replaceWord(highlight, suggestion)
init: () ->
@updatedLines = Array(@adapter.getLines().length).fill(true)
@runSpellCheckSoon(200) if @isSpellCheckEnabled()
@$scope.learnWord = (highlight) =>
@learnWord(highlight)
isSpellCheckEnabled: () ->
return !!(
@$scope.spellCheck and
@$scope.spellCheckLanguage and
@$scope.spellCheckLanguage != ''
)
runFullCheck: () ->
@highlightedWordManager.clearRows()
if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
@runSpellCheck()
onChange: (e) =>
if @isSpellCheckEnabled()
@markLinesAsUpdated(@adapter.normalizeChangeEvent(e))
@adapter.highlightedWordManager.clearHighlightTouchingRange(e)
runCheckOnChange: (e) ->
if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
@highlightedWordManager.applyChange(e)
@markLinesAsUpdated(e)
@runSpellCheckSoon()
onSessionChange: () =>
@adapter.highlightedWordManager.reset()
@inProgressRequest.abort() if @inProgressRequest?
@runSpellCheckSoon(200) if @isSpellCheckEnabled()
onContextMenu: (e) =>
@closeContextMenu()
@openContextMenu(e)
onScroll: () => @closeContextMenu()
openContextMenu: (e) ->
position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
highlight = @highlightedWordManager.findHighlightWithinRange
start: position
end: position
@$scope.$apply () =>
@$scope.spellingMenu.highlight = highlight
coords = @adapter.getCoordsFromContextMenuEvent(e)
highlight = @adapter.getHighlightFromCoords(coords)
if highlight
e.stopPropagation()
e.preventDefault()
@editor.getSession().getSelection().setSelectionRange(
new Range(
highlight.row, highlight.column
highlight.row, highlight.column + highlight.word.length
)
)
@adapter.preventContextMenuEventDefault(e)
@adapter.selectHighlightedWord(highlight)
@$scope.$apply () =>
@$scope.spellingMenu.open = true
@$scope.spellingMenu.left = e.clientX + 'px'
@$scope.spellingMenu.top = e.clientY + 'px'
@$scope.spellMenu = {
open: true
top: coords.y + 'px'
left: coords.x + 'px'
highlight: highlight
}
return false
closeContextMenu: (e) ->
# this is triggered on scroll, so for performance only apply
# setting when it changes
if @$scope?.spellingMenu?.open != false
closeContextMenu: () ->
# This is triggered on scroll, so for performance only apply setting when
# it changes
if @$scope?.spellMenu and @$scope.spellMenu.open != false
@$scope.$apply () =>
@$scope.spellingMenu.open = false
@$scope.spellMenu.open = false
replaceWord: (highlight, text) ->
@editor.getSession().replace(new Range(
highlight.row, highlight.column,
highlight.row, highlight.column + highlight.word.length
), text)
learnWord: (highlight) ->
learnWord: (highlight) =>
@apiRequest "/learn", word: highlight.word
@highlightedWordManager.removeWord highlight.word
@adapter.highlightedWordManager.removeWord highlight.word
language = @$scope.spellCheckLanguage
@cache?.put("#{language}:#{highlight.word}", true)
getHighlightedWordAtCursor: () ->
cursor = @editor.getCursorPosition()
highlight = @highlightedWordManager.findHighlightWithinRange
start: cursor
end: cursor
return highlight
runSpellCheckSoon: (delay = 1000) ->
run = () =>
delete @timeoutId
@runSpellCheck(@updatedLines)
@updatedLines = []
if @timeoutId?
clearTimeout @timeoutId
@timeoutId = setTimeout run, delay
runFullCheck: () ->
@adapter.highlightedWordManager.reset()
@runSpellCheck() if @isSpellCheckEnabled()
markLinesAsUpdated: (change) ->
start = change.start
@ -146,6 +105,15 @@ define [
@updatedLines[start.row] = true
removeLines()
runSpellCheckSoon: (delay = 1000) ->
run = () =>
delete @timeoutId
@runSpellCheck(@updatedLines)
@updatedLines = []
if @timeoutId?
clearTimeout @timeoutId
@timeoutId = setTimeout run, delay
runSpellCheck: (linesToProcess) ->
{words, positions} = @getWords(linesToProcess)
language = @$scope.spellCheckLanguage
@ -178,11 +146,11 @@ define [
displayResult = (highlights) =>
if linesToProcess?
for shouldProcess, row in linesToProcess
@highlightedWordManager.clearRows(row, row) if shouldProcess
@adapter.highlightedWordManager.clearRow(row) if shouldProcess
else
@highlightedWordManager.clearRows()
@adapter.highlightedWordManager.reset()
for highlight in highlights
@highlightedWordManager.addHighlight highlight
@adapter.highlightedWordManager.addHighlight highlight
if not words.length
displayResult highlights
@ -212,8 +180,24 @@ define [
seen[key] = true
displayResult highlights
apiRequest: (endpoint, data, callback = (error, result) ->)->
data.token = window.user.id
data._csrf = window.csrfToken
# use angular timeout option to cancel request if doc is changed
requestHandler = @$q.defer()
options = {timeout: requestHandler.promise}
httpRequest = @$http.post("/spelling" + endpoint, data, options)
.then (response) =>
callback(null, response.data)
.catch (response) =>
callback(new Error('api failure'))
# provide a method to cancel the request
abortRequest = () ->
requestHandler.resolve()
return { abort: abortRequest }
getWords: (linesToProcess) ->
lines = @editor.getValue().split("\n")
lines = @adapter.getLines()
words = []
positions = []
for line, row in lines
@ -232,22 +216,6 @@ define [
words.push(word)
return words: words, positions: positions
apiRequest: (endpoint, data, callback = (error, result) ->)->
data.token = window.user.id
data._csrf = window.csrfToken
# use angular timeout option to cancel request if doc is changed
requestHandler = @$q.defer()
options = {timeout: requestHandler.promise}
httpRequest = @$http.post("/spelling" + endpoint, data, options)
.then (response) =>
callback(null, response.data)
.catch (response) =>
callback(new Error('api failure'))
# provide a method to cancel the request
abortRequest = () ->
requestHandler.resolve()
return { abort: abortRequest }
blacklistedCommandRegex: ///
\\ # initial backslash
(label # any of these commands

View file

@ -8,6 +8,12 @@ define [
if $scope.settings.pdfViewer not in ["pdfjs", "native"]
$scope.settings.pdfViewer = "pdfjs"
if $scope.settings.fontFamily? and $scope.settings.fontFamily not in ["monaco", "lucida"]
delete $scope.settings.fontFamily
if $scope.settings.lineHeight? and $scope.settings.lineHeight not in ["compact", "normal", "wide"]
delete $scope.settings.lineHeight
$scope.fontSizeAsStr = (newVal) ->
if newVal?
$scope.settings.fontSize = newVal
@ -41,6 +47,14 @@ define [
if syntaxValidation != oldSyntaxValidation
settings.saveSettings({syntaxValidation: syntaxValidation})
$scope.$watch "settings.fontFamily", (fontFamily, oldFontFamily) =>
if fontFamily != oldFontFamily
settings.saveSettings({fontFamily: fontFamily})
$scope.$watch "settings.lineHeight", (lineHeight, oldLineHeight) =>
if lineHeight != oldLineHeight
settings.saveSettings({lineHeight: lineHeight})
$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
return if @ignoreUpdates
if oldLanguage? and language != oldLanguage

View file

@ -11,8 +11,8 @@ define [
w = window.open()
go = () ->
ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
if window.redirectToOLFreeTrialUrl?
url = window.redirectToOLFreeTrialUrl
if window.useV2TrialUrl
url = "/user/trial"
else
url = "/user/subscription/new?planCode=#{plan}&ssp=true"
if couponCode?

View file

@ -1,79 +1,7 @@
define [
"base"
"libs/platform"
"services/algolia-search"
], (App, platform) ->
App.controller 'ContactModal', ($scope, $modal) ->
$scope.contactUsModal = () ->
modalInstance = $modal.open(
templateUrl: "supportModalTemplate"
controller: "SupportModalController"
)
App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
$scope.form = {}
$scope.sent = false
$scope.sending = false
$scope.suggestions = [];
_handleSearchResults = (success, results) ->
suggestions = for hit in results.hits
page_underscored = hit.pageName.replace(/\s/g,'_')
suggestion =
url :"/learn/kb/#{page_underscored}"
name : hit._highlightResult.pageName.value
event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
$scope.$applyAsync () ->
$scope.suggestions = suggestions
$scope.contactUs = ->
if !$scope.form.email? or $scope.form.email == ""
console.log "email not set"
return
$scope.sending = true
ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
message = $scope.form.message
if $scope.form.project_url?
message = "#{message}\n\n project_url = #{$scope.form.project_url}"
params =
email: $scope.form.email
message: message or ""
subject: $scope.form.subject + " - [#{ticketNumber}]"
labels: "support"
about: "<div>browser: #{platform?.name} #{platform?.version}</div>
<div>os: #{platform?.os?.family} #{platform?.os?.version}</div>"
Groove.createTicket params, (response)->
$scope.sending = false
if response.responseText == "" # Blocked request or similar
$scope.error = true
else
data = JSON.parse(response.responseText)
if data.errors?
$scope.error = true
else
$scope.sent = true
$scope.$apply()
$scope.$watch "form.subject", (newVal, oldVal) ->
if newVal and newVal != oldVal and newVal.length > 3
algoliaSearch.searchKB newVal, _handleSearchResults, {
hitsPerPage: 3
typoTolerance: 'strict'
}
else
$scope.suggestions = [];
$scope.clickSuggestionLink = (url) ->
event_tracking.sendMB "contact-form-suggestions-clicked", { url }
$scope.close = () ->
$modalInstance.close()
App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
$scope.form = {}

View file

@ -55,32 +55,4 @@ define [
hits = _.map response.hits, buildHitViewModel
updateHits hits
$scope.showMissingTemplateModal = () ->
modalInstance = $modal.open(
templateUrl: "missingWikiPageModal"
controller: "MissingWikiPageController"
)
App.controller 'MissingWikiPageController', ($scope, $modalInstance) ->
$scope.form = {}
$scope.sent = false
$scope.sending = false
$scope.contactUs = ->
if !$scope.form.message?
console.log "message not set"
return
$scope.sending = true
ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
params =
email: $scope.form.email or "support@sharelatex.com"
message: $scope.form.message or ""
subject: "new wiki page sujection - [#{ticketNumber}]"
labels: "support wiki"
Groove.createTicket params, (err, json)->
$scope.sent = true
$scope.$apply()
$scope.close = () ->
$modalInstance.close()

View file

@ -8,6 +8,7 @@ define [
$scope.notifications = window.data.notifications
$scope.allSelected = false
$scope.selectedProjects = []
$scope.isArchiveableProjectSelected = false
$scope.filter = "all"
$scope.predicate = "lastUpdated"
$scope.nUntagged = 0
@ -85,6 +86,8 @@ define [
$scope.updateSelectedProjects = () ->
$scope.selectedProjects = $scope.projects.filter (project) -> project.selected
$scope.isArchiveableProjectSelected = $scope.selectedProjects.some (project) ->
window.user_id == project.owner._id
$scope.getSelectedProjects = () ->
$scope.selectedProjects

View file

@ -8,7 +8,7 @@ define [
kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
service =
searchWiki: wikiIdx.search.bind(wikiIdx)
searchKB: kbIdx.search.bind(kbIdx)
searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null
searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null
return service

View file

@ -0,0 +1,71 @@
ace.define("ace/theme/overleaf",["require","exports","module","ace/lib/dom"], function(require, exports, module) {
"use strict";
exports.isDark = false;
exports.cssClass = "ace-overleaf";
exports.cssText = ".ace-overleaf .ace_gutter {\
background: #f0f0f0;\
color: #333;\
}\
.ace-overleaf .ace_print-margin {\
width: 1px;\
background: #e8e8e8;\
}\
.ace-overleaf {\
background-color: #FFFFFF;\
color: black;\
}\
.ace-overleaf .ace_cursor {\
color: black;\
}\
.ace-overleaf .ace_marker-layer .ace_selection {\
background: rgb(181, 213, 255);\
}\
.ace-overleaf.ace_multiselect .ace_selection.ace_start {\
box-shadow: 0 0 3px 0px white;\
}\
.ace-overleaf .ace_marker-layer .ace_step {\
background: rgb(252, 255, 0);\
}\
.ace-overleaf .ace_marker-layer .ace_bracket {\
border: 1px solid #5A5CAD;\
}\
.ace-overleaf .ace_marker-layer .ace_active-line {\
background: rgba(0, 0, 0, 0.07);\
}\
.ace-overleaf .ace_gutter-active-line {\
background-color: #dcdcdc;\
}\
.ace-overleaf .ace_marker-layer .ace_selected-word {\
background: rgb(250, 250, 255);\
border: 1px solid rgb(200, 200, 250);\
}\
.ace-overleaf .ace_fold {\
background-color: #6B72E6;\
}\
.ace-overleaf .ace_comment {\
color: #0080FF;\
font-style: italic;\
}\
.ace-overleaf .ace_storage,\
.ace-overleaf .ace_keyword {\
color: #3F7F7F;\
}\
.ace-overleaf .ace_variable,\
.ace-overleaf .ace_string {\
color: #5A5CAD;\
}\
";
exports.$id = "ace/theme/overleaf";
var dom = require("../lib/dom");
dom.importCssString(exports.cssText, exports.cssClass);
});
(function() {
ace.require(["ace/theme/overleaf"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

View file

@ -1,2 +1,3 @@
@import "app/sidebar-v2-dash-pane.less";
@import "app/front-chat-widget.less";
@import "app/front-chat-widget.less";
@import "app/ol-chat.less";

View file

@ -47,6 +47,7 @@
@import "components/tooltip.less";
@import "components/popovers.less";
@import "components/carousel.less";
@import "components/daterange-picker";
// ngTagsInput
@import "components/tags-input.less";
@ -80,6 +81,7 @@
@import "app/error-pages.less";
@import "app/v1-badge.less";
@import "app/editor/history-v2.less";
@import "app/metrics.less";
// Vendor CSS
@import "../js/libs/pdfListView/TextLayer.css";

View file

@ -13,6 +13,7 @@
@import "./editor/hotkeys.less";
@import "./editor/review-panel.less";
@import "./editor/rich-text.less";
@import "./editor/publish-modal.less";
@ui-layout-toggler-def-height: 50px;
@ui-resizer-size: 7px;
@ -204,13 +205,9 @@
}
}
.cm-editor-wrapper {
height: 100%;
.CodeMirror {
height: 100%;
}
}
/**************************************
Ace
***************************************/
// The internal components of the aceEditor directive
.ace-editor-wrapper {
@ -285,6 +282,23 @@
}
}
/**************************************
CodeMirror
***************************************/
.cm-editor-wrapper {
position: relative;
height: 100%;
}
.cm-editor-body {
height: 100%;
}
// CM (for some reason) has height set to 300px in it's stylesheet
.CodeMirror {
height: 100%;
}
.ui-layout-resizer when (@is-overleaf = false) {
width: 6px;
background-color: @editor-resizer-bg-color;

View file

@ -0,0 +1,89 @@
.modal-body-publish {
#search-input-container {
overflow: hidden;
margin: 5px 0 10px;
}
.table-content-name {
width: 100%;
margin-bottom: 10px;
font-weight: 300;
}
.table-content-category {
font-weight: 300;
text-align: right;
font-style: italic;
width: 30%;
float: right;
text-transform: capitalize
}
.table-content-category ~ .table-content-name {
width: 70%;
display: inline-block;
}
.wl-icon:before{
font-size: 14px;
}
.button-as-link{
color: green;
text-transform: none;
background: none;
padding: 0;
border: none;
border-radius: 0;
font-size: 14px;
@extend a;
&:hover,
&:active,
&:focus {
color: green;
background: none;
}
text-align: left;
}
.affix-content-title {
color: @gray-light;
font-size: 1.2em;
padding-left: 10px;
}
.affix-subcontent {
margin: 5px 0 50px;
}
.overbox {
padding: @line-height-computed / 2;
background-color: white;
margin-top: @line-height-computed / 2;
border: 1px solid @gray-lighter;
}
.content-as-table {
.table-content,
.table-content > * {
display: table;
}
.table-content-icon {
float: left;
height: 80px;
width: 106px;
border: 1px solid @gray-lightest;
display: flex;
align-items: center;
overflow: hidden;
* {
width: 100%;
}
}
.table-content-text {
float: right;
width: calc(~'100% - 106px');
vertical-align: top;
padding-left: 15px;
}
.table-content-slogan {
height: 80px;
overflow: hidden;
}
.table-content-link {
padding-top: 10px;
}
}
}

View file

@ -775,6 +775,12 @@
}
}
#editor-rich-text {
.rp-size-expanded & {
right: @review-panel-width;
}
}
.rp-toggle {
display: inline-block;
vertical-align: middle;

View file

@ -219,4 +219,11 @@
font-style: italic;
color: #999;
}
.spelling-error {
background-image: url(/img/spellcheck-underline.png);
background-repeat: repeat-x;
background-position: bottom;
}
}

View file

@ -0,0 +1,177 @@
#metrics {
max-width: none;
padding: 0 30px;
width: auto;
svg.nvd3-svg {
width: 100%;
}
.overbox {
margin: 0;
padding: 40px 20px;
background: #fff;
border: 1px solid #DFDFDF;
.box {
padding-bottom: 30px;
overflow: hidden;
margin-bottom: 40px;
border-bottom: 1px solid rgb(216, 216, 216);
.header {
margin-bottom: 20px;
h4 {
font-size: 19px;
margin: 0;
}
}
}
}
.print-button {
margin-right: 10px;
font-size: 20px;
}
.title-button {
margin-right: 5px;
font-size: 20px;
}
.metric-col {
padding: 15px;
}
.metric-header-container {
h4 {
margin-bottom: 0;
}
}
svg {
display: block;
height: 250px;
text {
font-family: "Open Sans", sans-serif;
}
&:not(:root) {
overflow: visible
}
&.hidden-legend-margin-fix {
margin-top: 15px;
height: 235px;
}
&.no-fill-opacity {
.nvd3 {
.nv-area {
fill-opacity: 1;
}
}
}
}
.nvtooltip {
z-index: 10;
}
.tooltip {
width: 150px;
}
// BEGIN: Metrics header
.metric-header-container {
> h4 {
margin-top: 0;
margin-bottom: 0;
}
}
// END: Metrics header
// BEGIN: Metrics footer
.metric-footer-container {
text-align: center;
}
// END: Metrics footer
// BEGIN: Metrics overlays
.metric-overlay-loading,
.metric-overlay-error,
.metric-overlay-backdrop {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
padding: 16px; /* 15px of .metric-col padding + 1px border */
padding-top: 56px; /* Same as above + 30px for title + 10px overbox padding*/
}
.metric-overlay-loading {
padding: 175px 20%;
}
.metric-overlay-error {
display: none;
text-align: center;
padding-top: 175px;
}
.metric-overlay-backdrop {
opacity: 0.5;
}
.metric-overlay-backdrop-inner {
background-color: #fff;
width: 100%;
height: 100%;
}
// END: Metrics overlays
}
#metrics-header {
@media (min-width: 1200px) {
margin-bottom: 30px;
}
.section_header {
margin-bottom: 0;
}
#filters-container {
text-align: right;
.by {
color: #989898;
font-style: italic;
}
}
#lags-container {
.dropdown-menu {
min-width: 0;
}
}
#dates-container {
display: inline-block;
.daterangepicker {
margin-right: 15px;
}
}
}
#metrics-footer {
margin-top: 30px;
text-align: center;
}
body.print-loading {
#metrics {
.metric-col {
opacity: 0.5;
}
}
}

View file

@ -0,0 +1,6 @@
// Styles for Chat panel in Overleaf v2
.chat .message-wrapper .message .message-content a {
color: inherit;
text-decoration: underline;
}

View file

@ -0,0 +1,610 @@
//
// A stylesheet for use with Bootstrap 3.x
// @author: Dan Grossman http://www.dangrossman.info/
// @copyright: Copyright (c) 2012-2015 Dan Grossman. All rights reserved.
// @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php
// @website: https://www.improvely.com/
//
//
// VARIABLES
//
//
// Settings
// The class name to contain everything within.
@arrow-size: 7px;
//
// Colors
@daterangepicker-color: @brand-primary;
@daterangepicker-bg-color: #fff;
@daterangepicker-cell-color: @daterangepicker-color;
@daterangepicker-cell-border-color: transparent;
@daterangepicker-cell-bg-color: @daterangepicker-bg-color;
@daterangepicker-cell-hover-color: @daterangepicker-color;
@daterangepicker-cell-hover-border-color: @daterangepicker-cell-border-color;
@daterangepicker-cell-hover-bg-color: #eee;
@daterangepicker-in-range-color: #000;
@daterangepicker-in-range-border-color: transparent;
@daterangepicker-in-range-bg-color: #ebf4f8;
@daterangepicker-active-color: #fff;
@daterangepicker-active-bg-color: #a93529;
@daterangepicker-active-border-color: transparent;
@daterangepicker-unselected-color: #999;
@daterangepicker-unselected-border-color: transparent;
@daterangepicker-unselected-bg-color: #fff;
//
// daterangepicker
@daterangepicker-width: 278px;
@daterangepicker-padding: 4px;
@daterangepicker-z-index: 3000;
@daterangepicker-border-size: 1px;
@daterangepicker-border-color: #ccc;
@daterangepicker-border-radius: 4px;
//
// Calendar
@daterangepicker-calendar-margin: @daterangepicker-padding;
@daterangepicker-calendar-bg-color: @daterangepicker-bg-color;
@daterangepicker-calendar-border-size: 1px;
@daterangepicker-calendar-border-color: @daterangepicker-bg-color;
@daterangepicker-calendar-border-radius: @daterangepicker-border-radius;
//
// Calendar Cells
@daterangepicker-cell-size: 20px;
@daterangepicker-cell-width: @daterangepicker-cell-size;
@daterangepicker-cell-height: @daterangepicker-cell-size;
@daterangepicker-cell-border-radius: @daterangepicker-calendar-border-radius;
@daterangepicker-cell-border-size: 1px;
//
// Dropdowns
@daterangepicker-dropdown-z-index: @daterangepicker-z-index + 1;
//
// Controls
@daterangepicker-control-height: 30px;
@daterangepicker-control-line-height: @daterangepicker-control-height;
@daterangepicker-control-color: #555;
@daterangepicker-control-border-size: 1px;
@daterangepicker-control-border-color: #ccc;
@daterangepicker-control-border-radius: 4px;
@daterangepicker-control-active-border-size: 1px;
@daterangepicker-control-active-border-color: @brand-primary;
@daterangepicker-control-active-border-radius: @daterangepicker-control-border-radius;
@daterangepicker-control-disabled-color: #ccc;
//
// Ranges
@daterangepicker-ranges-color: @brand-primary;
@daterangepicker-ranges-bg-color: daterangepicker-ranges-color;
@daterangepicker-ranges-border-size: 1px;
@daterangepicker-ranges-border-color: @daterangepicker-ranges-bg-color;
@daterangepicker-ranges-border-radius: @daterangepicker-border-radius;
@daterangepicker-ranges-hover-color: #fff;
@daterangepicker-ranges-hover-bg-color: @daterangepicker-ranges-color;
@daterangepicker-ranges-hover-border-size: @daterangepicker-ranges-border-size;
@daterangepicker-ranges-hover-border-color: @daterangepicker-ranges-hover-bg-color;
@daterangepicker-ranges-hover-border-radius: @daterangepicker-border-radius;
@daterangepicker-ranges-active-border-size: @daterangepicker-ranges-border-size;
@daterangepicker-ranges-active-border-color: @daterangepicker-ranges-bg-color;
@daterangepicker-ranges-active-border-radius: @daterangepicker-border-radius;
//
// STYLESHEETS
//
.daterangepicker {
position: absolute;
color: @daterangepicker-color;
background-color: @daterangepicker-bg-color;
border-radius: @daterangepicker-border-radius;
width: @daterangepicker-width;
padding: @daterangepicker-padding;
margin-top: @daterangepicker-border-size;
// TODO: Should these be parameterized??
// top: 100px;
// left: 20px;
@arrow-prefix-size: @arrow-size;
@arrow-suffix-size: (@arrow-size - @daterangepicker-border-size);
&:before, &:after {
position: absolute;
display: inline-block;
border-bottom-color: rgba(0, 0, 0, 0.2);
content: '';
}
&:before {
top: -@arrow-prefix-size;
border-right: @arrow-prefix-size solid transparent;
border-left: @arrow-prefix-size solid transparent;
border-bottom: @arrow-prefix-size solid @daterangepicker-border-color;
}
&:after {
top: -@arrow-suffix-size;
border-right: @arrow-suffix-size solid transparent;
border-bottom: @arrow-suffix-size solid @daterangepicker-bg-color;
border-left: @arrow-suffix-size solid transparent;
}
&.opensleft {
&:before {
// TODO: Make this relative to prefix size.
right: @arrow-prefix-size + 2px;
}
&:after {
// TODO: Make this relative to suffix size.
right: @arrow-suffix-size + 4px;
}
}
&.openscenter {
&:before {
left: 0;
right: 0;
width: 0;
margin-left: auto;
margin-right: auto;
}
&:after {
left: 0;
right: 0;
width: 0;
margin-left: auto;
margin-right: auto;
}
}
&.opensright {
&:before {
// TODO: Make this relative to prefix size.
left: @arrow-prefix-size + 2px;
}
&:after {
// TODO: Make this relative to suffix size.
left: @arrow-suffix-size + 4px;
}
}
&.dropup {
margin-top: -5px;
// NOTE: Note sure why these are special-cased.
&:before {
top: initial;
bottom: -@arrow-prefix-size;
border-bottom: initial;
border-top: @arrow-prefix-size solid @daterangepicker-border-color;
}
&:after {
top: initial;
bottom:-@arrow-suffix-size;
border-bottom: initial;
border-top: @arrow-suffix-size solid @daterangepicker-bg-color;
}
}
&.dropdown-menu {
max-width: none;
z-index: @daterangepicker-dropdown-z-index;
}
&.single {
.ranges, .calendar {
float: none;
}
}
/* Calendars */
&.show-calendar {
.calendar {
display: block;
}
}
.calendar {
display: none;
max-width: @daterangepicker-width - (@daterangepicker-calendar-margin * 2);
margin: @daterangepicker-calendar-margin;
&.single {
.calendar-table {
border: none;
}
}
th, td {
white-space: nowrap;
text-align: center;
// TODO: Should this actually be hard-coded?
min-width: 32px;
}
}
.calendar-table {
border: @daterangepicker-calendar-border-size solid @daterangepicker-calendar-border-color;
padding: @daterangepicker-calendar-margin;
border-radius: @daterangepicker-calendar-border-radius;
background-color: @daterangepicker-calendar-bg-color;
}
table {
width: 100%;
margin: 0;
}
td, th {
text-align: center;
width: @daterangepicker-cell-width;
height: @daterangepicker-cell-height;
border-radius: @daterangepicker-cell-border-radius;
border: @daterangepicker-cell-border-size solid @daterangepicker-cell-border-color;
white-space: nowrap;
cursor: pointer;
&.available {
&:hover {
background-color: @daterangepicker-cell-hover-bg-color;
border-color: @daterangepicker-cell-hover-border-color;
color: @daterangepicker-cell-hover-color;
}
}
&.week {
font-size: 80%;
color: #ccc;
}
}
td {
&.off {
&, &.in-range, &.start-date, &.end-date {
background-color: @daterangepicker-unselected-bg-color;
border-color: @daterangepicker-unselected-border-color;
color: @daterangepicker-unselected-color;
}
}
//
// Date Range
&.in-range {
background-color: @daterangepicker-in-range-bg-color;
border-color: @daterangepicker-in-range-border-color;
color: @daterangepicker-in-range-color;
// TODO: Should this be static or should it be parameterized?
border-radius: 0;
}
&.start-date {
border-radius: @daterangepicker-cell-border-radius 0 0 @daterangepicker-cell-border-radius;
}
&.end-date {
border-radius: 0 @daterangepicker-cell-border-radius @daterangepicker-cell-border-radius 0;
}
&.start-date.end-date {
border-radius: @daterangepicker-cell-border-radius;
}
&.active {
&, &:hover {
background-color: @daterangepicker-active-bg-color;
border-color: @daterangepicker-active-border-color;
color: @daterangepicker-active-color;
}
}
}
th {
&.month {
width: auto;
}
}
//
// Disabled Controls
//
td, option {
&.disabled {
color: #999;
cursor: not-allowed;
text-decoration: line-through;
}
}
select {
&.monthselect, &.yearselect {
font-size: 12px;
padding: 1px;
height: auto;
margin: 0;
cursor: default;
}
&.monthselect {
margin-right: 2%;
width: 56%;
}
&.yearselect {
width: 40%;
}
&.hourselect, &.minuteselect, &.secondselect, &.ampmselect {
width: 50px;
margin-bottom: 0;
}
}
//
// Text Input Controls (above calendar)
//
.input-mini {
border: @daterangepicker-control-border-size solid @daterangepicker-control-border-color;
border-radius: @daterangepicker-control-border-radius;
color: @daterangepicker-control-color;
height: @daterangepicker-control-line-height;
line-height: @daterangepicker-control-height;
display: block;
vertical-align: middle;
// TODO: Should these all be static, too??
margin: 0 0 5px 0;
padding: 0 6px 0 28px;
width: 100%;
&.active {
border: @daterangepicker-control-active-border-size solid @daterangepicker-control-active-border-color;
border-radius: @daterangepicker-control-active-border-radius;
}
}
.daterangepicker_input {
position: relative;
i {
position: absolute;
// NOTE: These appear to be eyeballed to me...
left: 8px;
top: 8px;
}
}
&.rtl {
.input-mini {
padding-right: 28px;
padding-left: 6px;
}
.daterangepicker_input i {
left: auto;
right: 8px;
}
}
//
// Time Picker
//
.calendar-time {
text-align: center;
margin: 5px auto;
line-height: @daterangepicker-control-line-height;
position: relative;
padding-left: 28px;
select {
&.disabled {
color: @daterangepicker-control-disabled-color;
cursor: not-allowed;
}
}
}
}
//
// Predefined Ranges
//
.ranges {
font-size: 11px;
float: none;
margin: 4px;
text-align: left;
ul {
list-style: none;
margin: 0 auto;
padding: 0;
width: 100%;
}
li {
font-size: 13px;
background-color: @daterangepicker-ranges-bg-color;
border: @daterangepicker-ranges-border-size solid @daterangepicker-ranges-border-color;
border-radius: @daterangepicker-ranges-border-radius;
color: @daterangepicker-ranges-color;
padding: 3px 12px;
margin-bottom: 8px;
cursor: pointer;
&:hover {
background-color: @daterangepicker-ranges-hover-bg-color;
border: @daterangepicker-ranges-hover-border-size solid @daterangepicker-ranges-hover-border-color;
color: @daterangepicker-ranges-hover-color;
}
&.active {
background-color: @daterangepicker-ranges-hover-bg-color;
border: @daterangepicker-ranges-hover-border-size solid @daterangepicker-ranges-hover-border-color;
color: @daterangepicker-ranges-hover-color;
}
}
}
/* Larger Screen Styling */
@media (min-width: 564px) {
.daterangepicker {
width: auto;
.ranges {
ul {
width: 160px;
}
}
&.single {
.ranges {
ul {
width: 100%;
}
}
.calendar.left {
clear: none;
}
&.ltr {
.ranges, .calendar {
float:left;
}
}
&.rtl {
.ranges, .calendar {
float:right;
}
}
}
&.ltr {
direction: ltr;
text-align: left;
.calendar{
&.left {
clear: left;
margin-right: 0;
.calendar-table {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&.right {
margin-left: 0;
.calendar-table {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
.left .daterangepicker_input {
padding-right: 12px;
}
.calendar.left .calendar-table {
padding-right: 12px;
}
.ranges, .calendar {
float: left;
}
}
&.rtl {
direction: rtl;
text-align: right;
.calendar{
&.left {
clear: right;
margin-left: 0;
.calendar-table {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.right {
margin-right: 0;
.calendar-table {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.left .daterangepicker_input {
padding-left: 12px;
}
.calendar.left .calendar-table {
padding-left: 12px;
}
.ranges, .calendar {
text-align: right;
float: right;
}
}
}
}
@media (min-width: 730px) {
.daterangepicker {
.ranges {
width: auto;
}
&.ltr {
.ranges {
float: left;
}
}
&.rtl {
.ranges {
float: right;
}
}
.calendar.left {
clear: none !important;
}
}
}

View file

@ -0,0 +1,56 @@
expect = require('chai').expect
request = require './helpers/request'
_ = require 'underscore'
User = require './helpers/User'
ProjectGetter = require '../../../app/js/Features/Project/ProjectGetter.js'
ExportsHandler = require '../../../app/js/Features/Exports/ExportsHandler.js'
MockProjectHistoryApi = require './helpers/MockProjectHistoryApi'
MockV1Api = require './helpers/MockV1Api'
describe 'Exports', ->
before (done) ->
@brand_variation_id = '18'
@owner = new User()
@owner.login (error) =>
throw error if error?
@owner.createProject 'example-project', {template: 'example'}, (error, @project_id) =>
throw error if error?
done()
describe 'exporting a project', ->
beforeEach (done) ->
@version = Math.floor(Math.random() * 10000)
MockProjectHistoryApi.setProjectVersion(@project_id, @version)
@export_id = Math.floor(Math.random() * 10000)
MockV1Api.setExportId(@export_id)
MockV1Api.clearExportParams()
@owner.request {
method: 'POST',
url: "/project/#{@project_id}/export/#{@brand_variation_id}",
json: {},
}, (error, response, body) =>
throw error if error?
expect(response.statusCode).to.equal 200
@exportResponseBody = body
done()
it 'should have sent correct data to v1', (done) ->
{project, user, destination, options} = MockV1Api.getLastExportParams()
# project details should match
expect(project.id).to.equal @project_id
expect(project.rootDocPath).to.equal '/main.tex'
# version should match what was retrieved from project-history
expect(project.historyVersion).to.equal @version
# user details should match
expect(user.id).to.equal @owner.id
expect(user.email).to.equal @owner.email
# brand-variation should match
expect(destination.brandVariationId).to.equal @brand_variation_id
done()
it 'should have returned the export ID provided by v1', (done) ->
expect(@exportResponseBody.export_v1_id).to.equal @export_id
done()

View file

@ -16,6 +16,7 @@ createInvite = (sendingUser, projectId, email, callback=(err, invite)->) ->
privileges: 'readAndWrite'
}, (err, response, body) ->
return callback(err) if err
expect(response.statusCode).to.equal 200
callback(null, body.invite)
createProject = (owner, projectName, callback=(err, projectId, project)->) ->
@ -207,9 +208,9 @@ describe "ProjectInviteTests", ->
@email = 'smoketestuser@example.com'
@projectName = 'sharing test'
Async.series [
(cb) => @user.login cb
(cb) => @user.logout cb
(cb) => @user.ensureUserExists cb
(cb) => @sendingUser.login cb
(cb) => @sendingUser.setFeatures { collaborators: 10 }, cb
], done
describe 'creating invites', ->
@ -266,7 +267,7 @@ describe "ProjectInviteTests", ->
(cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 0, cb
], done
it 'should allow the project owner to many invites at once', (done) ->
it 'should allow the project owner to create many invites at once', (done) ->
@inviteOne = null
@inviteTwo = null
Async.series [

View file

@ -22,6 +22,7 @@ describe "ProjectStructureMongoLock", ->
before (done) ->
# We want to instantly fail if the lock is taken
LockManager.MAX_LOCK_WAIT_TIME = 1
@lockValue = "lock-value"
userDetails =
holdingAccount:false,
email: 'test@example.com'
@ -33,11 +34,13 @@ describe "ProjectStructureMongoLock", ->
@locked_project = project
namespace = ProjectEntityMongoUpdateHandler.LOCK_NAMESPACE
@lock_key = "lock:web:#{namespace}:#{project._id}"
LockManager._getLock @lock_key, namespace, done
LockManager._getLock @lock_key, namespace, (err, lockValue) =>
@lockValue = lockValue
done()
return
after (done) ->
LockManager._releaseLock @lock_key, done
LockManager._releaseLock @lock_key, @lockValue, done
describe 'interacting with the locked project', ->
LOCKING_UPDATE_METHODS = ['addDoc', 'addFile', 'mkdirp', 'moveEntity', 'renameEntity', 'addFolder']

View file

@ -0,0 +1,151 @@
expect = require("chai").expect
async = require("async")
UserClient = require "./helpers/User"
request = require "./helpers/request"
settings = require "settings-sharelatex"
{ObjectId} = require("../../../app/js/infrastructure/mongojs")
Subscription = require("../../../app/js/models/Subscription").Subscription
User = require("../../../app/js/models/User").User
MockV1Api = require "./helpers/MockV1Api"
syncUserAndGetFeatures = (user, callback = (error, features) ->) ->
request {
method: 'POST',
url: "/user/#{user._id}/features/sync",
auth:
user: 'sharelatex'
pass: 'password'
sendImmediately: true
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
User.findById user._id, (error, user) ->
return callback(error) if error?
features = user.toObject().features
delete features.$init # mongoose internals
return callback null, features
describe "Subscriptions", ->
beforeEach (done) ->
@user = new UserClient()
@user.ensureUserExists (error) ->
throw error if error?
done()
describe "when user has no subscriptions", ->
it "should set their features to the basic set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
expect(features).to.deep.equal(settings.defaultFeatures)
done()
describe "when the user has an individual subscription", ->
beforeEach ->
Subscription.create {
admin_id: @user._id
planCode: 'collaborator'
customAccount: true
} # returns a promise
it "should set their features to the upgraded set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user is in a group subscription", ->
beforeEach ->
Subscription.create {
admin_id: ObjectId()
member_ids: [@user._id]
groupAccount: true
planCode: 'collaborator'
customAccount: true
} # returns a promise
it "should set their features to the upgraded set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user has bonus features", ->
beforeEach ->
User.update {
_id: @user._id
}, {
refered_user_count: 10
} # returns a promise
it "should set their features to the bonus set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
expect(features).to.deep.equal(Object.assign(
{}, settings.defaultFeatures, settings.bonus_features[9]
))
done()
describe "when the user has a v1 plan", ->
beforeEach ->
MockV1Api.setUser 42, plan_name: 'free'
User.update {
_id: @user._id
}, {
overleaf:
id: 42
} # returns a promise
it "should set their features to the v1 plan", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user has a v1 plan and bonus features", ->
beforeEach ->
MockV1Api.setUser 42, plan_name: 'free'
User.update {
_id: @user._id
}, {
overleaf:
id: 42
refered_user_count: 10
} # returns a promise
it "should set their features to the best of the v1 plan and bonus features", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
v1plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
expectedFeatures = Object.assign(
{}, v1plan.features, settings.bonus_features[9]
)
expect(features).to.deep.equal(expectedFeatures)
done()
describe "when the user has a group and personal subscription", ->
beforeEach (done) ->
Subscription.create {
admin_id: @user._id
planCode: 'professional'
customAccount: true
}, (error) =>
throw error if error?
Subscription.create {
admin_id: ObjectId()
member_ids: [@user._id]
groupAccount: true
planCode: 'collaborator'
customAccount: true
}, done
return
it "should set their features to the best set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'professional'
expect(features).to.deep.equal(plan.features)
done()

View file

@ -6,9 +6,14 @@ module.exports = MockProjectHistoryApi =
oldFiles: {}
projectVersions: {}
addOldFile: (project_id, version, pathname, content) ->
@oldFiles["#{project_id}:#{version}:#{pathname}"] = content
setProjectVersion: (project_id, version) ->
@projectVersions[project_id] = version
run: () ->
app.post "/project", (req, res, next) =>
res.json project: id: 1
@ -21,6 +26,13 @@ module.exports = MockProjectHistoryApi =
else
res.send 404
app.get "/project/:project_id/version", (req, res, next) =>
{project_id} = req.params
if @projectVersions[project_id]?
res.json version: @projectVersions[project_id]
else
res.send 404
app.listen 3054, (error) ->
throw error if error?
.on "error", (error) ->

View file

@ -0,0 +1,45 @@
express = require("express")
app = express()
bodyParser = require('body-parser')
app.use(bodyParser.json())
module.exports = MockV1Api =
users: { }
setUser: (id, user) ->
@users[id] = user
exportId: null
exportParams: null
setExportId: (id) ->
@exportId = id
getLastExportParams: () ->
@exportParams
clearExportParams: () ->
@exportParams = null
run: () ->
app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) =>
user = @users[req.params.ol_user_id]
if user
res.json user
else
res.sendStatus 404
app.post "/api/v1/sharelatex/exports", (req, res, next) =>
#{project, version, pathname}
@exportParams = Object.assign({}, req.body)
res.json exportId: @exportId
app.listen 5000, (error) ->
throw error if error?
.on "error", (error) ->
console.error "error starting MockV1Api:", error.message
process.exit(1)
MockV1Api.run()

View file

@ -40,6 +40,12 @@ class User
@referal_id = user?.referal_id
callback(null, @password)
setFeatures: (features, callback = (error) ->) ->
update = {}
for key, value of features
update["features.#{key}"] = value
UserModel.update { _id: @id }, update, callback
logout: (callback = (error) ->) ->
@getCsrfToken (error) =>
return callback(error) if error?

View file

@ -0,0 +1,95 @@
module.exports =
enableSubscriptions: true
features: features =
v1_free:
collaborators: 1
dropbox: false
versioning: false
github: true
templates: false
references: false
referencesSearch: false
mendeley: true
compileTimeout: 60
compileGroup: "standard"
trackChanges: false
personal:
collaborators: 1
dropbox: false
versioning: false
github: false
templates: false
references: false
referencesSearch: false
mendeley: false
compileTimeout: 60
compileGroup: "standard"
trackChanges: false
collaborator:
collaborators: 10
dropbox: true
versioning: true
github: true
templates: true
references: true
referencesSearch: true
mendeley: true
compileTimeout: 180
compileGroup: "priority"
trackChanges: true
professional:
collaborators: -1
dropbox: true
versioning: true
github: true
templates: true
references: true
referencesSearch: true
mendeley: true
compileTimeout: 180
compileGroup: "priority"
trackChanges: true
defaultFeatures: features.personal
defaultPlanCode: 'personal'
plans: plans = [{
planCode: "v1_free"
name: "V1 Free"
price: 0
features: features.v1_free
},{
planCode: "personal"
name: "Personal"
price: 0
features: features.personal
},{
planCode: "collaborator"
name: "Collaborator"
price: 1500
features: features.collaborator
},{
planCode: "professional"
name: "Professional"
price: 3000
features: features.professional
}]
bonus_features:
1:
collaborators: 2
dropbox: false
versioning: false
3:
collaborators: 4
dropbox: false
versioning: false
6:
collaborators: 4
dropbox: true
versioning: true
9:
collaborators: -1
dropbox: true
versioning: true

View file

@ -23,8 +23,8 @@ describe "BetaProgramController", ->
optIn: sinon.stub()
optOut: sinon.stub()
},
"../User/UserLocator": @UserLocator = {
findById: sinon.stub()
"../User/UserGetter": @UserGetter = {
getUser: sinon.stub()
},
"settings-sharelatex": @settings = {
languages: {}
@ -119,7 +119,7 @@ describe "BetaProgramController", ->
describe "optInPage", ->
beforeEach ->
@UserLocator.findById.callsArgWith(1, null, @user)
@UserGetter.getUser.callsArgWith(1, null, @user)
it "should render the opt-in page", () ->
@BetaProgramController.optInPage @req, @res, @next
@ -128,10 +128,10 @@ describe "BetaProgramController", ->
args[0].should.equal 'beta_program/opt_in'
describe "when UserLocator.findById produces an error", ->
describe "when UserGetter.getUser produces an error", ->
beforeEach ->
@UserLocator.findById.callsArgWith(1, new Error('woops'))
@UserGetter.getUser.callsArgWith(1, new Error('woops'))
it "should not render the opt-in page", () ->
@BetaProgramController.optInPage @req, @res, @next

View file

@ -24,11 +24,14 @@ describe "CollaboratorsInviteController", ->
addCount: sinon.stub
@LimitationsManager = {}
@UserGetter =
getUserByMainEmail: sinon.stub()
getUser: sinon.stub()
@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
"../Project/ProjectGetter": @ProjectGetter = {}
'../Subscription/LimitationsManager' : @LimitationsManager
'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
'../User/UserGetter': @UserGetter
"./CollaboratorsHandler": @CollaboratorsHandler = {}
"./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
@ -713,7 +716,7 @@ describe "CollaboratorsInviteController", ->
beforeEach ->
@user = {_id: ObjectId().toString()}
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
it 'should callback with `true`', (done) ->
@call (err, shouldAllow) =>
@ -725,7 +728,7 @@ describe "CollaboratorsInviteController", ->
beforeEach ->
@user = null
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
it 'should callback with `false`', (done) ->
@call (err, shouldAllow) =>
@ -735,15 +738,15 @@ describe "CollaboratorsInviteController", ->
it 'should have called getUser', (done) ->
@call (err, shouldAllow) =>
@UserGetter.getUser.callCount.should.equal 1
@UserGetter.getUser.calledWith({email: @email}, {_id: 1}).should.equal true
@UserGetter.getUserByMainEmail.callCount.should.equal 1
@UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true
done()
describe 'when getUser produces an error', ->
beforeEach ->
@user = null
@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
it 'should callback with an error', (done) ->
@call (err, shouldAllow) =>

View file

@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", ->
_id: ObjectId()
first_name: "jim"
@existingUser = {_id: ObjectId()}
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser)
@fakeProject =
_id: @project_id
name: "some project"
@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", ->
it 'should call getUser', (done) ->
@call (err) =>
@UserGetter.getUser.callCount.should.equal 1
@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
@UserGetter.getUserByMainEmail.callCount.should.equal 1
@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
done()
it 'should call getProject', (done) ->
@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", ->
describe 'when the user does not exist', ->
beforeEach ->
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null)
it 'should not produce an error', (done) ->
@call (err) =>
@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", ->
it 'should call getUser', (done) ->
@call (err) =>
@UserGetter.getUser.callCount.should.equal 1
@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
@UserGetter.getUserByMainEmail.callCount.should.equal 1
@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
done()
it 'should not call getProject', (done) ->
@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", ->
describe 'when the getUser produces an error', ->
beforeEach ->
@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
it 'should produce an error', (done) ->
@call (err) =>
@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", ->
it 'should call getUser', (done) ->
@call (err) =>
@UserGetter.getUser.callCount.should.equal 1
@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
@UserGetter.getUserByMainEmail.callCount.should.equal 1
@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
done()
it 'should not call getProject', (done) ->

View file

@ -0,0 +1,39 @@
SandboxedModule = require('sandboxed-module')
assert = require('assert')
chai = require('chai')
expect = chai.expect
sinon = require('sinon')
modulePath = require('path').join __dirname, '../../../../app/js/Features/Exports/ExportsController.js'
describe 'ExportsController', ->
project_id = "123njdskj9jlk"
user_id = "123nd3ijdks"
brand_variation_id = 22
beforeEach ->
@handler =
getUserNotifications: sinon.stub().callsArgWith(1)
@req =
params:
project_id: project_id
brand_variation_id: brand_variation_id
session:
user:
_id:user_id
i18n:
translate:->
@AuthenticationController =
getLoggedInUserId: sinon.stub().returns(@req.session.user._id)
@controller = SandboxedModule.require modulePath, requires:
"./ExportsHandler":@handler
'logger-sharelatex':
log:->
err:->
'../Authentication/AuthenticationController': @AuthenticationController
it 'should ask the handler to perform the export', (done) ->
@handler.exportProject = sinon.stub().yields(null, {iAmAnExport: true, v1_id: 897})
@controller.exportProject @req, send:(body) =>
expect(body).to.deep.equal {export_v1_id: 897}
done()

View file

@ -0,0 +1,202 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = '../../../../app/js/Features/Exports/ExportsHandler.js'
SandboxedModule = require('sandboxed-module')
describe 'ExportsHandler', ->
beforeEach ->
@ProjectGetter = {}
@ProjectLocator = {}
@UserGetter = {}
@settings = {}
@stubRequest = {}
@request = defaults: => return @stubRequest
@ExportsHandler = SandboxedModule.require modulePath, requires:
'logger-sharelatex':
log: ->
err: ->
'../Project/ProjectGetter': @ProjectGetter
'../Project/ProjectLocator': @ProjectLocator
'../User/UserGetter': @UserGetter
'settings-sharelatex': @settings
'request': @request
@project_id = "project-id-123"
@project_history_id = 987
@user_id = "user-id-456"
@brand_variation_id = 789
@callback = sinon.stub()
describe 'exportProject', ->
beforeEach (done) ->
@export_data = {iAmAnExport: true}
@response_body = {iAmAResponseBody: true}
@ExportsHandler._buildExport = sinon.stub().yields(null, @export_data)
@ExportsHandler._requestExport = sinon.stub().yields(null, @response_body)
@ExportsHandler.exportProject @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should build the export", ->
@ExportsHandler._buildExport
.calledWith(@project_id, @user_id, @brand_variation_id)
.should.equal true
it "should request the export", ->
@ExportsHandler._requestExport
.calledWith(@export_data)
.should.equal true
it "should return the export", ->
@callback
.calledWith(null, @export_data)
.should.equal true
describe '_buildExport', ->
beforeEach (done) ->
@project =
id: @project_id
overleaf:
history:
id: @project_history_id
@user =
id: @user_id
first_name: 'Arthur'
last_name: 'Author'
email: 'arthur.author@arthurauthoring.org'
@rootDocPath = 'main.tex'
@historyVersion = 777
@ProjectGetter.getProject = sinon.stub().yields(null, @project)
@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, {fileSystem: 'main.tex'}])
@UserGetter.getUser = sinon.stub().yields(null, @user)
@ExportsHandler._requestVersion = sinon.stub().yields(null, @historyVersion)
done()
describe "when all goes well", ->
beforeEach (done) ->
@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should request the project history version", ->
@ExportsHandler._requestVersion.called
.should.equal true
it "should return export data", ->
expected_export_data =
project:
id: @project_id
rootDocPath: @rootDocPath
historyId: @project_history_id
historyVersion: @historyVersion
user:
id: @user_id
firstName: @user.first_name
lastName: @user.last_name
email: @user.email
orcidId: null
destination:
brandVariationId: @brand_variation_id
options:
callbackUrl: null
@callback.calledWith(null, expected_export_data)
.should.equal true
describe "when project is not found", ->
beforeEach (done) ->
@ProjectGetter.getProject = sinon.stub().yields(new Error("project not found"))
@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should return an error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true
describe "when project has no root doc", ->
beforeEach (done) ->
@ProjectLocator.findRootDoc = sinon.stub().yields(null, [null, null])
@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should return an error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true
describe "when user is not found", ->
beforeEach (done) ->
@UserGetter.getUser = sinon.stub().yields(new Error("user not found"))
@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should return an error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true
describe "when project history request fails", ->
beforeEach (done) ->
@ExportsHandler._requestVersion = sinon.stub().yields(new Error("project history call failed"))
@ExportsHandler._buildExport @project_id, @user_id, @brand_variation_id, (error, export_data) =>
@callback(error, export_data)
done()
it "should return an error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true
describe '_requestExport', ->
beforeEach (done) ->
@settings.apis =
v1:
url: 'http://localhost:5000'
user: 'overleaf'
pass: 'pass'
@export_data = {iAmAnExport: true}
@export_id = 4096
@stubPost = sinon.stub().yields(null, {statusCode: 200}, { exportId: @export_id })
done()
describe "when all goes well", ->
beforeEach (done) ->
@stubRequest.post = @stubPost
@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
@callback(error, export_v1_id)
done()
it 'should issue the request', ->
expect(@stubPost.getCall(0).args[0]).to.deep.equal
url: @settings.apis.v1.url + '/api/v1/sharelatex/exports'
auth:
user: @settings.apis.v1.user
pass: @settings.apis.v1.pass
json: @export_data
it 'should return the v1 export id', ->
@callback.calledWith(null, @export_id)
.should.equal true
describe "when the request fails", ->
beforeEach (done) ->
@stubRequest.post = sinon.stub().yields(new Error("export request failed"))
@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
@callback(error, export_v1_id)
done()
it "should return an error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true
describe "when the request returns an error code", ->
beforeEach (done) ->
@stubRequest.post = sinon.stub().yields(null, {statusCode: 401}, { })
@ExportsHandler._requestExport @export_data, (error, export_v1_id) =>
@callback(error, export_v1_id)
done()
it "should return the error", ->
(@callback.args[0][0] instanceof Error)
.should.equal true

View file

@ -16,7 +16,7 @@ describe "PasswordResetHandler", ->
getNewToken:sinon.stub()
getValueFromTokenAndExpire:sinon.stub()
@UserGetter =
getUser:sinon.stub()
getUserByMainEmail:sinon.stub()
@EmailHandler =
sendEmail:sinon.stub()
@AuthenticationManager =
@ -40,7 +40,7 @@ describe "PasswordResetHandler", ->
describe "generateAndEmailResetToken", ->
it "should check the user exists", (done)->
@UserGetter.getUser.callsArgWith(1)
@UserGetter.getUserByMainEmail.callsArgWith(1)
@OneTimeTokenHandler.getNewToken.callsArgWith(1)
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
exists.should.equal false
@ -49,7 +49,7 @@ describe "PasswordResetHandler", ->
it "should send the email with the token", (done)->
@UserGetter.getUser.callsArgWith(1, null, @user)
@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
@EmailHandler.sendEmail.callsArgWith(2)
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
@ -62,7 +62,7 @@ describe "PasswordResetHandler", ->
it "should return exists = false for a holdingAccount", (done) ->
@user.holdingAccount = true
@UserGetter.getUser.callsArgWith(1, null, @user)
@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
@OneTimeTokenHandler.getNewToken.callsArgWith(1)
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
exists.should.equal false

View file

@ -15,6 +15,7 @@ describe "ProjectController", ->
@user =
_id:"588f3ddae8ebc1bac07c9fa4"
first_name: "bjkdsjfk"
features: {}
@settings =
apis:
chat:
@ -300,6 +301,33 @@ describe "ProjectController", ->
done()
@ProjectController.projectListPage @req, @res
describe 'front widget', (done) ->
beforeEach ->
@settings.overleaf =
front_chat_widget_room_id: 'chat-room-id'
it 'should show for paid users', (done) ->
@user.features.github = true
@user.features.dropbox = true
@res.render = (pageName, opts)=>
opts.frontChatWidgetRoomId.should.equal @settings.overleaf.front_chat_widget_room_id
done()
@ProjectController.projectListPage @req, @res
it 'should show for sample users', (done) ->
@user._id = '588f3ddae8ebc1bac07c9f00' # last two digits
@res.render = (pageName, opts)=>
opts.frontChatWidgetRoomId.should.equal @settings.overleaf.front_chat_widget_room_id
done()
@ProjectController.projectListPage @req, @res
it 'should not show for non sample users', (done) ->
@user._id = '588f3ddae8ebc1bac07c9fff' # last two digits
@res.render = (pageName, opts)=>
expect(opts.frontChatWidgetRoomId).to.equal undefined
done()
@ProjectController.projectListPage @req, @res
describe 'with overleaf-integration-web-module hook', ->
beforeEach ->
@V1Response =

View file

@ -98,7 +98,11 @@ describe 'ProjectCreationHandler', ->
it "should set the overleaf id if overleaf id provided", (done)->
overleaf_id = 2345
@handler.createBlankProject ownerId, projectName, overleaf_id, (err, project)->
attributes =
overleaf:
history:
id: overleaf_id
@handler.createBlankProject ownerId, projectName, attributes, (err, project)->
project.overleaf.history.id.should.equal overleaf_id
done()

View file

@ -872,6 +872,12 @@ describe 'ProjectEntityUpdateHandler', ->
.calledWith(@project, @entity, @path, userId)
.should.equal true
it "should should send the update to the doc updater", ->
oldDocs = [ doc: @entity, path: @path ]
@DocumentUpdaterHandler.updateProjectStructure
.calledWith(project_id, projectHistoryId, userId, {oldDocs})
.should.equal true
describe "a folder", ->
beforeEach (done) ->
@folder =
@ -905,6 +911,13 @@ describe 'ProjectEntityUpdateHandler', ->
.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
.should.equal true
it "should should send one update to the doc updater for all docs and files", ->
oldFiles = [ {file: @file2, path: "/folder/file-name-2"}, {file: @file1, path: "/folder/subfolder/file-name-1"} ]
oldDocs = [ {doc: @doc2, path: "/folder/doc-name-2"}, { doc: @doc1, path: "/folder/subfolder/doc-name-1"} ]
@DocumentUpdaterHandler.updateProjectStructure
.calledWith(project_id, projectHistoryId, userId, {oldFiles, oldDocs})
.should.equal true
describe "_cleanUpDoc", ->
beforeEach ->
@doc =
@ -941,12 +954,6 @@ describe 'ProjectEntityUpdateHandler', ->
.calledWith(project_id, @doc._id.toString())
.should.equal true
it "should should send the update to the doc updater", ->
oldDocs = [ doc: @doc, path: @path ]
@DocumentUpdaterHandler.updateProjectStructure
.calledWith(project_id, projectHistoryId, userId, {oldDocs})
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true

View file

@ -4,12 +4,12 @@ require('chai').should()
sinon = require('sinon')
modulePath = require('path').join __dirname, '../../../../app/js/Features/Referal/ReferalAllocator.js'
describe 'Referalallocator', ->
describe 'ReferalAllocator', ->
beforeEach ->
@ReferalAllocator = SandboxedModule.require modulePath, requires:
'../../models/User': User: @User = {}
"../Subscription/SubscriptionLocator": @SubscriptionLocator = {}
"../Subscription/FeaturesUpdater": @FeaturesUpdater = {}
"settings-sharelatex": @Settings = {}
'logger-sharelatex':
log:->
@ -26,7 +26,7 @@ describe 'Referalallocator', ->
@referal_source = "bonus"
@User.update = sinon.stub().callsArgWith 3, null
@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
it 'should update the referring user with the refered users id', ->
@ -44,8 +44,8 @@ describe 'Referalallocator', ->
.calledWith( referal_id: @referal_id )
.should.equal true
it "shoudl assign the user their bonus", ->
@ReferalAllocator.assignBonus
it "should refresh the user's subscription", ->
@FeaturesUpdater.refreshFeatures
.calledWith(@user_id)
.should.equal true
@ -57,7 +57,7 @@ describe 'Referalallocator', ->
@referal_source = "public_share"
@User.update = sinon.stub().callsArgWith 3, null
@User.findOne = sinon.stub().callsArgWith 1, null, { _id: @user_id }
@ReferalAllocator.assignBonus = sinon.stub().callsArg 1
@FeaturesUpdater.refreshFeatures = sinon.stub().yields()
@ReferalAllocator.allocate @referal_id, @new_user_id, @referal_source, @referal_medium, @callback
it 'should not update the referring user with the refered users id', ->
@ -69,118 +69,7 @@ describe 'Referalallocator', ->
.should.equal true
it "should not assign the user a bonus", ->
@ReferalAllocator.assignBonus.called.should.equal false
@FeaturesUpdater.refreshFeatures.called.should.equal false
it "should call the callback", ->
@callback.called.should.equal true
describe "assignBonus", ->
beforeEach ->
@refered_user_count = 3
@Settings.bonus_features =
"3":
collaborators: 3
dropbox: false
versioning: false
stubbedUser = {
refered_user_count: @refered_user_count,
features:{collaborators:1, dropbox:false, versioning:false}
}
@User.findOne = sinon.stub().callsArgWith 1, null, stubbedUser
@User.update = sinon.stub().callsArgWith 2, null
@ReferalAllocator.assignBonus @user_id, @callback
it "should get the users number of refered user", ->
@User.findOne
.calledWith(_id: @user_id)
.should.equal true
it "should update the user to bonus features", ->
@User.update
.calledWith({
_id: @user_id
}, {
$set:
features:
@Settings.bonus_features[@refered_user_count.toString()]
})
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "when there is nothing to assign", ->
beforeEach ->
@ReferalAllocator._calculateBonuses = sinon.stub().returns({})
@stubbedUser =
refered_user_count:4
features:{collaborators:3, versioning:true, dropbox:false}
@Settings.bonus_features =
"4":
collaborators:3
versioning:true
dropbox:false
@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
@User.update = sinon.stub().callsArgWith 2, null
it "should not call update if there are no bonuses to apply", (done)->
@ReferalAllocator.assignBonus @user_id, (err)=>
@User.update.called.should.equal false
done()
describe "when the user has better features already", ->
beforeEach ->
@refered_user_count = 3
@stubbedUser =
refered_user_count:4
features:
collaborators:3
dropbox:false
versioning:false
@Settings.bonus_features =
"4":
collaborators: 10
dropbox: true
versioning: false
@User.findOne = sinon.stub().callsArgWith 1, null, @stubbedUser
@User.update = sinon.stub().callsArgWith 2, null
it "should not set in in mongo when the feature is better", (done)->
@ReferalAllocator.assignBonus @user_id, =>
@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:10} }).should.equal true
done()
it "should not overright if the user has -1 users", (done)->
@stubbedUser.features.collaborators = -1
@ReferalAllocator.assignBonus @user_id, =>
@User.update.calledWith({_id: @user_id }, {$set: features:{dropbox:true, versioning:false, collaborators:-1} }).should.equal true
done()
describe "when the user is not at a bonus level", ->
beforeEach ->
@refered_user_count = 0
@Settings.bonus_features =
"1":
collaborators: 3
dropbox: false
versioning: false
@User.findOne = sinon.stub().callsArgWith 1, null, { refered_user_count: @refered_user_count }
@User.update = sinon.stub().callsArgWith 2, null
@ReferalAllocator.assignBonus @user_id, @callback
it "should get the users number of refered user", ->
@User.findOne
.calledWith(_id: @user_id)
.should.equal true
it "should not update the user to bonus features", ->
@User.update.called.should.equal false
it "should call the callback", ->
@callback.called.should.equal true

Some files were not shown because too many files have changed in this diff Show more