Просмотр исходного кода

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

Paulo Reis 8 лет назад
Родитель
Сommit
8e5032fb34
39 измененных файлов с 1057 добавлено и 102 удалено
  1. 16 9
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
  2. 127 0
      services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
  3. 7 1
      services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
  4. 29 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  5. 31 28
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  6. 2 0
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  7. 80 0
      services/web/app/coffee/Features/Templates/TemplatesController.coffee
  8. 8 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear
  9. 9 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee
  10. 10 0
      services/web/app/coffee/Features/Templates/TemplatesRouter.coffee
  11. 20 5
      services/web/app/coffee/infrastructure/FileWriter.coffee
  12. 8 2
      services/web/app/coffee/router.coffee
  13. 16 1
      services/web/app/views/project/editor/binary-file.pug
  14. 2 4
      services/web/app/views/project/editor/editor.pug
  15. 71 0
      services/web/app/views/project/editor/file-tree.pug
  16. 1 1
      services/web/app/views/project/editor/history/diffPanelV1.pug
  17. 18 0
      services/web/app/views/project/editor/left-menu.pug
  18. 26 0
      services/web/app/views/project/editor/new_from_template.pug
  19. 39 2
      services/web/app/views/project/list/item.pug
  20. 8 2
      services/web/app/views/project/list/project-list.pug
  21. 2 2
      services/web/app/views/project/list/v1-item.pug
  22. 2 2
      services/web/config/settings.defaults.coffee
  23. 1 0
      services/web/docker-compose.yml
  24. 3 1
      services/web/public/coffee/ide.coffee
  25. 24 7
      services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
  26. 1 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  27. 5 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  28. 124 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  29. 13 14
      services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
  30. 16 0
      services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
  31. 3 0
      services/web/public/coffee/ide/test-controls/index.coffee
  32. 33 14
      services/web/public/coffee/main/project-list/project-list.coffee
  33. 20 0
      services/web/public/coffee/services/wait-for.coffee
  34. 10 0
      services/web/public/stylesheets/app/project-list.less
  35. 6 1
      services/web/public/stylesheets/components/forms.less
  36. 120 4
      services/web/test/acceptance/coffee/LinkedFilesTests.coffee
  37. 12 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  38. 58 1
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  39. 76 0
      services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee

+ 16 - 9
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
-}
+		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
+	}

+ 127 - 0
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()

+ 7 - 1
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)}"
-}
+}

+ 29 - 0
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()
 

+ 31 - 28
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)->
+	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 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, originalProject_id, origonalFileRef }, "error coping file in s3"
+					logger.err { err, project_id, folder_id }, "error putting element as part of copy"
 					return callback(err)
-				ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+				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 }, "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}, (error) ->
-							return callback(error) if error?
-							callback null, fileRef, folder_id
+						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)->

+ 2 - 0
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 =
 

+ 80 - 0
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()

+ 8 - 0
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()

+ 9 - 0
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()

+ 10 - 0
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

+ 20 - 5
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)
+				callback(err)

+ 8 - 2
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
 
@@ -202,7 +208,7 @@ module.exports = class Router
 		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.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.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

+ 16 - 1
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}}

+ 2 - 4
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

+ 71 - 0
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

+ 1 - 1
services/web/app/views/project/editor/history/diffPanelV1.pug

@@ -13,7 +13,7 @@
 					}"
 				)
 				|  in <strong>{{history.diff.pathname}}</strong>
-			.toolbar-right
+			.toolbar-right(ng-if="permissions.write")
 				a.btn.btn-danger.btn-sm(
 					href,
 					ng-click="openRestoreDiffModal()"

+ 18 - 0
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")

+ 26 - 0
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)

+ 39 - 2
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,42 @@
 			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"
+			tooltip=translate('archive'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="archive($event)"
+		)
+			i.icon.fa.fa-inbox
+		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

+ 8 - 2
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"

+ 2 - 2
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}}

+ 2 - 2
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:

+ 1 - 0
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

+ 3 - 1
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 = () ->) ->

+ 24 - 7
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)

+ 1 - 1
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) =>

+ 5 - 0
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

+ 124 - 0
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) ->

+ 13 - 14
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)
-			
+			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)

+ 16 - 0
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

+ 3 - 0
services/web/public/coffee/ide/test-controls/index.coffee

@@ -0,0 +1,3 @@
+define [
+	"ide/test-controls/controllers/TestControlsController"
+], () ->

+ 33 - 14
services/web/public/coffee/main/project-list/project-list.coffee

@@ -350,14 +350,15 @@ define [
 				$scope.archiveOrLeaveSelectedProjects()
 
 		$scope.archiveOrLeaveSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.archiveOrLeaveProjects($scope.getSelectedProjects())
 
+		$scope.archiveOrLeaveProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
 			# Remove project from any tags
 			for tag in $scope.tags
-				$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
+				$scope._removeProjectIdsFromTagArray(tag, projectIds)
 
-			for project in selected_projects
+			for project in projects
 				project.tags = []
 				if project.accessLevel == "owner"
 					project.archived = true
@@ -414,16 +415,17 @@ define [
 			$scope.updateVisibleProjects()
 
 		$scope.restoreSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.restoreProjects($scope.getSelectedProjects())
 
-			for project in selected_projects
+		$scope.restoreProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
+			for project in projects
 				project.archived = false
 
-			for project_id in selected_project_ids
+			for projectId in projectIds
 				queuedHttp {
 					method: "POST"
-					url: "/project/#{project_id}/restore"
+					url: "/project/#{projectId}/restore"
 					headers:
 						"X-CSRF-Token": window.csrfToken
 				}
@@ -437,13 +439,14 @@ define [
 			)
 
 		$scope.downloadSelectedProjects = () ->
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.downloadProjectsById($scope.getSelectedProjectIds())
+
+		$scope.downloadProjectsById = (projectIds) ->
 			event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
-			if selected_project_ids.length > 1
-				path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}"
+			if projectIds.length > 1
+				path = "/project/download/zip?project_ids=#{projectIds.join(',')}"
 			else
-				path = "/project/#{selected_project_ids[0]}/download/zip"
-
+				path = "/project/#{projectIds[0]}/download/zip"
 			window.location = path
 
 		$scope.openV1ImportModal = (project) ->
@@ -490,3 +493,19 @@ define [
 		$scope.$watch "project.selected", (value) ->
 			if value?
 				$scope.updateSelectedProjects()
+
+		$scope.clone = (e) ->
+			e.stopPropagation()
+			$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
+
+		$scope.download = (e) ->
+			e.stopPropagation()
+			$scope.downloadProjectsById([$scope.project.id])
+
+		$scope.archive = (e) ->
+			e.stopPropagation()
+			$scope.archiveOrLeaveProjects([$scope.project])
+
+		$scope.restore = (e) ->
+			e.stopPropagation()
+			$scope.restoreProjects([$scope.project])

+ 20 - 0
services/web/public/coffee/services/wait-for.coffee

@@ -0,0 +1,20 @@
+define [
+	"base"
+], (App) ->
+	App.factory "waitFor", ($q) ->
+		waitFor = (testFunction, timeout, pollInterval=500) ->
+			iterationLimit = Math.floor(timeout / pollInterval)
+			iterations = 0
+			$q(
+				(resolve, reject) ->
+					do tryIteration = () ->
+						if iterations > iterationLimit
+							return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}"))
+						iterations += 1
+						result = testFunction()
+						if result?
+							resolve(result)
+						else
+							setTimeout(tryIteration, pollInterval)
+			)
+		return waitFor

+ 10 - 0
services/web/public/stylesheets/app/project-list.less

@@ -369,6 +369,16 @@ ul.project-list {
 		.v1-badge {
 			margin-left: -4px;
 		}
+
+		.action-btn-row-header, .action-btn-row {
+			padding-right: 20px;
+			text-align: right;
+		}
+
+		.action-btn {
+			padding: 0 0.3em;
+			margin-left: 0.2em;
+		}
 	}
 	i.tablesort {
 		padding-left: 8px;

+ 6 - 1
services/web/public/stylesheets/components/forms.less

@@ -145,10 +145,15 @@ output {
     opacity: 1; // iOS fix for unreadable disabled content
   }
 
-  // Reset height for `textarea`s
+  // Reset height for `textarea`s, and smaller border-radius
   textarea& {
     height: auto;
+    border-radius: @border-radius-base;
   }
+	// Smaller border-radius for `select` inputs
+	select& {
+    border-radius: @border-radius-base;
+	}
 }
 
 

+ 120 - 4
services/web/test/acceptance/coffee/LinkedFilesTests.coffee

@@ -27,6 +27,122 @@ describe "LinkedFiles", ->
 			@owner.login ->
 				mkdirp Settings.path.dumpFolder, done
 
+	describe "creating a project linked file", ->
+		before (done) ->
+			@source_doc_name = 'test.txt'
+			async.series [
+				(cb) =>
+					@owner.createProject 'plf-test-one', {template: 'blank'}, (error, project_id) =>
+						@project_one_id = project_id
+						cb(error)
+				(cb) =>
+					@owner.getProject @project_one_id, (error, project) =>
+						@project_one = project
+						@project_one_root_folder_id = project.rootFolder[0]._id.toString()
+						cb(error)
+				(cb) =>
+					@owner.createProject 'plf-test-two', {template: 'blank'}, (error, project_id) =>
+						@project_two_id = project_id
+						cb(error)
+				(cb) =>
+					@owner.getProject @project_two_id, (error, project) =>
+						@project_two = project
+						@project_two_root_folder_id = project.rootFolder[0]._id.toString()
+						cb(error)
+				(cb) =>
+					@owner.createDocInProject @project_two_id,
+						@project_two_root_folder_id,
+						@source_doc_name,
+						(error, doc_id) =>
+							@source_doc_id = doc_id
+							cb(error)
+				(cb) =>
+					@owner.createDocInProject @project_two_id,
+						@project_two_root_folder_id,
+						'some-harmless-doc.txt',
+						(error, doc_id) =>
+							cb(error)
+			], done
+
+		it 'should produce a list of the users projects', (done) ->
+			@owner.request.get {
+				url: "/user/projects",
+				json: true
+			}, (err, response, body) =>
+				expect(err).to.not.exist
+				expect(body).to.deep.equal {
+					projects: [
+						{ _id: @project_one_id, name: 'plf-test-one', accessLevel: 'owner' },
+						{ _id: @project_two_id, name: 'plf-test-two', accessLevel: 'owner' }
+					]
+				}
+				done()
+
+		it 'should produce a list of entities in the project', (done) ->
+			@owner.request.get {
+				url: "/project/#{@project_two_id}/entities",
+				json: true
+			}, (err, response, body) =>
+				expect(err).to.not.exist
+				expect(body).to.deep.equal {
+					project_id: @project_two_id,
+					entities: [
+						{ path: '/main.tex',              type: 'doc' },
+						{ path: '/some-harmless-doc.txt', type: 'doc' },
+						{ path: '/test.txt',              type: 'doc' }
+					]
+				}
+				done()
+
+
+		it 'should import a file from the source project', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test-link.txt',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_file',
+					data:
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+			}, (error, response, body) =>
+				new_file_id = body.new_file_id
+				@existing_file_id = new_file_id
+				expect(new_file_id).to.exist
+				@owner.getProject @project_one_id, (error, project) =>
+					return done(error) if error?
+					firstFile = project.rootFolder[0].fileRefs[0]
+					expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+					expect(firstFile.linkedFileData).to.deep.equal {
+						provider: 'project_file',
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+						source_project_display_name: "plf-test-two"
+					}
+					expect(firstFile.name).to.equal('test-link.txt')
+					done()
+
+		it 'should refresh the file', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test-link.txt',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_file',
+					data:
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+			}, (error, response, body) =>
+				new_file_id = body.new_file_id
+				expect(new_file_id).to.exist
+				expect(new_file_id).to.not.equal @existing_file_id
+				@owner.getProject @project_one_id, (error, project) =>
+					return done(error) if error?
+					firstFile = project.rootFolder[0].fileRefs[0]
+					expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+					expect(firstFile.name).to.equal('test-link.txt')
+					done()
+
 	describe "creating a URL based linked file", ->
 		before (done) ->
 			@owner.createProject "url-linked-files-project", {template: "blank"}, (error, project_id) =>
@@ -50,7 +166,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-1'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.getProject @project_id, (error, project) =>
 					throw error if error?
 					file = project.rootFolder[0].fileRefs[0]
@@ -76,7 +192,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-2'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.request.post {
 					url: "/project/#{@project_id}/linked_file",
 					json:
@@ -88,7 +204,7 @@ describe "LinkedFiles", ->
 						name: 'url-test-file-2'
 				}, (error, response, body) =>
 					throw error if error?
-					expect(response.statusCode).to.equal 204
+					expect(response.statusCode).to.equal 200
 					@owner.getProject @project_id, (error, project) =>
 						throw error if error?
 						file = project.rootFolder[0].fileRefs[1]
@@ -168,7 +284,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-6'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.getProject @project_id, (error, project) =>
 					throw error if error?
 					file = _.find project.rootFolder[0].fileRefs, (file) ->

+ 12 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -143,6 +143,18 @@ class User
 				return callback(err)
 			callback(null)
 
+	createDocInProject: (project_id, parent_folder_id, name, callback=(error, doc_id)->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: "/project/#{project_id}/doc",
+				json: {
+					name: name,
+					parent_folder_id: parent_folder_id
+				}
+			}, (error, response, body) =>
+				callback(null, body._id)
+
 	addUserToProject: (project_id, user, privileges, callback = (error, user) ->) ->
 		if privileges == 'readAndWrite'
 			updateOp = {$addToSet: {collaberator_refs: user._id.toString()}}

+ 58 - 1
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -67,6 +67,7 @@ describe "ProjectController", ->
 			protectTokens: sinon.stub()
 		@CollaboratorsHandler =
 			userIsTokenMember: sinon.stub().callsArgWith(2, null, false)
+		@ProjectEntityHandler = {}
 		@Modules =
 			hooks:
 				fire: sinon.stub()
@@ -98,6 +99,7 @@ describe "ProjectController", ->
 			"../TokenAccess/TokenAccessHandler": @TokenAccessHandler
 			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
 			"../../infrastructure/Modules": @Modules
+			"./ProjectEntityHandler": @ProjectEntityHandler
 
 		@projectName = "£12321jkj9ujkljds"
 		@req =
@@ -520,7 +522,62 @@ describe "ProjectController", ->
 				@ProjectUpdateHandler.markAsOpened.calledWith(@project_id).should.equal true
 				done()
 			@ProjectController.loadEditor @req, @res
-		
+
+	describe 'userProjectsJson', ->
+		beforeEach (done) ->
+			projects = [
+				{archived: true,  id: 'a', name: 'A', accessLevel: 'a', somethingElse: 1}
+				{archived: false, id: 'b', name: 'B', accessLevel: 'b', somethingElse: 1}
+				{archived: false, id: 'c', name: 'C', accessLevel: 'c', somethingElse: 1}
+				{archived: false, id: 'd', name: 'D', accessLevel: 'd', somethingElse: 1}
+			]
+			@ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, [])
+			@ProjectController._buildProjectList = sinon.stub().returns(projects)
+			@AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc'
+			done()
+
+		it 'should produce a list of projects', (done) ->
+			@res.json = (data) =>
+				expect(data).to.deep.equal {
+					projects: [
+						{_id: 'b', name: 'B', accessLevel: 'b'},
+						{_id: 'c', name: 'C', accessLevel: 'c'},
+						{_id: 'd', name: 'D', accessLevel: 'd'}
+					]
+				}
+				done()
+			@ProjectController.userProjectsJson @req, @res, @next
+
+	describe 'projectEntitiesJson', ->
+		beforeEach () ->
+			@AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc'
+			@req.params = {Project_id: 'abcd'}
+			@project = { _id: 'abcd' }
+			@docs = [
+				{path: '/things/b.txt', doc: true},
+				{path: '/main.tex', doc: true}
+			]
+			@files = [
+				{path: '/things/a.txt'}
+			]
+			@ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project)
+			@ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files)
+
+		it 'should produce a list of entities', (done) ->
+			@res.json = (data) =>
+				expect(data).to.deep.equal {
+					project_id: 'abcd',
+					entities: [
+						{path: '/main.tex',     type: 'doc'},
+						{path: '/things/a.txt', type: 'file'},
+						{path: '/things/b.txt', type: 'doc'}
+					]
+				}
+				expect(@ProjectGetter.getProject.callCount).to.equal 1
+				expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1
+				done()
+			@ProjectController.projectEntitiesJson @req, @res, @next
+
 	describe '_isInPercentageRollout', ->
 		before ->
 			@ids = [

+ 76 - 0
services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee

@@ -0,0 +1,76 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = '../../../../app/js/Features/Templates/TemplatesController'
+
+
+describe 'TemplatesController', ->
+
+	project_id = "213432"
+
+	beforeEach ->
+		@request = sinon.stub()
+		@request.returns {
+			pipe:->
+			on:->
+		}
+		@fs = {
+			unlink : sinon.stub()
+			createWriteStream : sinon.stub().returns(on:(_, cb)->cb())
+		}
+		@ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})}
+		@dumpFolder = "dump/path"
+		@ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)}
+		@uuid = "1234"
+		@ProjectDetailsHandler =
+			getProjectDescription:sinon.stub()
+		@Project =
+			update: sinon.stub().callsArgWith(3, null)
+		@controller = SandboxedModule.require modulePath, requires:
+			'../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager
+			'../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler
+			'../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()}
+			'./TemplatesPublisher':@TemplatesPublisher
+			"logger-sharelatex":
+				log:->
+				err:->
+			"settings-sharelatex":
+				path:
+					dumpFolder:@dumpFolder
+				siteUrl: @siteUrl = "http://localhost:3000"
+				apis:
+					v1:
+						url: @v1Url="http://overleaf.com"
+						user: "sharelatex"
+						pass: "password"
+				overleaf:
+					host: @v1Url
+			"uuid":v4:=>@uuid
+			"request": @request
+			"fs":@fs
+			"../../../../app/js/models/Project": {Project: @Project}
+		@zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex"
+		@templateName = "project name here"
+		@user_id = "1234"
+		@req =
+			session:
+				user: _id:@user_id
+				templateData:
+					zipUrl: @zipUrl
+					templateName: @templateName
+		@redirect = {}
+		@AuthenticationController.getLoggedInUserId.returns(@user_id)
+
+	describe 'v1Templates', ->
+
+		it "should fetch zip from v1 based on template id", (done)->
+			@templateVersionId = 15
+			@req.body = {templateVersionId: @templateVersionId}
+
+			redirect = =>
+				@request.calledWith("#{@v1Url}/api/v1/sharelatex/templates/#{@templateVersionId}").should.equal true
+				done()
+			res = redirect:redirect
+			@controller.createProjectFromV1Template @req, res