mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-14 23:45:30 +00:00
Merge branch 'master' into pr-v2-history-ui
This commit is contained in:
commit
637c492e6e
120 changed files with 5320 additions and 3091 deletions
2
services/web/Jenkinsfile
vendored
2
services/web/Jenkinsfile
vendored
|
@ -101,7 +101,7 @@ pipeline {
|
|||
}
|
||||
}
|
||||
steps {
|
||||
sh 'make minify'
|
||||
sh 'WEBPACK_ENV=production make minify'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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 = [])->
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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) ->
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}")
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(), {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 []
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
script(type='text/ng-template', id='supportModalTemplate')
|
||||
.modal-header
|
||||
button.close(
|
||||
type="button"
|
||||
data-dismiss="modal"
|
||||
ng-click="close()"
|
||||
) ×
|
||||
h3 #{translate("contact_us")}
|
||||
.modal-body.contact-us-modal
|
||||
form(name="contactForm")
|
||||
span(ng-show="sent == false")
|
||||
.alert.alert-danger(ng-show="error") Something went wrong sending your request :(
|
||||
label
|
||||
| #{translate("subject")}
|
||||
.form-group
|
||||
input.field.text.medium.span8.form-control(
|
||||
name="subject",
|
||||
required
|
||||
ng-model="form.subject",
|
||||
ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
|
||||
maxlength='255',
|
||||
tabindex='1',
|
||||
onkeyup='')
|
||||
.contact-suggestions(ng-show="suggestions.length")
|
||||
p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<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")}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -157,7 +157,10 @@ block requirejs
|
|||
},
|
||||
"ace/ext-language_tools": {
|
||||
"deps": ["ace/ace"]
|
||||
}
|
||||
},
|
||||
"ace/keybinding-vim": {
|
||||
"deps": ["ace/ace"]
|
||||
},
|
||||
},
|
||||
"config":{
|
||||
"moment":{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
| #{translate("unlimited_projects")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("full_doc_history")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("sync_to_dropbox")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("sync_to_github")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
|#{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
|
||||
| #{translate("unlimited_projects")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("full_doc_history")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("sync_to_dropbox")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
| #{translate("sync_to_github")}
|
||||
li
|
||||
i.fa.fa-check
|
||||
|#{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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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}}
|
|
@ -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
6
services/web/bin/unit_test_app
Executable file
|
@ -0,0 +1,6 @@
|
|||
#!/bin/bash
|
||||
set -e;
|
||||
|
||||
MOCHA="node_modules/.bin/mocha --exit --recursive --reporter spec"
|
||||
|
||||
$MOCHA "$@" test/unit/js
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"exec": "make compile || exit 1",
|
||||
"watch": [
|
||||
"public/coffee/",
|
||||
"public/stylesheets/"
|
||||
"public/stylesheets/",
|
||||
"modules/**/public/coffee/"
|
||||
],
|
||||
"ext": "coffee less"
|
||||
}
|
4041
services/web/npm-shrinkwrap.json
generated
4041
services/web/npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -17,3 +17,7 @@ define [
|
|||
App.filter "relativeDate", () ->
|
||||
(date) ->
|
||||
moment(date).calendar()
|
||||
|
||||
App.filter "fromNowDate", () ->
|
||||
(date) ->
|
||||
moment(date).fromNow()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
define [
|
||||
"ide/editor/Document"
|
||||
"ide/editor/components/spellMenu"
|
||||
"ide/editor/directives/aceEditor"
|
||||
"ide/editor/directives/toggleSwitch"
|
||||
"ide/editor/controllers/SavingNotificationController"
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
71
services/web/public/js/ace-1.2.5/theme-overleaf.js
Normal file
71
services/web/public/js/ace-1.2.5/theme-overleaf.js
Normal 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -775,6 +775,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
#editor-rich-text {
|
||||
.rp-size-expanded & {
|
||||
right: @review-panel-width;
|
||||
}
|
||||
}
|
||||
|
||||
.rp-toggle {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
177
services/web/public/stylesheets/app/metrics.less
Normal file
177
services/web/public/stylesheets/app/metrics.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
6
services/web/public/stylesheets/app/ol-chat.less
Normal file
6
services/web/public/stylesheets/app/ol-chat.less
Normal file
|
@ -0,0 +1,6 @@
|
|||
// Styles for Chat panel in Overleaf v2
|
||||
|
||||
.chat .message-wrapper .message .message-content a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
610
services/web/public/stylesheets/components/daterange-picker.less
Normal file
610
services/web/public/stylesheets/components/daterange-picker.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
56
services/web/test/acceptance/coffee/ExportsTests.coffee
Normal file
56
services/web/test/acceptance/coffee/ExportsTests.coffee
Normal 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()
|
|
@ -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 [
|
||||
|
|
|
@ -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']
|
||||
|
|
151
services/web/test/acceptance/coffee/SubscriptionTests.coffee
Normal file
151
services/web/test/acceptance/coffee/SubscriptionTests.coffee
Normal 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()
|
|
@ -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) ->
|
||||
|
|
45
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
Normal file
45
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
Normal 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()
|
|
@ -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?
|
||||
|
|
95
services/web/test/acceptance/config/settings.test.coffee
Normal file
95
services/web/test/acceptance/config/settings.test.coffee
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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()
|
202
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
Normal file
202
services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue