diff --git a/services/web/Makefile b/services/web/Makefile
index 469ab8e919..bfdfc0f52f 100644
--- a/services/web/Makefile
+++ b/services/web/Makefile
@@ -180,7 +180,7 @@ clean_css:
rm -f public/stylesheets/*.css*
clean_ci:
- docker-compose down -v
+ docker-compose down -v -t 0
test: test_unit test_frontend test_acceptance
@@ -204,7 +204,7 @@ test_acceptance_app_start_service: test_clean # stop service and clear dbs
docker-compose ${DOCKER_COMPOSE_FLAGS} up -d test_acceptance
test_acceptance_app_stop_service:
- docker-compose ${DOCKER_COMPOSE_FLAGS} stop test_acceptance redis mongo
+ docker-compose ${DOCKER_COMPOSE_FLAGS} stop -t 0 test_acceptance redis mongo
test_acceptance_app_run:
docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}
@@ -224,7 +224,7 @@ test_acceptance_module: $(MODULE_MAKEFILES)
fi
test_clean:
- docker-compose ${DOCKER_COMPOSE_FLAGS} down -v
+ docker-compose ${DOCKER_COMPOSE_FLAGS} down -v -t 0
ci:
MOCHA_ARGS="--reporter tap" \
diff --git a/services/web/Makefile.module b/services/web/Makefile.module
index 00bea5f8da..fa1ba41679 100644
--- a/services/web/Makefile.module
+++ b/services/web/Makefile.module
@@ -62,7 +62,7 @@ test_acceptance_start_service: test_acceptance_stop_service
$(DOCKER_COMPOSE) up -d test_acceptance
test_acceptance_stop_service:
- $(DOCKER_COMPOSE) stop test_acceptance redis mongo
+ $(DOCKER_COMPOSE) stop -t 0 test_acceptance redis mongo
test_acceptance_run:
$(DOCKER_COMPOSE) exec -T test_acceptance npm -q run test:acceptance:dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/js
diff --git a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
index 4e96ce113c..1e0577cfc1 100644
--- a/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
+++ b/services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
@@ -1,5 +1,5 @@
BetaProgramHandler = require './BetaProgramHandler'
-UserLocator = require "../User/UserLocator"
+UserGetter = require "../User/UserGetter"
Settings = require "settings-sharelatex"
logger = require 'logger-sharelatex'
AuthenticationController = require '../Authentication/AuthenticationController'
@@ -30,7 +30,7 @@ module.exports = BetaProgramController =
optInPage: (req, res, next)->
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log {user_id}, "showing beta participation page for user"
- UserLocator.findById user_id, (err, user)->
+ UserGetter.getUser user_id, (err, user)->
if err
logger.err {err, user_id}, "error fetching user"
return next(err)
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
index 898abe52bb..f74a144bac 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
@@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
if Settings.restrictInvitesToExistingAccounts == true
logger.log {email}, "checking if user exists with this email"
- UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
+ UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
return callback(err) if err?
userExists = user? and user?._id?
callback(null, userExists)
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
index ecca8ab86f..b511f56e53 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
+++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
@@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
email = invite.email
- UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+ UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
if err?
logger.err {projectId, email}, "error checking if user exists"
return callback(err)
diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee
index b60f58ba20..40cb7bb507 100644
--- a/services/web/app/coffee/Features/Exports/ExportsController.coffee
+++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee
@@ -7,7 +7,17 @@ module.exports =
exportProject: (req, res) ->
{project_id, brand_variation_id} = req.params
user_id = AuthenticationController.getLoggedInUserId(req)
- ExportsHandler.exportProject project_id, user_id, brand_variation_id, (err, export_data) ->
+ export_params = {
+ project_id: project_id,
+ brand_variation_id: brand_variation_id,
+ user_id: user_id
+ }
+
+ if req.body && req.body.firstName && req.body.lastName
+ export_params.first_name = req.body.firstName.trim()
+ export_params.last_name = req.body.lastName.trim()
+
+ ExportsHandler.exportProject export_params, (err, export_data) ->
return next(err) if err?
logger.log
user_id:user_id
diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
index 727b01a575..38357c129d 100644
--- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
+++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
@@ -10,8 +10,8 @@ settings = require 'settings-sharelatex'
module.exports = ExportsHandler = self =
- exportProject: (project_id, user_id, brand_variation_id, callback=(error, export_data) ->) ->
- self._buildExport project_id, user_id, brand_variation_id, (err, export_data) ->
+ exportProject: (export_params, callback=(error, export_data) ->) ->
+ self._buildExport export_params, (err, export_data) ->
return callback(err) if err?
self._requestExport export_data, (err, export_v1_id) ->
return callback(err) if err?
@@ -19,7 +19,10 @@ module.exports = ExportsHandler = self =
# TODO: possibly store the export data in Mongo
callback null, export_data
- _buildExport: (project_id, user_id, brand_variation_id, callback=(err, export_data) ->) ->
+ _buildExport: (export_params, callback=(err, export_data) ->) ->
+ project_id = export_params.project_id
+ user_id = export_params.user_id
+ brand_variation_id = export_params.brand_variation_id
jobs =
project: (cb) ->
ProjectGetter.getProject project_id, cb
@@ -43,6 +46,10 @@ module.exports = ExportsHandler = self =
logger.err err:err, project_id: project_id
return callback(err)
+ if export_params.first_name && export_params.last_name
+ user.first_name = export_params.first_name
+ user.last_name = export_params.last_name
+
export_data =
project:
id: project_id
diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
index aaf4172cf4..1a5e13e86e 100644
--- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
+++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
@@ -5,7 +5,8 @@ logger = require 'logger-sharelatex'
module.exports = LinkedFilesController = {
Agents: {
- url: require('./UrlAgent')
+ url: require('./UrlAgent'),
+ project_file: require('./ProjectFileAgent')
}
createLinkedFile: (req, res, next) ->
@@ -22,11 +23,17 @@ module.exports = LinkedFilesController = {
linkedFileData = Agent.sanitizeData(data)
linkedFileData.provider = provider
- Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
- if error?
- logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
- return Agent.handleError(error, req, res, next)
- EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) ->
- return next(error) if error?
- res.send(204) # created
-}
\ No newline at end of file
+ Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) ->
+ return Agent.handleError(err, req, res, next) if err?
+ return res.sendStatus(403) if !allowed
+ Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) ->
+ return Agent.handleError(err) if err?
+ linkedFileData = newLinkedFileData
+ Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
+ if error?
+ logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
+ return Agent.handleError(error, req, res, next)
+ EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) ->
+ return next(error) if error?
+ res.json(new_file_id: file._id) # created
+ }
diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
new file mode 100644
index 0000000000..5ea4554426
--- /dev/null
+++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
@@ -0,0 +1,127 @@
+FileWriter = require('../../infrastructure/FileWriter')
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectLocator = require('../Project/ProjectLocator')
+ProjectGetter = require('../Project/ProjectGetter')
+DocstoreManager = require('../Docstore/DocstoreManager')
+FileStoreHandler = require('../FileStore/FileStoreHandler')
+FileWriter = require('../../infrastructure/FileWriter')
+_ = require "underscore"
+Settings = require 'settings-sharelatex'
+
+
+AccessDeniedError = (message) ->
+ error = new Error(message)
+ error.name = 'AccessDenied'
+ error.__proto__ = AccessDeniedError.prototype
+ return error
+AccessDeniedError.prototype.__proto__ = Error.prototype
+
+
+BadEntityTypeError = (message) ->
+ error = new Error(message)
+ error.name = 'BadEntityType'
+ error.__proto__ = BadEntityTypeError.prototype
+ return error
+BadEntityTypeError.prototype.__proto__ = Error.prototype
+
+
+BadDataError = (message) ->
+ error = new Error(message)
+ error.name = 'BadData'
+ error.__proto__ = BadDataError.prototype
+ return error
+BadDataError.prototype.__proto__ = Error.prototype
+
+
+ProjectNotFoundError = (message) ->
+ error = new Error(message)
+ error.name = 'ProjectNotFound'
+ error.__proto__ = ProjectNotFoundError.prototype
+ return error
+ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+ error = new Error(message)
+ error.name = 'BadData'
+ error.__proto__ = SourceFileNotFoundError.prototype
+ return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = ProjectFileAgent =
+
+ sanitizeData: (data) ->
+ return _.pick(
+ data,
+ 'source_project_id',
+ 'source_entity_path'
+ )
+
+ _validate: (data) ->
+ return (
+ data.source_project_id? &&
+ data.source_entity_path?
+ )
+
+ decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+ callback = _.once(callback)
+ { source_project_id } = data
+ return callback(new BadDataError()) if !source_project_id?
+ ProjectGetter.getProject source_project_id, (err, project) ->
+ return callback(err) if err?
+ return callback(new ProjectNotFoundError()) if !project?
+ callback(err, _.extend(data, {source_project_display_name: project.name}))
+
+ checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+ callback = _.once(callback)
+ if !ProjectFileAgent._validate(data)
+ return callback(new BadDataError())
+ {source_project_id, source_entity_path} = data
+ AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) ->
+ return callback(err) if err?
+ callback(null, canRead)
+
+ writeIncomingFileToDisk:
+ (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
+ callback = _.once(callback)
+ if !ProjectFileAgent._validate(data)
+ return callback(new BadDataError())
+ {source_project_id, source_entity_path} = data
+ ProjectLocator.findElementByPath {
+ project_id: source_project_id,
+ path: source_entity_path
+ }, (err, entity, type) ->
+ if err?
+ if err.toString().match(/^not found.*/)
+ err = new SourceFileNotFoundError()
+ return callback(err)
+ ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback
+
+ _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) ->
+ callback = _.once(callback)
+ if type == 'doc'
+ DocstoreManager.getDoc project_id, entity_id, (err, lines) ->
+ return callback(err) if err?
+ FileWriter.writeLinesToDisk entity_id, lines, callback
+ else if type == 'file'
+ FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) ->
+ return callback(err) if err?
+ FileWriter.writeStreamToDisk entity_id, fileStream, callback
+ else
+ callback(new BadEntityTypeError())
+
+ handleError: (error, req, res, next) ->
+ if error instanceof AccessDeniedError
+ res.status(403).send("You do not have access to this project")
+ else if error instanceof BadDataError
+ res.status(400).send("The submitted data is not valid")
+ else if error instanceof BadEntityTypeError
+ res.status(400).send("The file is the wrong type")
+ else if error instanceof SourceFileNotFoundError
+ res.status(404).send("Source file not found")
+ else if error instanceof ProjectNotFoundError
+ res.status(404).send("Project not found")
+ else
+ next(error)
+ next()
diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
index ad96aa628f..7a15fe52d3 100644
--- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
+++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
@@ -27,6 +27,12 @@ module.exports = UrlAgent = {
url: @._prependHttpIfNeeded(data.url)
}
+ decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+ return callback(null, data)
+
+ checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+ callback(null, true)
+
writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
callback = _.once(callback)
url = data.url
@@ -65,4 +71,4 @@ module.exports = UrlAgent = {
if !Settings.apis?.linkedUrlProxy?.url?
throw new Error('no linked url proxy configured')
return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}"
-}
\ No newline at end of file
+}
diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
index 4e67e9f1f4..3947b63004 100644
--- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
+++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
@@ -9,7 +9,7 @@ logger = require("logger-sharelatex")
module.exports =
generateAndEmailResetToken:(email, callback = (error, exists) ->)->
- UserGetter.getUser email:email, (err, user)->
+ UserGetter.getUserByMainEmail email, (err, user)->
if err then return callback(err)
if !user? or user.holdingAccount
logger.err email:email, "user could not be found for password reset"
diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee
index 4ca886fed0..8ea2dc3189 100644
--- a/services/web/app/coffee/Features/Project/ProjectController.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectController.coffee
@@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources"
TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
Modules = require '../../infrastructure/Modules'
+ProjectEntityHandler = require './ProjectEntityHandler'
crypto = require 'crypto'
module.exports = ProjectController =
@@ -138,6 +139,33 @@ module.exports = ProjectController =
return next(err) if err?
res.sendStatus 200
+ userProjectsJson: (req, res, next) ->
+ user_id = AuthenticationController.getLoggedInUserId(req)
+ ProjectGetter.findAllUsersProjects user_id,
+ 'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) ->
+ return next(err) if err?
+ projects = ProjectController._buildProjectList(projects)
+ .filter((p) -> !p.archived)
+ .filter((p) -> !p.isV1Project)
+ .map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel})
+
+ res.json({projects: projects})
+
+ projectEntitiesJson: (req, res, next) ->
+ user_id = AuthenticationController.getLoggedInUserId(req)
+ project_id = req.params.Project_id
+ ProjectGetter.getProject project_id, (err, project) ->
+ return next(err) if err?
+ ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) ->
+ return next(err) if err?
+ entities = docs.concat(files)
+ .sort (a, b) -> a.path > b.path # Sort by path ascending
+ .map (e) -> {
+ path: e.path,
+ type: if e.doc? then 'doc' else 'file'
+ }
+ res.json({project_id: project_id, entities: entities})
+
projectListPage: (req, res, next)->
timer = new metrics.Timer("project-list")
user_id = AuthenticationController.getLoggedInUserId(req)
@@ -313,6 +341,7 @@ module.exports = ProjectController =
maxDocLength: Settings.max_doc_length
useV2History: !!project.overleaf?.history?.display
showRichText: req.query?.rt == 'true'
+ showTestControls: req.query?.tc == 'true' || user.isAdmin
showPublishModal: req.query?.pm == 'true'
timer.done()
diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
index 27ead91841..22034200f5 100644
--- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
@@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) ->
methodWithLock
module.exports = ProjectEntityUpdateHandler = self =
- # this doesn't need any locking because it's only called by ProjectDuplicator
- copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
- project_id = project._id
- projectHistoryId = project.overleaf?.history?.id
- logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
- return callback(err) if err?
- ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=>
- if !origonalFileRef?
- logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
- return callback()
- # convert any invalid characters in original file to '_'
- fileRef = new File name : SafePath.clean(origonalFileRef.name)
- FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
- if err?
- logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
- return callback(err)
- ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
- if err?
- logger.err { err, project_id, folder_id }, "error putting element as part of copy"
- return callback(err)
- TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
+ copyFileFromExistingProjectWithProject: wrapWithLock
+ beforeLock: (next) ->
+ (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
+ project_id = project._id
+ logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
+ ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) ->
+ if !origonalFileRef?
+ logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
+ return callback()
+ # convert any invalid characters in original file to '_'
+ fileRef = new File name : SafePath.clean(origonalFileRef.name)
+ FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
if err?
- logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
- newFiles = [
- file: fileRef
- path: result?.path?.fileSystem
- url: fileStoreUrl
- ]
- DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
- return callback(error) if error?
- callback null, fileRef, folder_id
+ logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
+ return callback(err)
+ next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback)
+ withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)->
+ project_id = project._id
+ projectHistoryId = project.overleaf?.history?.id
+ ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) ->
+ if err?
+ logger.err { err, project_id, folder_id }, "error putting element as part of copy"
+ return callback(err)
+ TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
+ if err?
+ logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
+ newFiles = [
+ file: fileRef
+ path: result?.path?.fileSystem
+ url: fileStoreUrl
+ ]
+ DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) ->
+ return callback(error) if error?
+ callback null, fileRef, folder_id
updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee
index 8728896631..959833351f 100644
--- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee
+++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee
@@ -10,6 +10,8 @@ Async = require('async')
oneMinInMs = 60 * 1000
fiveMinsInMs = oneMinInMs * 5
+if !settings.apis?.references?.url?
+ logger.log "references search not enabled"
module.exports = ReferencesHandler =
diff --git a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
index 5c176c611f..5627072c93 100644
--- a/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
+++ b/services/web/app/coffee/Features/Subscription/FeaturesUpdater.coffee
@@ -11,7 +11,16 @@ V1SubscriptionManager = require("./V1SubscriptionManager")
oneMonthInSeconds = 60 * 60 * 24 * 30
module.exports = FeaturesUpdater =
- refreshFeatures: (user_id, callback)->
+ refreshFeatures: (user_id, notifyV1 = true, callback = () ->)->
+ if typeof notifyV1 == 'function'
+ callback = notifyV1
+ notifyV1 = true
+
+ if notifyV1
+ V1SubscriptionManager.notifyV1OfFeaturesChange user_id, (error) ->
+ if error?
+ logger.err {err: error, user_id}, "error notifying v1 about updated features"
+
jobs =
individualFeatures: (cb) -> FeaturesUpdater._getIndividualFeatures user_id, cb
groupFeatureSets: (cb) -> FeaturesUpdater._getGroupFeatureSets user_id, cb
@@ -80,4 +89,4 @@ module.exports = FeaturesUpdater =
if !plan?
return {}
else
- return plan.features
\ No newline at end of file
+ return plan.features
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
index 32d2abf594..03e87125ac 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
@@ -10,6 +10,7 @@ GeoIpLookup = require("../../infrastructure/GeoIpLookup")
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
UserGetter = require "../User/UserGetter"
FeaturesUpdater = require './FeaturesUpdater'
+planFeatures = require './planFeatures'
module.exports = SubscriptionController =
@@ -20,6 +21,7 @@ module.exports = SubscriptionController =
viewName = "#{viewName}_#{req.query.v}"
logger.log viewName:viewName, "showing plans page"
currentUser = null
+
GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
return next(err) if err?
render = () ->
@@ -29,6 +31,7 @@ module.exports = SubscriptionController =
gaExperiments: Settings.gaExperiments.plansPage
recomendedCurrency:recomendedCurrency
shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
+ planFeatures: planFeatures
user_id = AuthenticationController.getLoggedInUserId(req)
if user_id?
UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
index fe1e78e5e3..7e88a6c56a 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
@@ -2,8 +2,8 @@ async = require("async")
_ = require("underscore")
SubscriptionUpdater = require("./SubscriptionUpdater")
SubscriptionLocator = require("./SubscriptionLocator")
+UserGetter = require("../User/UserGetter")
Subscription = require("../../models/Subscription").Subscription
-UserLocator = require("../User/UserLocator")
LimitationsManager = require("./LimitationsManager")
logger = require("logger-sharelatex")
OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
@@ -22,7 +22,7 @@ module.exports = SubscriptionGroupHandler =
if limitReached
logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
return callback(limitReached:limitReached)
- UserLocator.findByEmail newEmail, (err, user)->
+ UserGetter.getUserByMainEmail newEmail, (err, user)->
return callback(err) if err?
if user?
SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@@ -66,7 +66,7 @@ module.exports = SubscriptionGroupHandler =
users.push buildEmailInviteViewModel(email)
jobs = _.map subscription.member_ids, (user_id)->
return (cb)->
- UserLocator.findById user_id, (err, user)->
+ UserGetter.getUser user_id, (err, user)->
if err? or !user?
users.push _id:user_id
return cb()
diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
index 05dc140be2..e920b94f6c 100644
--- a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
+++ b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
@@ -12,39 +12,49 @@ module.exports = V1SubscriptionManager =
# - 'v1_free'
getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
+ V1SubscriptionManager._v1Request userId, {
+ method: 'GET',
+ url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/plan_code"
+ }, (error, body) ->
+ return callback(error) if error?
+ planName = body?.plan_name
+ logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
+ if planName in ['pro', 'pro_plus', 'student', 'free']
+ planName = "v1_#{planName}"
+ else
+ # Throw away 'anonymous', etc as being equivalent to null
+ planName = null
+ return callback(null, planName)
+
+ notifyV1OfFeaturesChange: (userId, callback = (error) ->) ->
+ V1SubscriptionManager._v1Request userId, {
+ method: 'POST',
+ url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/sync"
+ }, callback
+
+ _v1Request: (userId, options, callback=(err, body)->) ->
+ if !settings?.apis?.v1
+ return callback null, null
UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
return callback(err) if err?
v1Id = user?.overleaf?.id
if !v1Id?
logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
return callback(null, null)
- V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
- return callback(err) if err?
- planName = body?.plan_name
- logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
- if planName in ['pro', 'pro_plus', 'student', 'free']
- planName = "v1_#{planName}"
+ request {
+ baseUrl: settings.apis.v1.url
+ url: options.url(v1Id)
+ method: options.method
+ auth:
+ user: settings.apis.v1.user
+ pass: settings.apis.v1.pass
+ sendImmediately: true
+ json: true,
+ timeout: 5 * 1000
+ }, (error, response, body) ->
+ return callback(error) if error?
+ if 200 <= response.statusCode < 300
+ return callback null, body
else
- # Throw away 'anonymous', etc as being equivalent to null
- planName = null
- return callback(null, planName)
+ return callback new Error("non-success code from v1: #{response.statusCode}")
- _v1PlanRequest: (v1Id, callback=(err, body)->) ->
- if !settings?.apis?.v1
- return callback null, null
- request {
- method: 'GET',
- url: settings.apis.v1.url +
- "/api/v1/sharelatex/users/#{v1Id}/plan_code"
- auth:
- user: settings.apis.v1.user
- pass: settings.apis.v1.pass
- sendImmediately: true
- json: true,
- timeout: 5 * 1000
- }, (error, response, body) ->
- return callback(error) if error?
- if 200 <= response.statusCode < 300
- return callback null, body
- else
- return callback new Error("non-success code from v1: #{response.statusCode}")
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Subscription/planFeatures.coffee b/services/web/app/coffee/Features/Subscription/planFeatures.coffee
new file mode 100644
index 0000000000..8c9c276955
--- /dev/null
+++ b/services/web/app/coffee/Features/Subscription/planFeatures.coffee
@@ -0,0 +1,133 @@
+module.exports =
+ [
+ {
+ feature: 'number_collab'
+ value: 'str'
+ plans: {
+ free: '1'
+ coll: '10'
+ prof: 'unlimited'
+ }
+ student: '6'
+ }
+ {
+ feature: 'unlimited_private'
+ value: 'bool'
+ info: 'unlimited_private_info'
+ plans: {
+ free: true
+ coll: true
+ prof: true
+ },
+ student: true
+ }
+ {
+ feature: 'realtime_collab'
+ value: 'bool'
+ info: 'realtime_collab_info'
+ plans: {
+ free: true
+ coll: true
+ prof: true
+ }
+ student: true
+ }
+ {
+ feature: 'hundreds_templates'
+ value: 'bool'
+ info: 'hundreds_templates_info'
+ plans: {
+ free: true
+ coll: true
+ prof: true
+ }
+ student: true
+ }
+ {
+ feature: 'powerful_latex_editor'
+ value: 'bool'
+ info: 'latex_editor_info'
+ plans: {
+ free: true
+ coll: true
+ prof: true
+ }
+ student: true
+ }
+ {
+ feature: 'realtime_track_changes'
+ value: 'bool'
+ info: 'realtime_track_changes_info'
+ plans: {
+ free: false
+ coll: true
+ prof: true
+ },
+ student: true
+ }
+ {
+ feature: 'reference_search'
+ value: 'bool'
+ info: 'reference_search_info'
+ plans: {
+ free: false
+ coll: true
+ prof: true
+ },
+ student: true
+ },
+ {
+ feature: 'reference_sync'
+ info: 'reference_sync_info'
+ value: 'bool'
+ plans: {
+ free: false
+ coll: true
+ prof: true
+ },
+ student: true
+ }
+ {
+ feature: 'full_doc_history'
+ value: 'bool'
+ info: 'full_doc_history_info'
+ plans: {
+ free: false,
+ coll: true,
+ prof: true
+ },
+ student: true
+ }
+ {
+ feature: 'dropbox_integration_lowercase'
+ value: 'bool'
+ info: 'dropbox_integration_info'
+ plans: {
+ free: false,
+ coll: true,
+ prof: true
+ },
+ student: true
+ },
+ {
+ feature: 'github_integration_lowercase'
+ value: 'bool'
+ info: 'github_integration_info'
+ plans: {
+ free: false,
+ coll: true,
+ prof: true
+ },
+ student: true
+ },
+ {
+ feature: 'priority_support',
+ value: 'bool',
+ plans: {
+ free: false,
+ coll: true,
+ prof: true
+ },
+ student: true
+ },
+ ]
\ No newline at end of file
diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee
new file mode 100644
index 0000000000..fce6c9502c
--- /dev/null
+++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee
@@ -0,0 +1,80 @@
+path = require('path')
+Project = require('../../../js/models/Project').Project
+ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager')
+ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler')
+AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController')
+settings = require('settings-sharelatex')
+fs = require('fs')
+request = require('request')
+uuid = require('uuid')
+logger = require('logger-sharelatex')
+async = require("async")
+
+
+module.exports = TemplatesController =
+
+ getV1Template: (req, res)->
+ templateVersionId = req.params.Template_version_id
+ templateId = req.query.id
+ if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)
+ logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version"
+ return res.sendStatus 400
+ data = {}
+ data.templateVersionId = templateVersionId
+ data.templateId = templateId
+ data.name = req.query.templateName
+ data.compiler = req.query.latexEngine
+ res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data
+
+ createProjectFromV1Template: (req, res)->
+ currentUserId = AuthenticationController.getLoggedInUserId(req)
+ zipUrl = "#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}"
+ zipReq = request(zipUrl, {
+ 'auth': {
+ 'user': settings.apis.v1.user,
+ 'pass': settings.apis.v1.pass
+ }
+ })
+
+ TemplatesController.createFromZip(
+ zipReq,
+ {
+ templateName: req.body.templateName,
+ currentUserId: currentUserId,
+ compiler: req.body.compiler
+ docId: req.body.docId
+ templateId: req.body.templateId
+ templateVersionId: req.body.templateVersionId
+ },
+ req,
+ res
+ )
+
+ createFromZip: (zipReq, options, req, res)->
+ dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}"
+ writeStream = fs.createWriteStream(dumpPath)
+
+ zipReq.on "error", (error) ->
+ logger.error err: error, "error getting zip from template API"
+ zipReq.pipe(writeStream)
+ writeStream.on 'close', ->
+ ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)->
+ if err?
+ logger.err err:err, zipReq:zipReq, "problem building project from zip"
+ return res.sendStatus 500
+ setCompiler project._id, options.compiler, ->
+ fs.unlink dumpPath, ->
+ delete req.session.templateData
+ conditions = {_id:project._id}
+ update = {
+ fromV1TemplateId:options.templateId,
+ fromV1TemplateVersionId:options.templateVersionId
+ }
+ Project.update conditions, update, {}, (err)->
+ res.redirect "/project/#{project._id}"
+
+setCompiler = (project_id, compiler, callback)->
+ if compiler?
+ ProjectOptionsHandler.setCompiler project_id, compiler, callback
+ else
+ callback()
diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear
new file mode 100644
index 0000000000..300721c889
--- /dev/null
+++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear
@@ -0,0 +1,8 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+module.exports =
+ saveTemplateDataInSession: (req, res, next)->
+ if req.query.templateName
+ req.session.templateData = req.query
+ next()
diff --git a/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee
new file mode 100644
index 0000000000..8baa0ca605
--- /dev/null
+++ b/services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee
@@ -0,0 +1,9 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+
+module.exports =
+ saveTemplateDataInSession: (req, res, next)->
+ if req.query.templateName
+ req.session.templateData = req.query
+ next()
diff --git a/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee
new file mode 100644
index 0000000000..3061789591
--- /dev/null
+++ b/services/web/app/coffee/Features/Templates/TemplatesRouter.coffee
@@ -0,0 +1,10 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+TemplatesController = require("./TemplatesController")
+TemplatesMiddlewear = require('./TemplatesMiddlewear')
+
+module.exports =
+ apply: (app)->
+
+ app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template
+
+ app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template
diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee
index ccbd0a86f1..cf7e6be33f 100644
--- a/services/web/app/coffee/Features/User/UserController.coffee
+++ b/services/web/app/coffee/Features/User/UserController.coffee
@@ -1,6 +1,6 @@
UserHandler = require("./UserHandler")
UserDeleter = require("./UserDeleter")
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
User = require("../../models/User").User
newsLetterManager = require('../Newsletter/NewsletterManager')
UserRegistrationHandler = require("./UserRegistrationHandler")
@@ -45,7 +45,7 @@ module.exports = UserController =
unsubscribe: (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req)
- UserLocator.findById user_id, (err, user)->
+ UserGetter.getUser user_id, (err, user)->
newsLetterManager.unsubscribe user, ->
res.send()
diff --git a/services/web/app/coffee/Features/User/UserCreator.coffee b/services/web/app/coffee/Features/User/UserCreator.coffee
index d08b953559..0a0cc8641e 100644
--- a/services/web/app/coffee/Features/User/UserCreator.coffee
+++ b/services/web/app/coffee/Features/User/UserCreator.coffee
@@ -1,19 +1,10 @@
User = require("../../models/User").User
-UserLocator = require("./UserLocator")
logger = require("logger-sharelatex")
metrics = require('metrics-sharelatex')
module.exports = UserCreator =
- getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
- self = @
- UserLocator.findByEmail email, (err, user)->
- if user?
- callback(err, user)
- else
- self.createNewUser email:email, holdingAccount:true, callback
-
createNewUser: (opts, callback)->
logger.log opts:opts, "creating new user"
user = new User()
diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee
index 306fbc7a10..2e6d4eada6 100644
--- a/services/web/app/coffee/Features/User/UserGetter.coffee
+++ b/services/web/app/coffee/Features/User/UserGetter.coffee
@@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
module.exports = UserGetter =
getUser: (query, projection, callback = (error, user) ->) ->
+ if query?.email?
+ return callback(new Error("Don't use getUser to find user by email"), null)
if arguments.length == 2
callback = projection
projection = {}
@@ -19,6 +21,31 @@ module.exports = UserGetter =
db.users.findOne query, projection, callback
+ getUserEmail: (userId, callback = (error, email) ->) ->
+ @getUser userId, { email: 1 }, (error, user) ->
+ callback(error, user?.email)
+
+ getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
+ email = email.trim()
+ if arguments.length == 2
+ callback = projection
+ projection = {}
+ db.users.findOne email: email, projection, callback
+
+ getUserByAnyEmail: (email, projection, callback = (error, user) ->) ->
+ email = email.trim()
+ if arguments.length == 2
+ callback = projection
+ projection = {}
+ # $exists: true MUST be set to use the partial index
+ query = emails: { $exists: true }, 'emails.email': email
+ db.users.findOne query, projection, (error, user) =>
+ return callback(error, user) if error? or user?
+
+ # While multiple emails are being rolled out, check for the main email as
+ # well
+ @getUserByMainEmail email, projection, callback
+
getUsers: (user_ids, projection, callback = (error, users) ->) ->
try
user_ids = user_ids.map (u) -> ObjectId(u.toString())
@@ -39,6 +66,9 @@ module.exports = UserGetter =
[
'getUser',
+ 'getUserEmail',
+ 'getUserByMainEmail',
+ 'getUserByAnyEmail',
'getUsers',
'getUserOrUserStubById'
].map (method) ->
diff --git a/services/web/app/coffee/Features/User/UserLocator.coffee b/services/web/app/coffee/Features/User/UserLocator.coffee
deleted file mode 100644
index 9be32c76b0..0000000000
--- a/services/web/app/coffee/Features/User/UserLocator.coffee
+++ /dev/null
@@ -1,21 +0,0 @@
-mongojs = require("../../infrastructure/mongojs")
-metrics = require("metrics-sharelatex")
-db = mongojs.db
-ObjectId = mongojs.ObjectId
-logger = require('logger-sharelatex')
-
-module.exports = UserLocator =
-
- findByEmail: (email, callback)->
- email = email.trim()
- db.users.findOne email:email, (err, user)->
- callback(err, user)
-
- findById: (_id, callback)->
- db.users.findOne _id:ObjectId(_id+""), callback
-
-[
- 'findById',
- 'findByEmail'
-].map (method) ->
- metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger
diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee
index 25825c35e6..5e6ea7d62b 100644
--- a/services/web/app/coffee/Features/User/UserPagesController.coffee
+++ b/services/web/app/coffee/Features/User/UserPagesController.coffee
@@ -1,4 +1,3 @@
-UserLocator = require("./UserLocator")
UserGetter = require("./UserGetter")
UserSessionsManager = require("./UserSessionsManager")
ErrorController = require("../Errors/ErrorController")
@@ -61,7 +60,7 @@ module.exports =
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log user: user_id, "loading settings page"
shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
- UserLocator.findById user_id, (err, user)->
+ UserGetter.getUser user_id, (err, user)->
return next(err) if err?
res.render 'user/settings',
title:'account_settings'
diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
index f5db2e54a1..fab438ffa6 100644
--- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
+++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
@@ -1,6 +1,7 @@
sanitize = require('sanitizer')
User = require("../../models/User").User
UserCreator = require("./UserCreator")
+UserGetter = require("./UserGetter")
AuthenticationManager = require("../Authentication/AuthenticationManager")
NewsLetterManager = require("../Newsletter/NewsletterManager")
async = require("async")
@@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler =
if !requestIsValid
return callback(new Error("request is not valid"))
userDetails.email = userDetails.email?.trim()?.toLowerCase()
- User.findOne email:userDetails.email, (err, user)->
+ UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
if err?
return callback err
if user?.holdingAccount == false
diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee
index 530d81063d..fa9ee24450 100644
--- a/services/web/app/coffee/Features/User/UserUpdater.coffee
+++ b/services/web/app/coffee/Features/User/UserUpdater.coffee
@@ -2,8 +2,9 @@ logger = require("logger-sharelatex")
mongojs = require("../../infrastructure/mongojs")
metrics = require("metrics-sharelatex")
db = mongojs.db
+async = require("async")
ObjectId = mongojs.ObjectId
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
module.exports = UserUpdater =
updateUser: (query, update, callback = (error) ->) ->
@@ -11,23 +12,89 @@ module.exports = UserUpdater =
query = _id: ObjectId(query)
else if query instanceof ObjectId
query = _id: query
+ else if typeof query._id == "string"
+ query._id = ObjectId(query._id)
db.users.update query, update, callback
- changeEmailAddress: (user_id, newEmail, callback)->
- self = @
- logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
- UserLocator.findByEmail newEmail, (error, user) ->
- if user?
- return callback({message:"alread_exists"})
- self.updateUser user_id.toString(), {
- $set: { "email": newEmail},
- }, (err) ->
- if err?
- logger.err err:err, "problem updating users email"
- return callback(err)
+ #
+ # DEPRECATED
+ #
+ # Change the user's main email address by adding a new email, switching the
+ # default email and removing the old email. Prefer manipulating multiple
+ # emails and the default rather than calling this method directly
+ #
+ changeEmailAddress: (userId, newEmail, callback)->
+ logger.log userId: userId, newEmail: newEmail, "updaing email address of user"
+
+ oldEmail = null
+ async.series [
+ (cb) ->
+ UserGetter.getUserEmail userId, (error, email) ->
+ oldEmail = email
+ cb(error)
+ (cb) -> UserUpdater.addEmailAddress userId, newEmail, cb
+ (cb) -> UserUpdater.setDefaultEmailAddress userId, newEmail, cb
+ (cb) -> UserUpdater.removeEmailAddress userId, oldEmail, cb
+ ], callback
+
+
+ # Add a new email address for the user. Email cannot be already used by this
+ # or any other user
+ addEmailAddress: (userId, newEmail, callback) ->
+ @_ensureUniqueEmailAddress newEmail, (error) =>
+ return callback(error) if error?
+
+ update = $push: emails: email: newEmail, createdAt: new Date()
+ @updateUser userId, update, (error) ->
+ if error?
+ logger.err error: error, 'problem updating users emails'
+ return callback(error)
callback()
-metrics.timeAsyncMethod UserUpdater, 'updateUser', 'mongo.UserUpdater', logger
+ # remove one of the user's email addresses. The email cannot be the user's
+ # default email address
+ removeEmailAddress: (userId, email, callback) ->
+ query = _id: userId, email: $ne: email
+ update = $pull: emails: email: email
+ @updateUser query, update, (error, res) ->
+ if error?
+ logger.err error:error, 'problem removing users email'
+ return callback(error)
+ if res.nMatched == 0
+ return callback(new Error('Cannot remove default email'))
+ callback()
+
+
+ # set the default email address by setting the `email` attribute. The email
+ # must be one of the user's multiple emails (`emails` attribute)
+ setDefaultEmailAddress: (userId, email, callback) ->
+ query = _id: userId, 'emails.email': email
+ update = $set: email: email
+ @updateUser query, update, (error, res) ->
+ if error?
+ logger.err error:error, 'problem setting default emails'
+ return callback(error)
+ if res.nMatched == 0
+ return callback(new Error('Default email does not belong to user'))
+ callback()
+
+
+ # check for duplicate email address. This is also enforced at the DB level
+ _ensureUniqueEmailAddress: (newEmail, callback) ->
+ UserGetter.getUserByAnyEmail newEmail, (error, user) ->
+ return callback(message: 'alread_exists') if user?
+ callback()
+
+
+[
+ 'updateUser'
+ 'changeEmailAddress'
+ 'setDefaultEmailAddress'
+ 'addEmailAddress'
+ 'removeEmailAddress'
+ '_ensureUniqueEmailAddress'
+].map (method) ->
+ metrics.timeAsyncMethod(UserUpdater, method, 'mongo.UserUpdater', logger)
diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee
index dedeed9bad..27b1f16921 100644
--- a/services/web/app/coffee/infrastructure/FileWriter.coffee
+++ b/services/web/app/coffee/infrastructure/FileWriter.coffee
@@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex'
request = require 'request'
module.exports = FileWriter =
+
+ _ensureDumpFolderExists: (callback=(error)->) ->
+ fs.mkdir Settings.path.dumpFolder, (error) ->
+ if error? and error.code != 'EEXIST'
+ # Ignore error about already existing
+ return callback(error)
+ callback(null)
+
+ writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+ callback = _.once(callback)
+ fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
+ FileWriter._ensureDumpFolderExists (error) ->
+ return callback(error) if error?
+ fs.writeFile fsPath, lines.join('\n'), (error) ->
+ return callback(error) if error?
+ callback(null, fsPath)
+
writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
callback = _.once(callback)
fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
stream.pause()
- fs.mkdir Settings.path.dumpFolder, (error) ->
+ FileWriter._ensureDumpFolderExists (error) ->
+ return callback(error) if error?
stream.resume()
- if error? and error.code != 'EEXIST'
- # Ignore error about already existing
- return callback(error)
writeStream = fs.createWriteStream(fsPath)
stream.pipe(writeStream)
@@ -39,4 +54,4 @@ module.exports = FileWriter =
else
err = new Error("bad response from url: #{response.statusCode}")
logger.err {err, identifier, url}, err.message
- callback(err)
\ No newline at end of file
+ callback(err)
diff --git a/services/web/app/coffee/infrastructure/Modules.coffee b/services/web/app/coffee/infrastructure/Modules.coffee
index 769182ad94..e1b2e11520 100644
--- a/services/web/app/coffee/infrastructure/Modules.coffee
+++ b/services/web/app/coffee/infrastructure/Modules.coffee
@@ -30,7 +30,8 @@ module.exports = Modules =
for module in @modules
for view, partial of module.viewIncludes or {}
@viewIncludes[view] ||= []
- @viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
+ filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")
+ @viewIncludes[view].push pug.compileFile(filePath, doctype: "html")
moduleIncludes: (view, locals) ->
compiledPartials = Modules.viewIncludes[view] or []
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index e6b2692f7c..22c4abe925 100644
--- a/services/web/app/coffee/router.coffee
+++ b/services/web/app/coffee/router.coffee
@@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController')
TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
Features = require('./infrastructure/Features')
LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
+TemplatesRouter = require './Features/Templates/TemplatesRouter'
logger = require("logger-sharelatex")
_ = require("underscore")
@@ -80,10 +81,10 @@ module.exports = class Router
ContactRouter.apply(webRouter, privateApiRouter)
AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
+ TemplatesRouter.apply(webRouter)
Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
-
if Settings.enableSubscriptions
webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
@@ -119,6 +120,11 @@ module.exports = class Router
webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
privateApiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
+ webRouter.get '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson
+ webRouter.get '/project/:Project_id/entities', AuthenticationController.requireLogin(),
+ AuthorizationMiddlewear.ensureUserCanReadProject,
+ ProjectController.projectEntitiesJson
+
webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject
@@ -201,7 +207,8 @@ module.exports = class Router
webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
- webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+ webRouter.get "/project/:Project_id/filetree/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+ webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
diff --git a/services/web/app/views/contact-us-modal.pug b/services/web/app/views/contact-us-modal.pug
deleted file mode 100644
index eacfd10533..0000000000
--- a/services/web/app/views/contact-us-modal.pug
+++ /dev/null
@@ -1,67 +0,0 @@
-script(type='text/ng-template', id='supportModalTemplate')
- .modal-header
- button.close(
- type="button"
- data-dismiss="modal"
- ng-click="close()"
- ) ×
- h3 #{translate("contact_us")}
- .modal-body.contact-us-modal
- form(name="contactForm")
- span(ng-show="sent == false")
- .alert.alert-danger(ng-show="error") Something went wrong sending your request :(
- label
- | #{translate("subject")}
- .form-group
- input.field.text.medium.span8.form-control(
- name="subject",
- required
- ng-model="form.subject",
- ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
- maxlength='255',
- tabindex='1',
- onkeyup='')
- .contact-suggestions(ng-show="suggestions.length")
- p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "" + translate("knowledge_base") + "" })}
- ul.contact-suggestion-list
- li(ng-repeat="suggestion in suggestions")
- a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
- span(ng-bind-html="suggestion.name")
- i.fa.fa-angle-right
- label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
- | #{translate("email")}
- .form-group(ng-show="'"+getUserEmail()+"'.length < 1")
- input.field.text.medium.span8.form-control(
- name="email",
- required
- ng-model="form.email",
- ng-init="form.email = '"+getUserEmail()+"'",
- type='email', spellcheck='false',
- value='',
- maxlength='255',
- tabindex='2')
- label#title12.desc
- | #{translate("project_url")} (#{translate("optional")})
- .form-group
- input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
- label.desc
- | #{translate("contact_message_label")}
- .form-group
- textarea.field.text.medium.span8.form-control(
- name="body",
- required
- ng-model="form.message",
- type='text',
- value='',
- tabindex='4',
- onkeyup=''
- )
- .form-group.text-center
- input.btn-success.btn.btn-lg(
- type='submit',
- ng-disabled="contactForm.$invalid || sending",
- ng-click="contactUs()"
- value=translate("contact_us")
- )
- span(ng-show="sent")
- p #{translate("request_sent_thank_you")}
diff --git a/services/web/app/views/layout.pug b/services/web/app/views/layout.pug
index 4dfdbc3ae7..687f5e1880 100644
--- a/services/web/app/views/layout.pug
+++ b/services/web/app/views/layout.pug
@@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
src=buildJsPath('libs/require.js', {hashedPath:true})
)
- include contact-us-modal
+ != moduleIncludes("contactModal", locals)
include v1-tooltip
include sentry
diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 7415c2ed77..602b9af86b 100644
--- a/services/web/app/views/project/editor.pug
+++ b/services/web/app/views/project/editor.pug
@@ -57,9 +57,12 @@ block content
include ./editor/share
!= moduleIncludes("publish:body", locals)
+ include ./editor/history/toolbarV2.pug
+
main#ide-body(
ng-cloak,
role="main",
+ ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
layout="main",
ng-hide="state.loading",
resize-on="layout:chat:resize",
@@ -70,7 +73,7 @@ block content
)
.ui-layout-west
include ./editor/file-tree
- include ./editor/history-file-tree
+ include ./editor/history/fileTreeV2
.ui-layout-center
include ./editor/editor
diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug
index e5690c1874..35d75c9fb3 100644
--- a/services/web/app/views/project/editor/binary-file.pug
+++ b/services/web/app/views/project/editor/binary-file.pug
@@ -47,7 +47,18 @@ div.binary-file.full-size(
|
| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
- span(ng-if="openFile.linkedFileData.provider == 'url'")
+ div(ng-if="openFile.linkedFileData.provider == 'project_file'")
+ p
+ i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+ | Imported from
+ |
+ a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+ | {{ openFile.linkedFileData.source_project_display_name }}
+ | /{{ openFile.linkedFileData.source_entity_path.slice(1) }},
+ |
+ | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+ span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'")
button.btn.btn-success(
href, ng-click="refreshFile(openFile)",
ng-disabled="refreshing"
@@ -63,3 +74,7 @@ div.binary-file.full-size(
i.fa.fa-fw.fa-download
|
| #{translate("download")}
+ div(ng-if="refreshError")
+ br
+ .alert.alert-danger.col-md-6.col-md-offset-3
+ | Error: {{ refreshError}}
diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug
index 3cac3a9490..c24d967da7 100644
--- a/services/web/app/views/project/editor/editor.pug
+++ b/services/web/app/views/project/editor/editor.pug
@@ -33,11 +33,11 @@ div.full-size(
i.fa.fa-arrow-left
| #{translate("open_a_file_on_the_left")}
- != moduleIncludes('editor:toolbar', locals)
+ != moduleIncludes('editor:main', locals)
#editor(
ace-editor="editor",
- ng-if="!editor.richText",
+ ng-if="!editor.showRichText",
ng-show="!!editor.sharejs_doc && !editor.opening",
style=showRichText ? "top: 32px" : "",
theme="settings.theme",
@@ -73,8 +73,6 @@ div.full-size(
line-height="settings.lineHeight || ui.defaultLineHeight"
)
- != moduleIncludes('editor:body', locals)
-
include ./review-panel
.ui-layout-east
diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug
index 55fc660abc..d16258d1c9 100644
--- a/services/web/app/views/project/editor/file-tree.pug
+++ b/services/web/app/views/project/editor/file-tree.pug
@@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate')
span(ng-show="state.inflight") #{translate("creating")}...
+// Project Linked Files Modal
+script(type='text/ng-template', id='projectLinkedFileModalTemplate')
+ .modal-header
+ h3 New file from Project
+
+ .modal-body
+ div
+ div.alert.alert-danger(ng-if="state.error") Error, something went wrong!
+ div
+ form
+ .form-controls
+ label(for="project-select") Select a Project
+ span(ng-show="state.inFlight.projects")
+ |
+ i.fa.fa-spinner.fa-spin
+ select.form-control(
+ name="project-select"
+ ng-model="data.selectedProjectId"
+ ng-disabled="!shouldEnableProjectSelect()"
+ )
+ option(value="" disabled selected) - Please Select a Project
+ option(
+ ng-repeat="project in data.projects"
+ value="{{ project._id }}"
+ ) {{ project.name }}
+
+ br
+ .form-controls
+ label(for="project-entity-select") Select a File
+ span(ng-show="state.inFlight.entities")
+ |
+ i.fa.fa-spinner.fa-spin
+ select.form-control(
+ name="project-entity-select"
+ ng-model="data.selectedProjectEntity"
+ ng-disabled="!shouldEnableProjectEntitySelect()"
+ )
+ option(value="" disabled selected) - Please Select a File
+ option(
+ ng-repeat="projectEntity in data.projectEntities"
+ value="{{ projectEntity.path }}"
+ ) {{ projectEntity.path.slice(1) }}
+ br
+
+ .form-controls
+ label(for="name") File Name In This Project
+ input.form-control(
+ type="text"
+ placeholder="example.tex"
+ required
+ ng-model="data.name"
+ name="name"
+ )
+ br
+
+ .modal-footer
+ span(ng-show="state.inFlight.create")
+ i.fa.fa-spinner.fa-spin
+ |
+ button.btn.btn-default(
+ ng-disabled="state.inflight"
+ ng-click="cancel()"
+ ) #{translate("cancel")}
+ button.btn.btn-primary(
+ ng-disabled="!shouldEnableCreateButton()"
+ ng-click="create()"
+ )
+ span(ng-hide="state.inflight") #{translate("create")}
+ span(ng-show="state.inflight") #{translate("creating")}...
+
+
script(type='text/ng-template', id='linkedFileModalTemplate')
.modal-header
h3 New file from URL
diff --git a/services/web/app/views/project/editor/history-file-tree.pug b/services/web/app/views/project/editor/history-file-tree.pug
deleted file mode 100644
index 3356dc2249..0000000000
--- a/services/web/app/views/project/editor/history-file-tree.pug
+++ /dev/null
@@ -1,17 +0,0 @@
-aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }", ng-show="ui.view == 'history' && history.isV2").full-size
- .toolbar.toolbar-filetree
- span Modified files
-
- .file-tree-inner
- ul.list-unstyled.file-tree-list
- li(
- ng-repeat="(pathname, doc) in history.selection.docs"
- ng-class="{ 'selected': history.selection.pathname == pathname }"
- )
- .entity
- .entity-name.entity-name-history(
- ng-click="history.selection.pathname = pathname",
- ng-class="{ 'deleted': !!doc.deletedAtV }"
- )
- i.fa.fa-fw.fa-pencil
- span {{ pathname }}
diff --git a/services/web/app/views/project/editor/history.pug b/services/web/app/views/project/editor/history.pug
index d366d5f41a..a7a52d2927 100644
--- a/services/web/app/views/project/editor/history.pug
+++ b/services/web/app/views/project/editor/history.pug
@@ -40,90 +40,11 @@ div#history(ng-show="ui.view == 'history'")
p
a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
- aside.change-list(
- ng-controller="HistoryListController"
- infinite-scroll="loadMore()"
- infinite-scroll-disabled="history.loading || history.atEnd"
- infinite-scroll-initialize="ui.view == 'history'"
- )
- .infinite-scroll-inner
- ul.list-unstyled(
- ng-class="{\
- 'hover-state': history.hoveringOverListSelectors\
- }"
- )
- li.change(
- ng-repeat="update in history.updates"
- ng-class="{\
- 'first-in-day': update.meta.first_in_day,\
- 'selected': update.inSelection,\
- 'selected-to': update.selectedTo,\
- 'selected-from': update.selectedFrom,\
- 'hover-selected': update.inHoverSelection,\
- 'hover-selected-to': update.hoverSelectedTo,\
- 'hover-selected-from': update.hoverSelectedFrom,\
- }"
- ng-controller="HistoryListItemController"
- )
-
- div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
-
- div.selectors
- div.range
- form
- input.selector-from(
- type="radio"
- name="fromVersion"
- ng-model="update.selectedFrom"
- ng-value="true"
- ng-mouseover="mouseOverSelectedFrom()"
- ng-mouseout="mouseOutSelectedFrom()"
- ng-show="update.afterSelection || update.inSelection"
- )
- form
- input.selector-to(
- type="radio"
- name="toVersion"
- ng-model="update.selectedTo"
- ng-value="true"
- ng-mouseover="mouseOverSelectedTo()"
- ng-mouseout="mouseOutSelectedTo()"
- ng-show="update.beforeSelection || update.inSelection"
- )
-
- div.description(ng-click="select()")
- div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
- div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
- | Edited
- div.docs(ng-repeat="pathname in update.pathnames")
- .doc {{ pathname }}
- div.docs(ng-repeat="project_op in update.project_ops")
- div(ng-if="project_op.rename")
- .action Renamed
- .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }}
- div(ng-if="project_op.add")
- .action Created
- .doc {{ project_op.add.pathname }}
- div(ng-if="project_op.remove")
- .action Deleted
- .doc {{ project_op.remove.pathname }}
- div.users
- div.user(ng-repeat="update_user in update.meta.users")
- .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
- .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
- .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
- .name(ng-if="update_user && update_user.id == user.id") You
- .name(ng-if="update_user == null") #{translate("anonymous")}
- div.user(ng-if="update.meta.users.length == 0")
- .color-square(style="background-color: hsl(100, 100%, 50%)")
- span #{translate("anonymous")}
-
- .loading(ng-show="history.loading")
- i.fa.fa-spin.fa-refresh
- | #{translate("loading")}...
+ include ./history/entriesListV1
+ include ./history/entriesListV2
include ./history/diffPanelV1
- include ./history/diffPanelV2
+ include ./history/previewPanelV2
script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
.modal-header
diff --git a/services/web/app/views/project/editor/history/diffPanelV1.pug b/services/web/app/views/project/editor/history/diffPanelV1.pug
index 1720f48b59..369f3703b0 100644
--- a/services/web/app/views/project/editor/history/diffPanelV1.pug
+++ b/services/web/app/views/project/editor/history/diffPanelV1.pug
@@ -13,8 +13,8 @@
}"
)
| in {{history.diff.pathname}}
- .toolbar-right
- a.btn.btn-danger.btn-sm(
+ .toolbar-right(ng-if="permissions.write")
+ a.btn.btn-danger.btn-xs(
href,
ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")}
diff --git a/services/web/app/views/project/editor/history/entriesListV1.pug b/services/web/app/views/project/editor/history/entriesListV1.pug
new file mode 100644
index 0000000000..27f9e66fe1
--- /dev/null
+++ b/services/web/app/views/project/editor/history/entriesListV1.pug
@@ -0,0 +1,82 @@
+aside.change-list(
+ ng-if="!history.isV2"
+ ng-controller="HistoryListController"
+ infinite-scroll="loadMore()"
+ infinite-scroll-disabled="history.loading || history.atEnd"
+ infinite-scroll-initialize="ui.view == 'history'"
+ )
+ .infinite-scroll-inner
+ ul.list-unstyled(
+ ng-class="{\
+ 'hover-state': history.hoveringOverListSelectors\
+ }"
+ )
+ li.change(
+ ng-repeat="update in history.updates"
+ ng-class="{\
+ 'first-in-day': update.meta.first_in_day,\
+ 'selected': update.inSelection,\
+ 'selected-to': update.selectedTo,\
+ 'selected-from': update.selectedFrom,\
+ 'hover-selected': update.inHoverSelection,\
+ 'hover-selected-to': update.hoverSelectedTo,\
+ 'hover-selected-from': update.hoverSelectedFrom,\
+ }"
+ ng-controller="HistoryListItemController"
+ )
+
+ div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
+
+ div.selectors
+ div.range
+ form
+ input.selector-from(
+ type="radio"
+ name="fromVersion"
+ ng-model="update.selectedFrom"
+ ng-value="true"
+ ng-mouseover="mouseOverSelectedFrom()"
+ ng-mouseout="mouseOutSelectedFrom()"
+ ng-show="update.afterSelection || update.inSelection"
+ )
+ form
+ input.selector-to(
+ type="radio"
+ name="toVersion"
+ ng-model="update.selectedTo"
+ ng-value="true"
+ ng-mouseover="mouseOverSelectedTo()"
+ ng-mouseout="mouseOutSelectedTo()"
+ ng-show="update.beforeSelection || update.inSelection"
+ )
+
+ div.description(ng-click="select()")
+ div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
+ div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
+ | #{translate("file_action_edited")}
+ div.docs(ng-repeat="pathname in update.pathnames")
+ .doc {{ pathname }}
+ div.docs(ng-repeat="project_op in update.project_ops")
+ div(ng-if="project_op.rename")
+ .action #{translate("file_action_renamed")}
+ .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }}
+ div(ng-if="project_op.add")
+ .action #{translate("file_action_created")}
+ .doc {{ project_op.add.pathname }}
+ div(ng-if="project_op.remove")
+ .action #{translate("file_action_deleted")}
+ .doc {{ project_op.remove.pathname }}
+ div.users
+ div.user(ng-repeat="update_user in update.meta.users")
+ .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
+ .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
+ .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
+ .name(ng-if="update_user && update_user.id == user.id") You
+ .name(ng-if="update_user == null") #{translate("anonymous")}
+ div.user(ng-if="update.meta.users.length == 0")
+ .color-square(style="background-color: hsl(100, 100%, 50%)")
+ span #{translate("anonymous")}
+
+ .loading(ng-show="history.loading")
+ i.fa.fa-spin.fa-refresh
+ | #{translate("loading")}...
diff --git a/services/web/app/views/project/editor/history/entriesListV2.pug b/services/web/app/views/project/editor/history/entriesListV2.pug
new file mode 100644
index 0000000000..fa7a90b20e
--- /dev/null
+++ b/services/web/app/views/project/editor/history/entriesListV2.pug
@@ -0,0 +1,174 @@
+aside.change-list(
+ ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+ ng-controller="HistoryV2ListController"
+)
+ history-entries-list(
+ entries="history.updates"
+ current-user="user"
+ load-entries="loadMore()"
+ load-disabled="history.loading || history.atEnd"
+ load-initialize="ui.view == 'history'"
+ is-loading="history.loading"
+ on-entry-select="handleEntrySelect(selectedEntry)"
+ )
+
+aside.change-list(
+ ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
+ ng-controller="HistoryListController"
+ infinite-scroll="loadMore()"
+ infinite-scroll-disabled="history.loading || history.atEnd"
+ infinite-scroll-initialize="ui.view == 'history'"
+)
+ .infinite-scroll-inner
+ ul.list-unstyled(
+ ng-class="{\
+ 'hover-state': history.hoveringOverListSelectors\
+ }"
+ )
+ li.change(
+ ng-repeat="update in history.updates"
+ ng-class="{\
+ 'first-in-day': update.meta.first_in_day,\
+ 'selected': update.inSelection,\
+ 'selected-to': update.selectedTo,\
+ 'selected-from': update.selectedFrom,\
+ 'hover-selected': update.inHoverSelection,\
+ 'hover-selected-to': update.hoverSelectedTo,\
+ 'hover-selected-from': update.hoverSelectedFrom,\
+ }"
+ ng-controller="HistoryListItemController"
+ )
+
+ div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
+
+ div.selectors
+ div.range
+ form
+ input.selector-from(
+ type="radio"
+ name="fromVersion"
+ ng-model="update.selectedFrom"
+ ng-value="true"
+ ng-mouseover="mouseOverSelectedFrom()"
+ ng-mouseout="mouseOutSelectedFrom()"
+ ng-show="update.afterSelection || update.inSelection"
+ )
+ form
+ input.selector-to(
+ type="radio"
+ name="toVersion"
+ ng-model="update.selectedTo"
+ ng-value="true"
+ ng-mouseover="mouseOverSelectedTo()"
+ ng-mouseout="mouseOutSelectedTo()"
+ ng-show="update.beforeSelection || update.inSelection"
+ )
+
+ div.description(ng-click="select()")
+ div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
+ div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
+ | #{translate("file_action_edited")}
+ div.docs(ng-repeat="pathname in update.pathnames")
+ .doc {{ pathname }}
+ div.docs(ng-repeat="project_op in update.project_ops")
+ div(ng-if="project_op.rename")
+ .action #{translate("file_action_renamed")}
+ .doc {{ project_op.rename.pathname }} → {{ project_op.rename.newPathname }}
+ div(ng-if="project_op.add")
+ .action #{translate("file_action_created")}
+ .doc {{ project_op.add.pathname }}
+ div(ng-if="project_op.remove")
+ .action #{translate("file_action_deleted")}
+ .doc {{ project_op.remove.pathname }}
+ div.users
+ div.user(ng-repeat="update_user in update.meta.users")
+ .color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
+ .color-square(ng-if="update_user == null", ng-style="{'background-color': 'hsl(100, 70%, 50%)'}")
+ .name(ng-if="update_user && update_user.id != user.id" ng-bind="displayName(update_user)")
+ .name(ng-if="update_user && update_user.id == user.id") You
+ .name(ng-if="update_user == null") #{translate("anonymous")}
+ div.user(ng-if="update.meta.users.length == 0")
+ .color-square(style="background-color: hsl(100, 100%, 50%)")
+ span #{translate("anonymous")}
+
+ .loading(ng-show="history.loading")
+ i.fa.fa-spin.fa-refresh
+ | #{translate("loading")}...
+
+script(type="text/ng-template", id="historyEntriesListTpl")
+ .history-entries(
+ infinite-scroll="$ctrl.loadEntries()"
+ infinite-scroll-disabled="$ctrl.loadDisabled"
+ infinite-scroll-initialize="$ctrl.loadInitialize"
+ )
+ .infinite-scroll-inner
+ history-entry(
+ ng-repeat="entry in $ctrl.entries"
+ entry="entry"
+ current-user="$ctrl.currentUser"
+ on-select="$ctrl.onEntrySelect({ selectedEntry: selectedEntry })"
+ ng-show="!$ctrl.isLoading"
+ )
+ .loading(ng-show="$ctrl.isLoading")
+ i.fa.fa-spin.fa-refresh
+ | #{translate("loading")}...
+
+script(type="text/ng-template", id="historyEntryTpl")
+ .history-entry(
+ ng-class="{\
+ 'history-entry-first-in-day': $ctrl.entry.meta.first_in_day,\
+ 'history-entry-selected': $ctrl.entry.inSelection,\
+ 'history-entry-selected-to': $ctrl.entry.selectedTo,\
+ 'history-entry-selected-from': $ctrl.entry.selectedFrom,\
+ 'history-entry-hover-selected': $ctrl.entry.inHoverSelection,\
+ 'history-entry-hover-selected-to': $ctrl.entry.hoverSelectedTo,\
+ 'history-entry-hover-selected-from': $ctrl.entry.hoverSelectedFrom,\
+ }"
+ )
+
+ time.history-entry-day(ng-if="::$ctrl.entry.meta.first_in_day") {{ ::$ctrl.entry.meta.end_ts | relativeDate }}
+
+ .history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })")
+ ol.history-entry-changes
+ li.history-entry-change(
+ ng-repeat="pathname in ::$ctrl.entry.pathnames"
+ )
+ span.history-entry-change-action #{translate("file_action_edited")}
+ span.history-entry-change-doc {{ ::pathname }}
+ li.history-entry-change(
+ ng-repeat="project_op in ::$ctrl.entry.project_ops"
+ )
+ span.history-entry-change-action(
+ ng-if="::project_op.rename"
+ ) #{translate("file_action_renamed")}
+ span.history-entry-change-action(
+ ng-if="::project_op.add"
+ ) #{translate("file_action_created")}
+ span.history-entry-change-action(
+ ng-if="::project_op.remove"
+ ) #{translate("file_action_deleted")}
+ span.history-entry-change-doc {{ ::$ctrl.getProjectOpDoc(project_op) }}
+ .history-entry-metadata
+ time.history-entry-metadata-time {{ ::$ctrl.entry.meta.end_ts | formatDate:'h:mm a' }}
+ span
+ |
+ | •
+ |
+ ol.history-entry-metadata-users
+ li.history-entry-metadata-user(ng-repeat="update_user in ::$ctrl.entry.meta.users")
+ span.name(
+ ng-if="::update_user && update_user.id != $ctrl.currentUser.id"
+ ng-style="$ctrl.getUserCSSStyle(update_user);"
+ ) {{ ::$ctrl.displayName(update_user) }}
+ span.name(
+ ng-if="::update_user && update_user.id == $ctrl.currentUser.id"
+ ng-style="$ctrl.getUserCSSStyle(update_user);"
+ ) You
+ span.name(
+ ng-if="::update_user == null"
+ ng-style="$ctrl.getUserCSSStyle(update_user);"
+ ) #{translate("anonymous")}
+ li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
+ span.name(
+ ng-style="$ctrl.getUserCSSStyle();"
+ ) #{translate("anonymous")}
\ No newline at end of file
diff --git a/services/web/app/views/project/editor/history/fileTreeV2.pug b/services/web/app/views/project/editor/history/fileTreeV2.pug
new file mode 100644
index 0000000000..0f3a2c1203
--- /dev/null
+++ b/services/web/app/views/project/editor/history/fileTreeV2.pug
@@ -0,0 +1,70 @@
+aside.file-tree.full-size(
+ ng-controller="HistoryV2FileTreeController"
+ ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+ .history-file-tree-inner
+ history-file-tree(
+ file-tree="currentFileTree"
+ selected-pathname="history.selection.pathname"
+ on-selected-file-change="handleFileSelection(file)"
+ is-loading="history.loadingFileTree"
+ )
+
+aside.file-tree.file-tree-history.full-size(
+ ng-controller="FileTreeController"
+ ng-class="{ 'multi-selected': multiSelectedCount > 0 }"
+ ng-show="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.COMPARE")
+ .toolbar.toolbar-filetree
+ span Modified files
+
+ .file-tree-inner
+ ul.list-unstyled.file-tree-list
+ li(
+ ng-repeat="(pathname, doc) in history.selection.docs"
+ ng-class="{ 'selected': history.selection.pathname == pathname }"
+ )
+ .entity
+ .entity-name.entity-name-history(
+ ng-click="history.selection.pathname = pathname",
+ ng-class="{ 'deleted': !!doc.deletedAtV }"
+ )
+ i.fa.fa-fw.fa-pencil
+ span {{ pathname }}
+
+
+
+
+script(type="text/ng-template", id="historyFileTreeTpl")
+ .history-file-tree
+ history-file-entity(
+ ng-repeat="fileEntity in $ctrl.fileTree"
+ file-entity="fileEntity"
+ ng-show="!$ctrl.isLoading"
+ )
+
+
+script(type="text/ng-template", id="historyFileEntityTpl")
+ .history-file-entity-wrapper
+ a.history-file-entity-link(
+ href
+ ng-click="$ctrl.handleClick()"
+ ng-class="{ 'history-file-entity-link-selected': $ctrl.isSelected }"
+ )
+ span.history-file-entity-name
+ i.history-file-entity-icon.history-file-entity-icon-folder-state.fa.fa-fw(
+ ng-class="{\
+ 'fa-chevron-down': ($ctrl.fileEntity.type === 'folder' && $ctrl.isOpen),\
+ 'fa-chevron-right': ($ctrl.fileEntity.type === 'folder' && !$ctrl.isOpen)\
+ }"
+ )
+ i.history-file-entity-icon.fa(
+ ng-class="$ctrl.iconClass"
+ )
+ | {{ ::$ctrl.fileEntity.name }}
+ div(
+ ng-show="$ctrl.isOpen"
+ )
+ history-file-entity(
+ ng-repeat="childEntity in $ctrl.fileEntity.children"
+ file-entity="childEntity"
+ )
diff --git a/services/web/app/views/project/editor/history/diffPanelV2.pug b/services/web/app/views/project/editor/history/previewPanelV2.pug
similarity index 58%
rename from services/web/app/views/project/editor/history/diffPanelV2.pug
rename to services/web/app/views/project/editor/history/previewPanelV2.pug
index 415d587155..3d7a1ac3df 100644
--- a/services/web/app/views/project/editor/history/diffPanelV2.pug
+++ b/services/web/app/views/project/editor/history/previewPanelV2.pug
@@ -1,4 +1,7 @@
-.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController")
+.diff-panel.full-size(
+ ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
+ ng-controller="HistoryV2DiffController"
+)
.diff(
ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
ng-class="{ 'diff-binary': history.diff.binary }"
@@ -16,8 +19,13 @@
}"
)
| in {{history.diff.pathname}}
+ .history-toolbar-btn(
+ ng-click="toggleHistoryViewMode();"
+ )
+ i.fa
+ | #{translate("view_single_version")}
.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
- button.btn.btn-danger.btn-sm(
+ button.btn.btn-danger.btn-xs(
ng-click="restoreDeletedFile()"
ng-show="!restoreState.error"
ng-disabled="restoreState.inflight"
@@ -47,4 +55,27 @@
i.fa.fa-spin.fa-refresh
| #{translate("loading")}...
.error-panel(ng-show="history.diff.error")
- .alert.alert-danger #{translate("generic_something_went_wrong")}
\ No newline at end of file
+ .alert.alert-danger #{translate("generic_something_went_wrong")}
+
+.point-in-time-panel.full-size(
+ ng-if="history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+ .point-in-time-editor-container(
+ ng-if="!!history.selectedFile && !history.selectedFile.loading && !history.selectedFile.error"
+ )
+ .hide-ace-cursor(
+ ng-if="!history.selectedFile.binary"
+ ace-editor="history-pointintime",
+ theme="settings.theme",
+ font-size="settings.fontSize",
+ text="history.selectedFile.text",
+ read-only="true",
+ resize-on="layout:main:resize",
+ )
+ .alert.alert-info(ng-if="history.selectedFile.binary")
+ | We're still working on showing image and binary changes, sorry. Stay tuned!
+ .loading-panel(ng-show="history.selectedFile.loading")
+ i.fa.fa-spin.fa-refresh
+ | #{translate("loading")}...
+ .error-panel(ng-show="history.selectedFile.error")
+ .alert.alert-danger #{translate("generic_something_went_wrong")}
diff --git a/services/web/app/views/project/editor/history/toolbarV2.pug b/services/web/app/views/project/editor/history/toolbarV2.pug
new file mode 100644
index 0000000000..799a7136f3
--- /dev/null
+++ b/services/web/app/views/project/editor/history/toolbarV2.pug
@@ -0,0 +1,13 @@
+.history-toolbar(
+ ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
+)
+ span(ng-show="history.loadingFileTree")
+ i.fa.fa-spin.fa-refresh
+ | #{translate("loading")}...
+ span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")}
+ time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
+ .history-toolbar-btn(
+ ng-click="toggleHistoryViewMode();"
+ )
+ i.fa
+ | #{translate("compare_to_another_version")}
\ No newline at end of file
diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index ed6ef85b44..d85a45723b 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -62,6 +62,23 @@ aside#left-menu.full-size(
!= moduleIncludes("editorLeftMenu:editing_services", locals)
+ if showTestControls
+ h4 Test Controls
+ ul.list-unstyled.nav(ng-controller="TestControlsController")
+ li
+ a(href="#" ng-click="richText()")
+ i.fa.fa-exclamation.fa-fw
+ | Rich Text
+ li
+ a(href="#" ng-click="openProjectLinkedFileModal()")
+ i.fa.fa-exclamation.fa-fw
+ | Project-Linked-File Modal
+ li
+ a(href="#" ng-click="openLinkedFileModal()")
+ i.fa.fa-exclamation.fa-fw
+ | URL-Linked-File Modal
+
+
h4(ng-show="!anonymous") #{translate("settings")}
form.settings(ng-controller="SettingsController", ng-show="!anonymous")
.containter-fluid
@@ -179,6 +196,7 @@ aside#left-menu.full-size(
option(value="pdfjs") #{translate("built_in")}
option(value="native") #{translate("native")}
+
h4 #{translate("hotkeys")}
ul.list-unstyled.nav
li(ng-controller="HotkeysController")
diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug
new file mode 100644
index 0000000000..6dc27a4241
--- /dev/null
+++ b/services/web/app/views/project/editor/new_from_template.pug
@@ -0,0 +1,26 @@
+extends ../../layout
+
+block content
+ script.
+ $(document).ready(function(){
+ $('#create_form').submit();
+ });
+
+ .editor.full-size
+ .loading-screen()
+ .loading-screen-brand-container
+ .loading-screen-brand(
+ style="height: 20%;"
+ )
+
+ h3.loading-screen-label() #{translate("Opening template")}
+ span.loading-screen-ellip .
+ span.loading-screen-ellip .
+ span.loading-screen-ellip .
+
+ form(id='create_form' method='POST' action='/project/new/template/')
+ input(type="hidden", name="_csrf", value=csrfToken)
+ input(type="hidden" name="templateId" value=templateId)
+ input(type="hidden" name="templateVersionId" value=templateVersionId)
+ input(type="hidden" name="templateName" value=name)
+ input(type="hidden" name="compiler" value=compiler)
diff --git a/services/web/app/views/project/list/item.pug b/services/web/app/views/project/list/item.pug
index bfc53a8360..5a9cb2138d 100644
--- a/services/web/app/views/project/list/item.pug
+++ b/services/web/app/views/project/list/item.pug
@@ -1,4 +1,7 @@
-.col-xs-6
+- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+div(class=titleClasses)
input.select-item(
select-individual,
type="checkbox",
@@ -37,8 +40,50 @@
tooltip-placement="right"
tooltip-append-to-body="true"
)
-.col-xs-4
+
+div(class=lastUpdatedClasses)
if settings.overleaf
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
else
span.last-modified {{project.lastUpdated | formatDate}}
+
+if settings.overleaf
+ .hidden-xs.col-sm-3.col-md-2.action-btn-row
+ button.btn.btn-link.action-btn(
+ tooltip=translate('copy'),
+ tooltip-placement="top",
+ tooltip-append-to-body="true",
+ ng-click="clone($event)"
+ )
+ i.icon.fa.fa-files-o
+ button.btn.btn-link.action-btn(
+ tooltip=translate('download'),
+ tooltip-placement="top",
+ tooltip-append-to-body="true",
+ ng-click="download($event)"
+ )
+ i.icon.fa.fa-cloud-download
+ button.btn.btn-link.action-btn(
+ ng-if="!project.archived && isOwner()"
+ tooltip=translate('archive'),
+ tooltip-placement="top",
+ tooltip-append-to-body="true",
+ ng-click="archiveOrLeave($event)"
+ )
+ i.icon.fa.fa-inbox
+ button.btn.btn-link.action-btn(
+ ng-if="!project.archived && !isOwner()"
+ tooltip=translate('leave'),
+ tooltip-placement="top",
+ tooltip-append-to-body="true",
+ ng-click="archiveOrLeave($event)"
+ )
+ i.icon.fa.fa-sign-out
+ button.btn.btn-link.action-btn(
+ ng-if="project.archived"
+ tooltip=translate('unarchive'),
+ tooltip-placement="top",
+ tooltip-append-to-body="true",
+ ng-click="restore($event)"
+ )
+ i.icon.fa.fa-reply
\ No newline at end of file
diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug
index df3c2bf681..cfea4aa6c6 100644
--- a/services/web/app/views/project/list/project-list.pug
+++ b/services/web/app/views/project/list/project-list.pug
@@ -131,7 +131,10 @@
)
li.container-fluid
.row
- .col-xs-6
+ - var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+ - var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+ div(class=titleClasses)
input.select-all(
select-all,
type="checkbox"
@@ -142,9 +145,12 @@
.col-xs-2
span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
- .col-xs-4
+ div(class=lastUpdatedClasses)
span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
+ if settings.overleaf
+ .hidden-xs.col-sm-3.col-md-2.action-btn-row-header
+ span.header #{translate("actions")}
li.project_entry.container-fluid(
ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
ng-controller="ProjectListItemController"
diff --git a/services/web/app/views/project/list/v1-item.pug b/services/web/app/views/project/list/v1-item.pug
index b4a3ccb99d..5a8e37bca0 100644
--- a/services/web/app/views/project/list/v1-item.pug
+++ b/services/web/app/views/project/list/v1-item.pug
@@ -1,4 +1,4 @@
-.col-xs-6
+.col-xs-6.col-sm-4.col-md-6
.select-item
span.v1-badge(
aria-label=translate("v1_badge")
@@ -21,5 +21,5 @@
.col-xs-2
span.owner {{ownerName()}}
-.col-xs-4
+.col-xs-4.col-sm-3.col-md-2
span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
\ No newline at end of file
diff --git a/services/web/app/views/subscriptions/_plans_page_details_less.pug b/services/web/app/views/subscriptions/_plans_page_details_less.pug
new file mode 100644
index 0000000000..2aa572e4e7
--- /dev/null
+++ b/services/web/app/views/subscriptions/_plans_page_details_less.pug
@@ -0,0 +1,118 @@
+.row
+ .col-md-12
+ .page-header.centered.plans-header.text-centered
+ h1 #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
+.row
+ .col-md-8.col-md-offset-2
+ p.text-centered #{translate("sl_benefits_plans")}
+
+.row.top-switch
+ .col-md-6.col-md-offset-3
+ +plan_switch('card')
+ .col-md-2.text-right
+ +currency_dropdown
+
+div(ng-show="showPlans")
+ .row
+ .col-md-10.col-md-offset-1
+ .row
+ .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
+ .col-md-4
+ .card.card-first
+ .card-header
+ h2 #{translate("personal")}
+ .circle #{translate("free")}
+ +features_free
+ .col-md-4
+ .card.card-highlighted
+ .card-header
+ h2 #{translate("collaborator")}
+ .circle
+ +price_collaborator
+ +features_collaborator
+ .col-md-4
+ .card.card-last
+ .card-header
+ h2 #{translate("professional")}
+ .circle
+ +price_professional
+ +features_professional
+
+ .card-group.text-centered(ng-if="ui.view == 'student'")
+ .col-md-4
+ .card.card-first
+ .card-header
+ h2 #{translate("personal")}
+ .circle #{translate("free")}
+ +features_free
+
+ .col-md-4
+ .card.card-highlighted
+ +card_student_monthly
+
+ .col-md-4
+ .card.card-last
+ +card_student_annual
+
+.row.row-spaced
+ p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
+
+.row
+ .col-md-8.col-md-offset-2
+ .alert.alert-info.text-centered
+ | #{translate("interested_in_group_licence")}
+ br
+ a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
+
+ script(type="text/ng-template", id="groupPlanModalTemplate")
+ .modal-header
+ h3 #{translate("group_plan_enquiry")}
+ .modal-body
+ form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()")
+ span(ng-show="sent == false && error == false")
+ .form-group
+ label#title9(for='Field9')
+ | Name
+ input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
+ label#title11.desc(for='Field11')
+ | Email
+ .form-group
+ input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
+ label#title12.desc(for='Field12')
+ | University / Company
+ .form-group
+ input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
+ label#title13.desc(for='Field13')
+ | Position
+ .form-group
+ input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
+ .form-group
+ input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
+ .form-group.text-center
+ input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote')
+ span(ng-show="sent == true && error == false")
+ p Request Sent, Thank you.
+ span(ng-show="error")
+ p Error sending request.
+
+.row
+ .col-md-12
+ .page-header.plans-header.plans-subheader.text-centered
+ h2 #{translate("enjoy_these_features")}
+ .col-md-4
+ .card.features.text-centered
+ i.fa.fa-file-text-o.fa-5x
+ h4 #{translate("unlimited_projects")}
+ p #{translate("create_unlimited_projects")}
+ .col-md-4
+ .card.features.text-centered
+ i.fa.fa-clock-o.fa-5x
+ h4 #{translate("full_doc_history")}
+ p #{translate("never_loose_work")}
+ .col-md-4
+ .card.features.text-centered
+ i.fa.fa-dropbox.fa-5x
+ |
+ i.fa.fa-github.fa-5x
+ h4 #{translate("sync_to_dropbox_and_github")}
+ p #{translate("access_projects_anywhere")}
\ No newline at end of file
diff --git a/services/web/app/views/subscriptions/_plans_page_details_more.pug b/services/web/app/views/subscriptions/_plans_page_details_more.pug
new file mode 100644
index 0000000000..17c683201e
--- /dev/null
+++ b/services/web/app/views/subscriptions/_plans_page_details_more.pug
@@ -0,0 +1,160 @@
+.row
+ .col-md-12
+ .page-header.centered.plans-header.text-centered
+ h1.text-capitalize #{translate('instant_access')}
+.row
+ .col-md-8.col-md-offset-2
+ p.text-centered #{translate("sl_benefits_plans")}
+
+.row.top-switch
+ .col-md-6.col-md-offset-3
+ +plan_switch('card')
+ .col-md-2.text-right
+ +currency_dropdown
+
+div(ng-show="showPlans")
+ .row
+ .col-md-10.col-md-offset-1
+ .row
+ .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
+ .col-md-4
+ .card.card-first
+ .card-header
+ h2 #{translate("personal")}
+ h5.tagline #{translate("tagline_personal")}
+ .circle #{translate("free")}
+ +features_free
+ .col-md-4
+ .card.card-highlighted
+ .best-value
+ strong #{translate('best_value')}
+ .card-header
+ h2 #{translate("collaborator")}
+ h5.tagline #{translate("tagline_collaborator")}
+ .circle
+ +price_collaborator
+ +features_collaborator
+ .col-md-4
+ .card.card-last
+ .card-header
+ h2 #{translate("professional")}
+ h5.tagline #{translate("tagline_professional")}
+ .circle
+ +price_professional
+ +features_professional
+
+ .card-group.text-centered(ng-if="ui.view == 'student'")
+ .col-md-4
+ .card.card-first
+ .card-header
+ h2 #{translate("personal")}
+ h5.tagline #{translate("tagline_personal")}
+ .circle #{translate("free")}
+ +features_free
+
+ .col-md-4
+ .card.card-highlighted
+ +card_student_annual
+
+ .col-md-4
+ .card.card-last
+ +card_student_monthly
+
+.row.row-spaced-large.text-centered
+ i.fa.fa-cc-mastercard.fa-2x
+ i.fa.fa-cc-visa.fa-2x
+ i.fa.fa-cc-amex.fa-2x
+ i.fa.fa-cc-paypal.fa-2x
+ div.text-centered #{translate('change_plans_any_time')}
#{translate('billed_after_x_days', {len:'{{trial_len}}'})}
+
+.row.row-spaced-large
+ .col-md-8.col-md-offset-2
+ .card.text-centered
+ .card-header
+ h2 #{translate('looking_multiple_licenses')}
+ span #{translate('reduce_costs_group_licenses')}
+ br
+ br
+ a.btn.btn-info(href="/i/university/groups") #{translate('find_out_more')}
+
+div
+ .row.row-spaced-large
+ .col-sm-12
+ .page-header.plans-header.plans-subheader.text-centered
+ h2 #{translate('compare_plan_features')}
+ .row
+ .col-md-6.col-md-offset-3
+ +plan_switch('table')
+ .col-md-3.text-right
+ +currency_dropdown
+ .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true")
+ .col-sm-12(ng-if="ui.view != 'student'")
+ +table_premium
+ .col-sm-12(ng-if="ui.view == 'student'")
+ +table_student
+
+ .row.row-spaced-large
+ .col-md-12
+ .page-header.plans-header.plans-subheader.text-centered
+ h2 #{translate('in_good_company')}
+ .row
+ .col-md-6
+ div
+ .row
+ .col-md-3
+ .circle-img
+ img(src=buildImgPath('advocates/erdogmus.jpg') alt="Professor Erdogmus")
+ .col-md-9
+ blockquote
+ p The ability to track changes and the real-time collaborative nature is what sets ShareLaTeX apart.
+ footer Professor Erdogmus, Northeastern University
+ .col-md-6
+ div
+ .row
+ .col-md-3
+ .circle-img
+ img(src=buildImgPath('advocates/henderson.jpg') alt="Rob Henderson")
+ .col-md-9
+ blockquote
+ p ShareLaTeX has proven to be a powerful and robust collaboration tool that is widely used in our School.
+ footer Rob Henderson, School Of Informatics And Computing - Indiana University
+
+ .faq
+ .row.row-spaced-large
+ .col-md-12
+ .page-header.plans-header.plans-subheader.text-centered
+ h2 FAQ
+ .row
+ .col-md-6
+ h3 #{translate("faq_how_free_trial_works_question")}
+ p #{translate('faq_how_free_trial_works_answer', { len:'{{trial_len}}' })}
+ .col-md-6
+ h3 #{translate('faq_change_plans_question')}
+ p #{translate('faq_change_plans_answer')}
+ .row
+ .col-md-6
+ h3 #{translate('faq_do_collab_need_premium_question')}
+ p #{translate('faq_do_collab_need_premium_answer')}
+ .col-md-6
+ h3 #{translate('faq_need_more_collab_question')}
+ p !{translate('faq_need_more_collab_answer', { referFriendsLink: '' + translate('referring_your_friends') + ''})}
+ .row
+ .col-md-6
+ h3 #{translate('faq_purchase_more_licenses_question')}
+ p !{translate('faq_purchase_more_licenses_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })}
+ .col-md-6
+ h3 #{translate('faq_monthly_or_annual_question')}
+ p #{translate('faq_monthly_or_annual_answer')}
+ .row
+ .col-md-6
+ h3 #{translate('faq_how_to_pay_question')}
+ p #{translate('faq_how_to_pay_answer')}
+ .col-md-6
+ h3 #{translate('faq_pay_by_invoice_question')}
+ p !{translate('faq_pay_by_invoice_answer', { groupLink: '' + translate('discounted_group_accounts') + '' })}
+ .row.row-spaced-large.text-centery
+ .col-md-12
+ .plans-header.plans-subheader.text-centered
+ h2 #{translate('still_have_questions')}
+ button.btn.btn-info.btn-header.text-capitalize(ng-controller="ContactGeneralModal" ng-click="openModal()") #{translate('get_in_touch')}
+ != moduleIncludes("contactModalGeneral", locals)
diff --git a/services/web/app/views/subscriptions/_plans_page_mixins.pug b/services/web/app/views/subscriptions/_plans_page_mixins.pug
new file mode 100644
index 0000000000..c1f19bca00
--- /dev/null
+++ b/services/web/app/views/subscriptions/_plans_page_mixins.pug
@@ -0,0 +1,162 @@
+//- Buy Buttons
+mixin btn_buy_collaborator(location)
+ a.btn.btn-info(
+ ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}",
+ ng-click="signUpNowClicked('collaborator','" + location + "')"
+ )
+ span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+ span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+mixin btn_buy_free(location)
+ a.btn.btn-info(
+ href="/register"
+ style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
+ ng-click="signUpNowClicked('free','" + location + "')"
+ )
+ span(ng-if="plansVariant !== 'more-details'") #{translate('sign_up_now')}
+ span.text-capitalize(ng-if="plansVariant === 'more-details'") #{translate('get_started_now')}
+mixin btn_buy_professional(location)
+ a.btn.btn-info(
+ ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}"
+ ng-click="signUpNowClicked('professional','" + location + "')"
+ )
+ span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
+ span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
+mixin btn_buy_student(location, plan)
+ if plan == 'annual'
+ a.btn.btn-info(
+ ng-href="/user/subscription/new?planCode=student-annual¤cy={{currencyCode}}",
+ ng-click="signUpNowClicked('student-annual','" + location + "')"
+ ) #{translate("buy_now")}
+ else
+ //- planQueryString will contain _free_trial_7_days
+ a.btn.btn-info(
+ ng-href="/user/subscription/new?planCode=student{{planQueryString}}¤cy={{currencyCode}}",
+ ng-click="signUpNowClicked('student-monthly','" + location + "')"
+ ) #{translate("start_free_trial")}
+
+//- Cards
+mixin card_student_annual
+ .best-value(ng-if="plansVariant == 'more-details'")
+ strong #{translate('best_value')}
+ .card-header
+ h2 #{translate("student")} (#{translate("annual")})
+ h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_annual')}
+ .circle
+ span
+ +price_student_annual
+ +features_student('card', 'annual')
+mixin card_student_monthly
+ .card-header
+ h2 #{translate("student")}
+ h5.tagline(ng-if="plansVariant == 'more-details'") #{translate('tagline_student_monthly')}
+ .circle
+ span
+ +price_student_monthly
+ +features_student('card', 'monthly')
+
+//- Features Lists
+mixin features_collaborator
+ ul.list-unstyled
+ li
+ strong #{translate("collabs_per_proj", {collabcount:10})}
+ +features_premium
+ li
+ br
+ +btn_buy_collaborator('card')
+mixin features_free
+ ul.list-unstyled
+ li #{translate("one_collaborator")}
+ li(class="hidden-xs hidden-sm")
+ li(class="hidden-xs hidden-sm")
+ li(class="hidden-xs hidden-sm")
+ li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")
+ li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")
+ li(class="hidden-xs hidden-sm" ng-if="plansVariant === 'more-details'")
+ li
+ br
+ +btn_buy_free('card')
+mixin features_premium
+ li(ng-if="plansVariant != 'more-details'") #{translate("full_doc_history")}
+ li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_dropbox")}
+ li(ng-if="plansVariant != 'more-details'") #{translate("sync_to_github")}
+ li(ng-if="plansVariant === 'more-details'")
+ li(ng-if="plansVariant === 'more-details'")
+ strong #{translate('all_premium_features')}
+ li(ng-if="plansVariant === 'more-details'") #{translate('sync_dropbox_github')}
+ li(ng-if="plansVariant === 'more-details'") #{translate('full_doc_history')}
+ li(ng-if="plansVariant === 'more-details'") #{translate('track_changes')}
+ li(ng-if="plansVariant === 'more-details'") + #{translate('more').toLowerCase()}
+mixin features_professional
+ ul.list-unstyled
+ li
+ strong #{translate("unlimited_collabs")}
+ +features_premium
+ li
+ br
+ +btn_buy_professional('card')
+mixin features_student(location, plan)
+ ul.list-unstyled
+ li
+ strong #{translate("collabs_per_proj", {collabcount:6})}
+ +features_premium
+ li
+ br
+ +btn_buy_student(location, plan)
+
+//- Prices
+mixin price_collaborator
+ span(ng-if="ui.view == 'monthly'")
+ | {{plans[currencyCode]['collaborator']['monthly']}}
+ span.small /mo
+ span(ng-if="ui.view == 'annual'")
+ | {{plans[currencyCode]['collaborator']['annual']}}
+ span.small /yr
+mixin price_professional
+ span(ng-if="ui.view == 'monthly'")
+ | {{plans[currencyCode]['professional']['monthly']}}
+ span.small /mo
+ span(ng-if="ui.view == 'annual'")
+ | {{plans[currencyCode]['professional']['annual']}}
+ span.small /yr
+mixin price_student_annual
+ | {{plans[currencyCode]['student']['annual']}}
+ span.small /yr
+mixin price_student_monthly
+ | {{plans[currencyCode]['student']['monthly']}}
+ span.small /mo
+
+//- UI Control
+mixin currency_dropdown
+ .dropdown.currency-dropdown(dropdown)
+ a.btn.btn-default.dropdown-toggle(
+ href="#",
+ data-toggle="dropdown",
+ dropdown-toggle
+ )
+ | {{currencyCode}} ({{plans[currencyCode]['symbol']}})
+ span.caret
+
+ ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
+ li(ng-repeat="(currency, value) in plans")
+ a(
+ href="#",
+ ng-click="changeCurreny($event, currency)"
+ ) {{currency}} ({{value['symbol']}})
+mixin plan_switch(location)
+ ul.nav.nav-pills
+ li(ng-class="{'active': ui.view == 'monthly'}")
+ a(
+ href="#"
+ ng-click="switchToMonthly($event,'" + location + "')"
+ ) #{translate("monthly")}
+ li(ng-class="{'active': ui.view == 'annual'}")
+ a(
+ href="#"
+ ng-click="switchToAnnual($event,'" + location + "')"
+ ) #{translate("annual")}
+ li(ng-class="{'active': ui.view == 'student'}")
+ a(
+ href="#"
+ ng-click="switchToStudent($event,'" + location + "')"
+ ) #{translate("half_price_student")}
+
diff --git a/services/web/app/views/subscriptions/_plans_page_tables.pug b/services/web/app/views/subscriptions/_plans_page_tables.pug
new file mode 100644
index 0000000000..63c4747603
--- /dev/null
+++ b/services/web/app/views/subscriptions/_plans_page_tables.pug
@@ -0,0 +1,107 @@
+
+//- Features Tables
+mixin table_premium
+ table.card.plans-table
+ tr
+ th
+ th #{translate("personal")}
+ th #{translate("collaborator")}
+ .outer.outer-top
+ .outer-content
+ .best-value
+ strong #{translate('best_value')}
+ th #{translate("professional")}
+
+ tr
+ td #{translate("price")}
+ td #{translate("free")}
+ td
+ +price_collaborator
+ td
+ +price_professional
+
+ for feature in planFeatures
+ tr
+ td(event-tracking="features-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
+ if feature.info
+ span(tooltip=translate(feature.info)) #{translate(feature.feature)}
+ else
+ | #{translate(feature.feature)}
+ for plan in feature.plans
+ td
+ if feature.value == 'str'
+ | #{plan}
+ else if plan
+ i.fa.fa-check
+ else
+ i.fa.fa-times
+
+ tr
+ td
+ td
+ +btn_buy_free('table')
+ td
+ +btn_buy_collaborator('table')
+ .outer.outer-btm
+ .outer-content
+ td
+ +btn_buy_professional('table')
+
+mixin table_cell_student(feature)
+ if feature.value == 'str'
+ | #{feature.student}
+ else if feature.student
+ i.fa.fa-check
+ else
+ i.fa.fa-times
+
+mixin table_student
+ table.card.plans-table
+ tr
+ th
+ th #{translate("personal")}
+ th #{translate("student")} (#{translate("annual")})
+ .outer.outer-top
+ .outer-content
+ .best-value
+ strong Best Value
+ th #{translate("student")}
+
+ tr
+ td #{translate("price")}
+ td #{translate("free")}
+ td
+ +price_student_annual
+ td
+ +price_student_monthly
+
+ for feature in planFeatures
+ tr
+ td(event-tracking="plans-page-table" event-tracking-trigger="hover" event-tracking-ga="subscription-funnel" event-tracking-label=`${feature.feature}-exp-{{plansVariant}}`)
+ if feature.info
+ span(tooltip=translate(feature.info)) #{translate(feature.feature)}
+ else
+ | #{translate(feature.feature)}
+ td
+ if feature.value == 'str'
+ | #{feature.plans.free}
+ else if feature.plans.free
+ i.fa.fa-check
+ else
+ i.fa.fa-times
+ td
+ +table_cell_student(feature)
+ td
+ +table_cell_student(feature)
+
+ tr
+ td
+ td
+ +btn_buy_free('table')
+ td
+ +btn_buy_student('table', 'annual')
+ .outer.outer-btm
+ .outer-content
+ td
+ +btn_buy_student('table', 'monthly')
+
diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug
index d86bdb8166..4eae442f1e 100644
--- a/services/web/app/views/subscriptions/new.pug
+++ b/services/web/app/views/subscriptions/new.pug
@@ -31,7 +31,10 @@ block content
li(ng-repeat="(currency, value) in plans")
a(
ng-click="changeCurrency(currency)",
- ) {{currency}} ({{value['symbol']}})
+ ) {{currency}} ({{value['symbol']}})
+ .row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'")
+ .col-xs-12
+ p.student-disclaimer #{translate('student_disclaimer')}
hr.thin
.row
.col-md-12.text-center
diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug
index 7c40c38a52..56a20f90f5 100644
--- a/services/web/app/views/subscriptions/plans.pug
+++ b/services/web/app/views/subscriptions/plans.pug
@@ -1,253 +1,18 @@
extends ../layout
+
+include _plans_page_mixins
+include _plans_page_tables
+
block scripts
script(type='text/javascript').
window.recomendedCurrency = '#{recomendedCurrency}'
window.abCurrencyFlag = '#{abCurrencyFlag}'
window.shouldABTestPlans = #{shouldABTestPlans || false}
- script(type='text/javascript').
- (function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true;
- s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js';
- var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})();
-
block content
.content.content-alt
.content.plans(ng-controller="PlansController")
- .container
- .row
- .col-md-12
- .page-header.centered.plans-header.text-centered
- h1(ng-cloak) #{translate("start_x_day_trial", {len:'{{trial_len}}'})}
- .row
- .col-md-8.col-md-offset-2
- p.text-centered #{translate("sl_benefits_plans")}
-
- .row(ng-cloak)
- .col-md-6.col-md-offset-3
- ul.nav.nav-pills
- li(ng-class="{'active': ui.view == 'monthly'}")
- a(
- href,
- ng-click="switchToMonthly()"
- ) #{translate("monthly")}
- li(ng-class="{'active': ui.view == 'annual'}")
- a(
- href
- ng-click="switchToAnnual()"
- ) #{translate("annual")}
- li(ng-class="{'active': ui.view == 'student'}")
- a(
- href,
- ng-click="switchToStudent()"
- ) #{translate("half_price_student")}
- .col-md-2.text-right
- .dropdown.currency-dropdown(dropdown)
- a.btn.btn-default.dropdown-toggle#currenyDropdown(
- href="#",
- data-toggle="dropdown",
- dropdown-toggle
- )
- | {{currencyCode}} ({{plans[currencyCode]['symbol']}})
- span.caret
-
- ul.dropdown-menu.dropdown-menu-right.text-right(role="menu")
- li(ng-repeat="(currency, value) in plans")
- a(
- href,
- ng-click="changeCurreny(currency)"
- ) {{currency}} ({{value['symbol']}})
-
- div(ng-show="showPlans")
- .row(ng-cloak)
- .col-md-10.col-md-offset-1
- .row
- .card-group.text-centered(ng-if="ui.view == 'monthly' || ui.view == 'annual'")
- .col-md-4
- .card.card-first
- .card-header
- h2 #{translate("personal")}
- .circle #{translate("free")}
- ul.list-unstyled
- li #{translate("one_collaborator")}
- li
- li
- li
- li
- br
- a.btn.btn-info(
- href="/register"
- style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
- ) #{translate("sign_up_now")}
- .col-md-4
- .card.card-highlighted
- .card-header
- h2 #{translate("collaborator")}
- .circle
- span(ng-if="ui.view == 'monthly'")
- | {{plans[currencyCode]['collaborator']['monthly']}}
- span.small /mo
- span(ng-if="ui.view == 'annual'")
- | {{plans[currencyCode]['collaborator']['annual']}}
- span.small /yr
- ul.list-unstyled
- li
- strong #{translate("collabs_per_proj", {collabcount:10})}
- li #{translate("full_doc_history")}
- li #{translate("sync_to_dropbox")}
- li #{translate("sync_to_github")}
- li
- br
- a.btn.btn-info(
- ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
- )
- span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
- span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
- .col-md-4
- .card.card-last
- .card-header
- h2 #{translate("professional")}
- .circle
- span(ng-if="ui.view == 'monthly'")
- | {{plans[currencyCode]['professional']['monthly']}}
- span.small /mo
- span(ng-if="ui.view == 'annual'")
- | {{plans[currencyCode]['professional']['annual']}}
- span.small /yr
- ul.list-unstyled
- li
- strong #{translate("unlimited_collabs")}
- li #{translate("full_doc_history")}
- li #{translate("sync_to_dropbox")}
- li #{translate("sync_to_github")}
- li
- br
- a.btn.btn-info(
- ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('professional')"
- )
- span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
- span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
-
- .card-group.text-centered(ng-if="ui.view == 'student'")
- .col-md-4
- .card.card-first
- .card-header
- h2 #{translate("personal")}
- .circle #{translate("free")}
- ul.list-unstyled
- li #{translate("one_collaborator")}
- li
- li
- li
- li
- br
- a.btn.btn-info(
- href="/register"
- style=(getLoggedInUserId() === null ? "" : "visibility: hidden")
- ) #{translate("sign_up_now")}
-
- .col-md-4
- .card.card-highlighted
- .card-header
- h2 #{translate("student")}
- .circle
- span
- | {{plans[currencyCode]['student']['monthly']}}
- span.small /mo
- ul.list-unstyled
- li
- strong #{translate("collabs_per_proj", {collabcount:6})}
- li #{translate("full_doc_history")}
- li #{translate("sync_to_dropbox")}
- li #{translate("sync_to_github")}
- li
- br
- a.btn.btn-info(
- ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}¤cy={{currencyCode}}",
- ng-click="signUpNowClicked('student-monthly')"
- ) #{translate("start_free_trial")}
-
- .col-md-4
- .card.card-last
- .card-header
- h2 #{translate("student")} (#{translate("annual")})
- .circle
- span
- | {{plans[currencyCode]['student']['annual']}}
- span.small /yr
- ul.list-unstyled
- li
- strong #{translate("collabs_per_proj", {collabcount:6})}
- li #{translate("full_doc_history")}
- li #{translate("sync_to_dropbox")}
- li #{translate("sync_to_github")}
- li
- br
- a.btn.btn-info(
- ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}¤cy={{currencyCode}}",
- ng-click="signUpNowClicked('student-annual')"
- ) #{translate("buy_now")}
-
-
-
- .row.row-spaced(ng-cloak)
- p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
-
- .row(ng-cloak)
- .col-md-8.col-md-offset-2
- .alert.alert-info.text-centered
- | #{translate("interested_in_group_licence")}
- br
- a(href, ng-click="openGroupPlanModal()") #{translate("get_in_touch_for_details")}
-
- script(type="text/ng-template", id="groupPlanModalTemplate")
- .modal-header
- h3 #{translate("group_plan_enquiry")}
- .modal-body
- form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak)
- span(ng-show="sent == false && error == false")
- .form-group
- label#title9(for='Field9')
- | Name
- input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
- label#title11.desc(for='Field11')
- | Email
- .form-group
- input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
- label#title12.desc(for='Field12')
- | University / Company
- .form-group
- input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
- label#title13.desc(for='Field13')
- | Position
- .form-group
- input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
- .form-group
- input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'General enquiry for larger ShareLaTeX use';")
- .form-group.text-center
- input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending", value='Request a quote')
- span(ng-show="sent == true && error == false")
- p Request Sent, Thank you.
- span(ng-show="error")
- p Error sending request.
-
- .row
- .col-md-12
- .page-header.plans-header.plans-subheader.text-centered
- h2 #{translate("enjoy_these_features")}
- .col-md-4
- .card.features.text-centered
- i.fa.fa-file-text-o.fa-5x
- h4 #{translate("unlimited_projects")}
- p #{translate("create_unlimited_projects")}
- .col-md-4
- .card.features.text-centered
- i.fa.fa-clock-o.fa-5x
- h4 #{translate("full_doc_history")}
- p #{translate("never_loose_work")}
- .col-md-4
- .card.features.text-centered
- i.fa.fa-dropbox.fa-5x
- |
- i.fa.fa-github.fa-5x
- h4 #{translate("sync_to_dropbox_and_github")}
- p #{translate("access_projects_anywhere")}
+ .container(class="more-details" ng-cloak ng-if="plansVariant === 'more-details'")
+ include _plans_page_details_more
+ .container(ng-cloak ng-if="plansVariant != 'more-details'")
+ include _plans_page_details_less
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index 0892804778..7f4024c368 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/services/web/config/settings.defaults.coffee
@@ -146,8 +146,8 @@ module.exports = settings =
url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036"
sixpack:
url: ""
- # references:
- # url: "http://localhost:3040"
+ references:
+ url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040"
notifications:
url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042"
analytics:
diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml
index a062c0df4e..9596fe5126 100644
--- a/services/web/docker-compose.yml
+++ b/services/web/docker-compose.yml
@@ -17,6 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
+ ENABLED_LINKED_FILE_TYPES: 'url,project_file'
SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis
diff --git a/services/web/public/coffee/directives/eventTracking.coffee b/services/web/public/coffee/directives/eventTracking.coffee
index 9ba2fbb647..2710960fe0 100644
--- a/services/web/public/coffee/directives/eventTracking.coffee
+++ b/services/web/public/coffee/directives/eventTracking.coffee
@@ -4,11 +4,23 @@
# event not sent to MB.
# for MB, add event-tracking-mb='true'
# by default, event sent to MB via sendMB
-# this can be changed to use sendMBOnce via event-tracking-send-once='true' attribute
# event not sent to GA.
# for GA, add event-tracking-ga attribute, where the value is the GA category
+# Either GA or MB can use the attribute event-tracking-send-once='true' to
+# send event just once
+# MB will use the key and GA will use the action to determine if the event
+# has been sent
# event-tracking-trigger attribute is required to send event
+isInViewport = (element) ->
+ elTop = element.offset().top
+ elBtm = elTop + element.outerHeight()
+
+ viewportTop = $(window).scrollTop()
+ viewportBtm = viewportTop + $(window).height()
+
+ elBtm > viewportTop && elTop < viewportBtm
+
define [
'base'
], (App) ->
@@ -22,20 +34,42 @@ define [
sendGA = attrs.eventTrackingGa || false
sendMB = attrs.eventTrackingMb || false
sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
+ sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send'
segmentation = scope.eventSegmentation || {}
-
segmentation.page = window.location.pathname
- sendEvent = () ->
+ sendEvent = (scrollEvent) ->
+ ###
+ @param {boolean} scrollEvent Use to unbind scroll event
+ ###
if sendMB
event_tracking[sendMBFunction] scope.eventTracking, segmentation
if sendGA
- event_tracking.send attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
+ event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
+ if scrollEvent
+ $(window).unbind('resize scroll')
if attrs.eventTrackingTrigger == 'load'
sendEvent()
else if attrs.eventTrackingTrigger == 'click'
element.on 'click', (e) ->
sendEvent()
+ else if attrs.eventTrackingTrigger == 'hover'
+ timer = null
+ timeoutAmt = 500
+ if attrs.eventHoverAmt
+ timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
+ element.on 'mouseenter', () ->
+ timer = setTimeout((-> sendEvent()), timeoutAmt)
+ return
+ .on 'mouseleave', () ->
+ clearTimeout(timer)
+ else if attrs.eventTrackingTrigger == 'scroll'
+ if !event_tracking.eventInCache(scope.eventTracking)
+ $(window).on 'resize scroll', () ->
+ _.throttle(
+ if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking)
+ sendEvent(true)
+ , 500)
}
- ]
\ No newline at end of file
+ ]
diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee
index 7c8602eb76..9f910d857e 100644
--- a/services/web/public/coffee/ide.coffee
+++ b/services/web/public/coffee/ide.coffee
@@ -18,6 +18,7 @@ define [
"ide/chat/index"
"ide/clone/index"
"ide/hotkeys/index"
+ "ide/test-controls/index"
"ide/wordcount/index"
"ide/directives/layout"
"ide/directives/validFile"
@@ -34,6 +35,7 @@ define [
"directives/videoPlayState"
"services/queued-http"
"services/validateCaptcha"
+ "services/wait-for"
"filters/formatDate"
"main/event"
"main/account-upgrade"
@@ -54,7 +56,7 @@ define [
SafariScrollPatcher
) ->
- App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) ->
+ App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) ->
# Don't freak out if we're already in an apply callback
$scope.$originalApply = $scope.$apply
$scope.$apply = (fn = () ->) ->
@@ -211,11 +213,10 @@ define [
try
chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null;
browserIsChrome61or62 = (
- chromeVersion? &&
- (chromeVersion == 61 || chromeVersion == 62)
+ chromeVersion?
)
if browserIsChrome61or62
- document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; }", 1)
+ document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1)
catch err
console.error err
diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
index c14e097842..bba455c447 100644
--- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
+++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
@@ -2,7 +2,7 @@ define [
"base"
"moment"
], (App, moment) ->
- App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) ->
+ App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) ->
TWO_MEGABYTES = 2 * 1024 * 1024
@@ -31,6 +31,7 @@ define [
data: null
$scope.refreshing = false
+ $scope.refreshError = null
MAX_URL_LENGTH = 60
FRONT_OF_URL_LENGTH = 35
@@ -48,9 +49,27 @@ define [
$scope.refreshFile = (file) ->
$scope.refreshing = true
+ $scope.refreshError = null
ide.fileTreeManager.refreshLinkedFile(file)
- .then () ->
- loadTextFileFilePreview()
+ .then (response) ->
+ { data } = response
+ { new_file_id } = data
+ $timeout(
+ () ->
+ waitFor(
+ () ->
+ ide.fileTreeManager.findEntityById(new_file_id)
+ 5000
+ )
+ .then (newFile) ->
+ ide.binaryFilesManager.openFile(newFile)
+ .catch (err) ->
+ console.warn(err)
+ , 0
+ )
+ $scope.refreshError = null
+ .catch (response) ->
+ $scope.refreshError = response.data
.finally () ->
$scope.refreshing = false
@@ -86,11 +105,9 @@ define [
# show dots when payload is closs to cutoff
if data.length >= (TWO_MEGABYTES - 200)
$scope.textPreview.shouldShowDots = true
- try
# remove last partial line
- data = data.replace(/\n.*$/, '')
- finally
- $scope.textPreview.data = data
+ data = data?.replace?(/\n.*$/, '')
+ $scope.textPreview.data = data
$timeout(setHeight, 0)
.catch (error) ->
console.error(error)
diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee
index e3cabf8e98..7246e09b83 100644
--- a/services/web/public/coffee/ide/editor/EditorManager.coffee
+++ b/services/web/public/coffee/ide/editor/EditorManager.coffee
@@ -14,7 +14,7 @@ define [
opening: true
trackChanges: false
wantTrackChanges: false
- richText: false
+ showRichText: false
}
@$scope.$on "entity:selected", (event, entity) =>
diff --git a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
index d7a428ec80..8d717e09b8 100644
--- a/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
+++ b/services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
@@ -335,6 +335,11 @@ define [
return null
+ projectContainsFolder: () ->
+ for entity in @$scope.rootFolder.children
+ return true if entity.type == 'folder'
+ return false
+
existsInThisFolder: (folder, name) ->
for entity in folder?.children or []
return true if entity.name is name
diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
index 3d4077b2dd..010e00476f 100644
--- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
+++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
@@ -43,6 +43,19 @@ define [
}
)
+ $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () ->
+ unless 'project_file' in window.data.enabledLinkedFileTypes
+ console.warn("Project linked files are not enabled")
+ return
+ $modal.open(
+ templateUrl: "projectLinkedFileModalTemplate"
+ controller: "ProjectLinkedFileModalController"
+ scope: $scope
+ resolve: {
+ parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+ }
+ )
+
$scope.orderByFoldersFirst = (entity) ->
return '0' if entity?.type == "folder"
return '1'
@@ -201,6 +214,117 @@ define [
$modalInstance.dismiss('cancel')
]
+ App.controller "ProjectLinkedFileModalController", [
+ "$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+ ($scope, ide, $modalInstance, $timeout, parent_folder) ->
+ $scope.data =
+ projects: null # or []
+ selectedProjectId: null
+ projectEntities: null # or []
+ selectedProjectEntity: null
+ name: null
+ $scope.state =
+ inFlight:
+ projects: false
+ entities: false
+ create: false
+ error: false
+
+ $scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
+ return if !newVal
+ $scope.data.selectedProjectEntity = null
+ $scope.getProjectEntities($scope.data.selectedProjectId)
+
+ # auto-set filename based on selected file
+ $scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
+ return if !newVal
+ fileName = newVal.split('/').reverse()[0]
+ if fileName
+ $scope.data.name = fileName
+
+ _setInFlight = (type) ->
+ $scope.state.inFlight[type] = true
+
+ _reset = (opts) ->
+ isError = opts.err == true
+ inFlight = $scope.state.inFlight
+ inFlight.projects = inFlight.entities = inFlight.create = false
+ $scope.state.error = isError
+
+ $scope.shouldEnableProjectSelect = () ->
+ { state, data } = $scope
+ return !state.inFlight.projects && data.projects
+
+ $scope.shouldEnableProjectEntitySelect = () ->
+ { state, data } = $scope
+ return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
+
+ $scope.shouldEnableCreateButton = () ->
+ state = $scope.state
+ data = $scope.data
+ return !state.inFlight.projects &&
+ !state.inFlight.entities &&
+ data.projects &&
+ data.selectedProjectId &&
+ data.projectEntities &&
+ data.selectedProjectEntity &&
+ data.name
+
+ $scope.getUserProjects = () ->
+ _setInFlight('projects')
+ ide.$http.get("/user/projects", {
+ _csrf: window.csrfToken
+ })
+ .then (resp) ->
+ $scope.data.projectEntities = null
+ $scope.data.projects = resp.data.projects.filter (p) ->
+ p._id != ide.project_id
+ _reset(err: false)
+ .catch (err) ->
+ _reset(err: true)
+
+ $scope.getProjectEntities = (project_id) =>
+ _setInFlight('entities')
+ ide.$http.get("/project/#{project_id}/entities", {
+ _csrf: window.csrfToken
+ })
+ .then (resp) ->
+ if $scope.data.selectedProjectId == resp.data.project_id
+ $scope.data.projectEntities = resp.data.entities
+ _reset(err: false)
+ .catch (err) ->
+ _reset(err: true)
+
+ $scope.init = () ->
+ $scope.getUserProjects()
+ $timeout($scope.init, 0)
+
+ $scope.create = () ->
+ projectId = $scope.data.selectedProjectId
+ path = $scope.data.selectedProjectEntity
+ name = $scope.data.name
+ if !name || !path || !projectId
+ _reset(err: true)
+ return
+ _setInFlight('create')
+ ide.fileTreeManager
+ .createLinkedFile(name, parent_folder, 'project_file', {
+ source_project_id: projectId,
+ source_entity_path: path
+ })
+ .then () ->
+ _reset(err: false)
+ $modalInstance.close()
+ .catch (response)->
+ { data } = response
+ _reset(err: true)
+
+ $scope.cancel = () ->
+ $modalInstance.dismiss('cancel')
+
+ ]
+
+ # TODO: rename all this to UrlLinkedFilModalController
App.controller "LinkedFileModalController", [
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
($scope, ide, $modalInstance, $timeout, parent_folder) ->
diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
index 735d065cd8..0dcbac31c1 100644
--- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
+++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
@@ -1,6 +1,7 @@
define [
"base"
-], (App) ->
+ "ide/file-tree/util/iconTypeFromName"
+], (App, iconTypeFromName) ->
App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
$scope.select = (e) ->
if e.ctrlKey or e.metaKey
@@ -70,18 +71,7 @@ define [
$scope.$on "delete:selected", () ->
$scope.openDeleteModal() if $scope.entity.selected
- $scope.iconTypeFromName = (name) ->
- ext = name.split(".").pop()?.toLowerCase()
- if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
- return "image"
- else if ext in ["csv", "xls", "xlsx"]
- return "table"
- else if ext in ["py", "r"]
- return "file-text"
- else if ext in ['bib']
- return 'book'
- else
- return "file"
+ $scope.iconTypeFromName = iconTypeFromName
]
App.controller "DeleteEntityModalController", [
diff --git a/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee
new file mode 100644
index 0000000000..01c11f395a
--- /dev/null
+++ b/services/web/public/coffee/ide/file-tree/util/iconTypeFromName.coffee
@@ -0,0 +1,13 @@
+define [], () ->
+ return iconTypeFromName = (name) ->
+ ext = name.split(".").pop()?.toLowerCase()
+ if ext in ["png", "pdf", "jpg", "jpeg", "gif"]
+ return "image"
+ else if ext in ["csv", "xls", "xlsx"]
+ return "table"
+ else if ext in ["py", "r"]
+ return "file-text"
+ else if ext in ['bib']
+ return 'book'
+ else
+ return "file"
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee
index cdc39ffd05..8c77d97965 100644
--- a/services/web/public/coffee/ide/history/HistoryManager.coffee
+++ b/services/web/public/coffee/ide/history/HistoryManager.coffee
@@ -4,7 +4,6 @@ define [
"ide/history/util/displayNameForUser"
"ide/history/controllers/HistoryListController"
"ide/history/controllers/HistoryDiffController"
- "ide/history/controllers/HistoryV2DiffController"
"ide/history/directives/infiniteScroll"
], (moment, ColorManager, displayNameForUser) ->
class HistoryManager
diff --git a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee
index 72f4c79bdd..7c8f476e39 100644
--- a/services/web/public/coffee/ide/history/HistoryV2Manager.coffee
+++ b/services/web/public/coffee/ide/history/HistoryV2Manager.coffee
@@ -2,13 +2,20 @@ define [
"moment"
"ide/colors/ColorManager"
"ide/history/util/displayNameForUser"
- "ide/history/controllers/HistoryListController"
- "ide/history/controllers/HistoryDiffController"
+ "ide/history/util/HistoryViewModes"
+ "ide/history/controllers/HistoryV2ListController"
+ "ide/history/controllers/HistoryV2DiffController"
+ "ide/history/controllers/HistoryV2FileTreeController"
"ide/history/directives/infiniteScroll"
-], (moment, ColorManager, displayNameForUser) ->
+ "ide/history/components/historyEntriesList"
+ "ide/history/components/historyEntry"
+ "ide/history/components/historyFileTree"
+ "ide/history/components/historyFileEntity"
+], (moment, ColorManager, displayNameForUser, HistoryViewModes) ->
class HistoryManager
constructor: (@ide, @$scope) ->
@reset()
+ @$scope.HistoryViewModes = HistoryViewModes
@$scope.toggleHistory = () =>
if @$scope.ui.view == "history"
@@ -16,17 +23,31 @@ define [
else
@show()
- @$scope.$watch "history.selection.updates", (updates) =>
- if updates? and updates.length > 0
- @_selectDocFromUpdates()
- @reloadDiff()
+ @$scope.toggleHistoryViewMode = () =>
+ if @$scope.history.viewMode == HistoryViewModes.COMPARE
+ @reset()
+ @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
+ else
+ @reset()
+ @$scope.history.viewMode = HistoryViewModes.COMPARE
- @$scope.$watch "history.selection.pathname", () =>
- @reloadDiff()
+ @$scope.$watch "history.selection.updates", (updates) =>
+ if @$scope.history.viewMode == HistoryViewModes.COMPARE
+ if updates? and updates.length > 0
+ @_selectDocFromUpdates()
+ @reloadDiff()
+
+ @$scope.$watch "history.selection.pathname", (pathname) =>
+ if @$scope.history.viewMode == HistoryViewModes.POINT_IN_TIME
+ if pathname?
+ @loadFileAtPointInTime()
+ else
+ @reloadDiff()
show: () ->
@$scope.ui.view = "history"
@reset()
+ @$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
hide: () ->
@$scope.ui.view = "editor"
@@ -35,6 +56,7 @@ define [
@$scope.history = {
isV2: true
updates: []
+ viewMode: null
nextBeforeTimestamp: null
atEnd: false
selection: {
@@ -46,16 +68,33 @@ define [
toV: null
}
}
- diff: null
+ files: []
+ diff: null # When history.viewMode == HistoryViewModes.COMPARE
+ selectedFile: null # When history.viewMode == HistoryViewModes.POINT_IN_TIME
}
restoreFile: (version, pathname) ->
url = "/project/#{@$scope.project_id}/restore_file"
+
@ide.$http.post(url, {
version, pathname,
_csrf: window.csrfToken
})
+ loadFileTreeForUpdate: (update) ->
+ {fromV, toV} = update
+ url = "/project/#{@$scope.project_id}/filetree/diff"
+ query = [ "from=#{toV}", "to=#{toV}" ]
+ url += "?" + query.join("&")
+ @$scope.history.loadingFileTree = true
+ @$scope.history.selectedFile = null
+ @$scope.history.selection.pathname = null
+ @ide.$http
+ .get(url)
+ .then (response) =>
+ @$scope.history.files = response.data.diff
+ @$scope.history.loadingFileTree = false
+
MAX_RECENT_UPDATES_TO_SELECT: 5
autoSelectRecentUpdates: () ->
return if @$scope.history.updates.length == 0
@@ -70,12 +109,28 @@ define [
@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
+ autoSelectLastUpdate: () ->
+ return if @$scope.history.updates.length == 0
+ @selectUpdate @$scope.history.updates[0]
+
+ selectUpdate: (update) ->
+ selectedUpdateIndex = @$scope.history.updates.indexOf update
+ if selectedUpdateIndex == -1
+ selectedUpdateIndex = 0
+ for update in @$scope.history.updates
+ update.selectedTo = false
+ update.selectedFrom = false
+ @$scope.history.updates[selectedUpdateIndex].selectedTo = true
+ @$scope.history.updates[selectedUpdateIndex].selectedFrom = true
+ @loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex]
+
BATCH_SIZE: 10
fetchNextBatchOfUpdates: () ->
url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
if @$scope.history.nextBeforeTimestamp?
url += "&before=#{@$scope.history.nextBeforeTimestamp}"
@$scope.history.loading = true
+ @$scope.history.loadingFileTree = true
@ide.$http
.get(url)
.then (response) =>
@@ -86,6 +141,23 @@ define [
@$scope.history.atEnd = true
@$scope.history.loading = false
+ loadFileAtPointInTime: () ->
+ pathname = @$scope.history.selection.pathname
+ toV = @$scope.history.selection.updates[0].toV
+ url = "/project/#{@$scope.project_id}/diff"
+ query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
+ url += "?" + query.join("&")
+ @$scope.history.selectedFile =
+ loading: true
+ @ide.$http
+ .get(url)
+ .then (response) =>
+ {text, binary} = @_parseDiff(response.data.diff)
+ @$scope.history.selectedFile.binary = binary
+ @$scope.history.selectedFile.text = text
+ @$scope.history.selectedFile.loading = false
+ .catch () ->
+
reloadDiff: () ->
diff = @$scope.history.diff
{updates} = @$scope.history.selection
@@ -200,7 +272,11 @@ define [
@$scope.history.updates =
@$scope.history.updates.concat(updates)
- @autoSelectRecentUpdates() if firstLoad
+ if firstLoad
+ if @$scope.history.viewMode == HistoryViewModes.COMPARE
+ @autoSelectRecentUpdates()
+ else
+ @autoSelectLastUpdate()
_perDocSummaryOfUpdates: (updates) ->
# Track current_pathname -> original_pathname
diff --git a/services/web/public/coffee/ide/history/components/historyEntriesList.coffee b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee
new file mode 100644
index 0000000000..5022724714
--- /dev/null
+++ b/services/web/public/coffee/ide/history/components/historyEntriesList.coffee
@@ -0,0 +1,19 @@
+define [
+ "base"
+], (App) ->
+ historyEntriesListController = ($scope, $element, $attrs) ->
+ ctrl = @
+ return
+
+ App.component "historyEntriesList", {
+ bindings:
+ entries: "<"
+ loadEntries: "&"
+ loadDisabled: "<"
+ loadInitialize: "<"
+ isLoading: "<"
+ currentUser: "<"
+ onEntrySelect: "&"
+ controller: historyEntriesListController
+ templateUrl: "historyEntriesListTpl"
+ }
diff --git a/services/web/public/coffee/ide/history/components/historyEntry.coffee b/services/web/public/coffee/ide/history/components/historyEntry.coffee
new file mode 100644
index 0000000000..e2692b7dee
--- /dev/null
+++ b/services/web/public/coffee/ide/history/components/historyEntry.coffee
@@ -0,0 +1,27 @@
+define [
+ "base"
+ "ide/history/util/displayNameForUser"
+], (App, displayNameForUser) ->
+ historyEntryController = ($scope, $element, $attrs) ->
+ ctrl = @
+ ctrl.displayName = displayNameForUser
+ ctrl.getProjectOpDoc = (projectOp) ->
+ if projectOp.rename? then "#{ projectOp.rename.pathname} → #{ projectOp.rename.newPathname }"
+ else if projectOp.add? then "#{ projectOp.add.pathname}"
+ else if projectOp.remove? then "#{ projectOp.remove.pathname}"
+ ctrl.getUserCSSStyle = (user) ->
+ hue = user?.hue or 100
+ if ctrl.entry.inSelection
+ color : "#FFF"
+ else
+ color: "hsl(#{ hue }, 70%, 50%)"
+ return
+
+ App.component "historyEntry", {
+ bindings:
+ entry: "<"
+ currentUser: "<"
+ onSelect: "&"
+ controller: historyEntryController
+ templateUrl: "historyEntryTpl"
+ }
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/components/historyFileEntity.coffee b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee
new file mode 100644
index 0000000000..bf99348c62
--- /dev/null
+++ b/services/web/public/coffee/ide/history/components/historyFileEntity.coffee
@@ -0,0 +1,34 @@
+define [
+ "base"
+ "ide/file-tree/util/iconTypeFromName"
+], (App, iconTypeFromName) ->
+ # TODO Add arrows in folders
+ historyFileEntityController = ($scope, $element, $attrs) ->
+ ctrl = @
+ _handleFolderClick = () ->
+ ctrl.isOpen = !ctrl.isOpen
+ ctrl.iconClass = _getFolderIcon()
+ _handleFileClick = () ->
+ ctrl.historyFileTreeController.handleEntityClick ctrl.fileEntity
+ _getFolderIcon = () ->
+ if ctrl.isOpen then "fa-folder-open" else "fa-folder"
+ ctrl.$onInit = () ->
+ if ctrl.fileEntity.type == "folder"
+ ctrl.isOpen = true
+ ctrl.iconClass = _getFolderIcon()
+ ctrl.handleClick = _handleFolderClick
+ else
+ ctrl.iconClass = "fa-#{ iconTypeFromName(ctrl.fileEntity.name) }"
+ ctrl.handleClick = _handleFileClick
+ $scope.$watch (() -> ctrl.historyFileTreeController.selectedPathname), (newPathname) ->
+ ctrl.isSelected = ctrl.fileEntity.pathname == newPathname
+ return
+
+ App.component "historyFileEntity", {
+ require:
+ historyFileTreeController: "^historyFileTree"
+ bindings:
+ fileEntity: "<"
+ controller: historyFileEntityController
+ templateUrl: "historyFileEntityTpl"
+ }
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/components/historyFileTree.coffee b/services/web/public/coffee/ide/history/components/historyFileTree.coffee
new file mode 100644
index 0000000000..7e3c636470
--- /dev/null
+++ b/services/web/public/coffee/ide/history/components/historyFileTree.coffee
@@ -0,0 +1,18 @@
+define [
+ "base"
+], (App) ->
+ historyFileTreeController = ($scope, $element, $attrs) ->
+ ctrl = @
+ ctrl.handleEntityClick = (file) ->
+ ctrl.onSelectedFileChange file: file
+ return
+
+ App.component "historyFileTree", {
+ bindings:
+ fileTree: "<"
+ selectedPathname: "<"
+ onSelectedFileChange: "&"
+ isLoading: "<"
+ controller: historyFileTreeController
+ templateUrl: "historyFileTreeTpl"
+ }
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
index f16cace816..4b0786d259 100644
--- a/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
+++ b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
@@ -5,7 +5,7 @@ define [
App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
$scope.hoveringOverListSelectors = false
-
+
$scope.loadMore = () =>
ide.historyManager.fetchNextBatchOfUpdates()
diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
index c2ced4cf59..279c230afb 100644
--- a/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
+++ b/services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
@@ -1,7 +1,7 @@
define [
"base"
], (App) ->
- App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
+ App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) ->
$scope.restoreState =
inflight: false
error: false
@@ -24,17 +24,16 @@ define [
$scope.restoreState.inflight = false
openEntity = (data) ->
- iterations = 0
{id, type} = data
- do tryOpen = () ->
- if iterations > 5
- return
- iterations += 1
- entity = ide.fileTreeManager.findEntityById(id)
- if entity? and type == 'doc'
- ide.editorManager.openDoc(entity)
- else if entity? and type == 'file'
- ide.binaryFilesManager.openFile(entity)
- else
- setTimeout(tryOpen, 500)
-
\ No newline at end of file
+ waitFor(
+ () ->
+ ide.fileTreeManager.findEntityById(id)
+ 3000
+ )
+ .then (entity) ->
+ if type == 'doc'
+ ide.editorManager.openDoc(entity)
+ else if type == 'file'
+ ide.binaryFilesManager.openFile(entity)
+ .catch (err) ->
+ console.warn(err)
diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee
new file mode 100644
index 0000000000..47f5c07c4c
--- /dev/null
+++ b/services/web/public/coffee/ide/history/controllers/HistoryV2FileTreeController.coffee
@@ -0,0 +1,54 @@
+define [
+ "base"
+], (App) ->
+
+ App.controller "HistoryV2FileTreeController", ["$scope", "ide", "_", ($scope, ide, _) ->
+ _previouslySelectedPathname = null
+ $scope.currentFileTree = []
+
+ _pathnameExistsInFiles = (pathname, files) ->
+ _.any files, (file) -> file.pathname == pathname
+
+ _getSelectedDefaultPathname = (files) ->
+ selectedPathname = null
+ if _previouslySelectedPathname? and _pathnameExistsInFiles _previouslySelectedPathname, files
+ selectedPathname = _previouslySelectedPathname
+ else
+ mainFile = _.find files, (file) -> /main\.tex$/.test file.pathname
+ if mainFile?
+ selectedPathname = _previouslySelectedPathname = mainFile.pathname
+ else
+ selectedPathname = _previouslySelectedPathname = files[0].pathname
+ return selectedPathname
+
+ $scope.handleFileSelection = (file) ->
+ $scope.history.selection.pathname = _previouslySelectedPathname = file.pathname
+
+ $scope.$watch 'history.files', (files) ->
+ if files? and files.length > 0
+ $scope.currentFileTree = _.reduce files, _reducePathsToTree, []
+ $scope.history.selection.pathname = _getSelectedDefaultPathname(files)
+
+ _reducePathsToTree = (currentFileTree, fileObject) ->
+ filePathParts = fileObject.pathname.split "/"
+ currentFileTreeLocation = currentFileTree
+ for pathPart, index in filePathParts
+ isFile = index == filePathParts.length - 1
+ if isFile
+ fileTreeEntity =
+ name: pathPart
+ pathname: fileObject.pathname
+ type: "file"
+ operation: fileObject.operation || "edited"
+ currentFileTreeLocation.push fileTreeEntity
+ else
+ fileTreeEntity = _.find currentFileTreeLocation, (entity) => entity.name == pathPart
+ if !fileTreeEntity?
+ fileTreeEntity =
+ name: pathPart
+ type: "folder"
+ children: []
+ currentFileTreeLocation.push fileTreeEntity
+ currentFileTreeLocation = fileTreeEntity.children
+ return currentFileTree
+ ]
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee
new file mode 100644
index 0000000000..eaf7fbc884
--- /dev/null
+++ b/services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee
@@ -0,0 +1,76 @@
+define [
+ "base",
+ "ide/history/util/displayNameForUser"
+], (App, displayNameForUser) ->
+
+ App.controller "HistoryV2ListController", ["$scope", "ide", ($scope, ide) ->
+ $scope.hoveringOverListSelectors = false
+
+ $scope.loadMore = () =>
+ ide.historyManager.fetchNextBatchOfUpdates()
+
+ $scope.handleEntrySelect = (entry) ->
+ # $scope.$applyAsync () ->
+ ide.historyManager.selectUpdate(entry)
+ $scope.recalculateSelectedUpdates()
+
+ $scope.recalculateSelectedUpdates = () ->
+ beforeSelection = true
+ afterSelection = false
+ $scope.history.selection.updates = []
+ for update in $scope.history.updates
+ if update.selectedTo
+ inSelection = true
+ beforeSelection = false
+
+ update.beforeSelection = beforeSelection
+ update.inSelection = inSelection
+ update.afterSelection = afterSelection
+
+ if inSelection
+ $scope.history.selection.updates.push update
+
+ if update.selectedFrom
+ inSelection = false
+ afterSelection = true
+
+ $scope.recalculateHoveredUpdates = () ->
+ hoverSelectedFrom = false
+ hoverSelectedTo = false
+ for update in $scope.history.updates
+ # Figure out whether the to or from selector is hovered over
+ if update.hoverSelectedFrom
+ hoverSelectedFrom = true
+ if update.hoverSelectedTo
+ hoverSelectedTo = true
+
+ if hoverSelectedFrom
+ # We want to 'hover select' everything between hoverSelectedFrom and selectedTo
+ inHoverSelection = false
+ for update in $scope.history.updates
+ if update.selectedTo
+ update.hoverSelectedTo = true
+ inHoverSelection = true
+ update.inHoverSelection = inHoverSelection
+ if update.hoverSelectedFrom
+ inHoverSelection = false
+ if hoverSelectedTo
+ # We want to 'hover select' everything between hoverSelectedTo and selectedFrom
+ inHoverSelection = false
+ for update in $scope.history.updates
+ if update.hoverSelectedTo
+ inHoverSelection = true
+ update.inHoverSelection = inHoverSelection
+ if update.selectedFrom
+ update.hoverSelectedFrom = true
+ inHoverSelection = false
+
+ $scope.resetHoverState = () ->
+ for update in $scope.history.updates
+ delete update.hoverSelectedFrom
+ delete update.hoverSelectedTo
+ delete update.inHoverSelection
+
+ $scope.$watch "history.updates.length", () ->
+ $scope.recalculateSelectedUpdates()
+ ]
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee
new file mode 100644
index 0000000000..125dd87060
--- /dev/null
+++ b/services/web/public/coffee/ide/history/util/HistoryViewModes.coffee
@@ -0,0 +1,4 @@
+define [], () ->
+ HistoryViewModes =
+ POINT_IN_TIME : 'point_in_time'
+ COMPARE : 'compare'
diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
new file mode 100644
index 0000000000..ae7db45905
--- /dev/null
+++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
@@ -0,0 +1,16 @@
+define [
+ "base"
+ "ace/ace"
+], (App) ->
+ App.controller "TestControlsController", ($scope) ->
+
+ $scope.openProjectLinkedFileModal = () ->
+ window.openProjectLinkedFileModal()
+
+ $scope.openLinkedFileModal = () ->
+ window.openLinkedFileModal()
+
+ $scope.richText = () ->
+ current = window.location.toString()
+ target = "#{current}#{if window.location.search then '&' else '?'}rt=true"
+ window.location.href = target
diff --git a/services/web/public/coffee/ide/test-controls/index.coffee b/services/web/public/coffee/ide/test-controls/index.coffee
new file mode 100644
index 0000000000..d60d9e1a01
--- /dev/null
+++ b/services/web/public/coffee/ide/test-controls/index.coffee
@@ -0,0 +1,3 @@
+define [
+ "ide/test-controls/controllers/TestControlsController"
+], () ->
diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee
index 2d1dee6d34..6d3e441c49 100644
--- a/services/web/public/coffee/main/contact-us.coffee
+++ b/services/web/public/coffee/main/contact-us.coffee
@@ -1,79 +1,7 @@
define [
"base"
"libs/platform"
- "services/algolia-search"
], (App, platform) ->
- App.controller 'ContactModal', ($scope, $modal) ->
- $scope.contactUsModal = () ->
- modalInstance = $modal.open(
- templateUrl: "supportModalTemplate"
- controller: "SupportModalController"
- )
-
- App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
- $scope.form = {}
- $scope.sent = false
- $scope.sending = false
- $scope.suggestions = [];
-
- _handleSearchResults = (success, results) ->
- suggestions = for hit in results.hits
- page_underscored = hit.pageName.replace(/\s/g,'_')
-
- suggestion =
- url :"/learn/kb/#{page_underscored}"
- name : hit._highlightResult.pageName.value
-
- event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
-
- $scope.$applyAsync () ->
- $scope.suggestions = suggestions
-
- $scope.contactUs = ->
- if !$scope.form.email? or $scope.form.email == ""
- console.log "email not set"
- return
- $scope.sending = true
- ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
- message = $scope.form.message
- if $scope.form.project_url?
- message = "#{message}\n\n project_url = #{$scope.form.project_url}"
- params =
- email: $scope.form.email
- message: message or ""
- subject: $scope.form.subject + " - [#{ticketNumber}]"
- labels: "support"
- about: "