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

Merge pull request #655 from sharelatex/sk-linked-files-output-redux

Linked files from project output
Shane Kilkelly 8 лет назад
Родитель
Сommit
f6424ada40
21 измененных файлов с 1149 добавлено и 573 удалено
  1. 8 0
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  2. 72 51
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
  3. 84 0
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee
  4. 86 0
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee
  5. 84 127
      services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
  6. 158 0
      services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee
  7. 33 47
      services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
  8. 4 1
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  9. 4 1
      services/web/app/coffee/infrastructure/FileWriter.coffee
  10. 21 3
      services/web/app/views/project/editor/binary-file.pug
  11. 7 221
      services/web/app/views/project/editor/file-tree.pug
  12. 0 8
      services/web/app/views/project/editor/left-menu.pug
  13. 217 0
      services/web/app/views/project/editor/new-file-modal.pug
  14. 1 1
      services/web/docker-compose.yml
  15. 172 98
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  16. 0 6
      services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
  17. 40 0
      services/web/public/stylesheets/app/editor/file-tree.less
  18. 8 4
      services/web/public/stylesheets/components/fineupload.less
  19. 105 2
      services/web/test/acceptance/coffee/LinkedFilesTests.coffee
  20. 12 1
      services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee
  21. 33 2
      services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

+ 8 - 0
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -182,6 +182,14 @@ module.exports = ClsiManager =
 				return callback(error) if error?
 				callback(null, projectStateHash, docs)
 
+	getOutputFileStream: (project_id, user_id, build_id, output_file_path, callback=(err, readStream)->) ->
+		url = "#{Settings.apis.clsi.url}/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{output_file_path}"
+		ClsiCookieManager.getCookieJar project_id, (err, jar)->
+			return callback(err) if err?
+			options = { url: url, method: "GET", timeout: 60 * 1000, jar : jar }
+			readStream = request(options)
+			callback(null, readStream)
+
 	_buildRequestFromDocupdater: (project_id, options, project, projectStateHash, docUpdaterDocs, callback = (error, request) ->) ->
 		ProjectEntityHandler.getAllDocPathsFromProject project, (error, docPath) ->
 				return callback(error) if error?

+ 72 - 51
services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee

@@ -4,11 +4,27 @@ ProjectLocator = require '../Project/ProjectLocator'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
 _ = require 'underscore'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+
+	UrlFetchFailedError,
+	InvalidUrlError,
+	OutputFileFetchFailedError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	BadDataError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	SourceFileNotFoundError,
+} = require './LinkedFilesErrors'
+
 
 module.exports = LinkedFilesController = {
+
 	Agents: {
 		url: require('./UrlAgent'),
-		project_file: require('./ProjectFileAgent')
+		project_file: require('./ProjectFileAgent'),
+		project_output_file: require('./ProjectOutputFileAgent')
 	}
 
 	_getAgent: (provider) ->
@@ -18,15 +34,6 @@ module.exports = LinkedFilesController = {
 			return null
 		LinkedFilesController.Agents[provider]
 
-	_getFileById: (project_id, file_id, callback=(err, file)->) ->
-		ProjectLocator.findElement {
-			project_id,
-			element_id: file_id,
-			type: 'file'
-		}, (err, file, path, parentFolder) ->
-			return callback(err) if err?
-			callback(null, file, path, parentFolder)
-
 	createLinkedFile: (req, res, next) ->
 		{project_id} = req.params
 		{name, provider, data, parent_folder_id} = req.body
@@ -37,23 +44,23 @@ module.exports = LinkedFilesController = {
 		if !Agent?
 			return res.sendStatus(400)
 
-		linkedFileData = Agent.sanitizeData(data)
-		linkedFileData.provider = provider
-
-		if !Agent.canCreate(linkedFileData)
-			return res.status(403).send('Cannot create linked file')
+		data.provider = provider
 
-		LinkedFilesController._doImport(
-			req, res, next, Agent, project_id, user_id,
-			parent_folder_id, name, linkedFileData
-		)
+		Agent.createLinkedFile project_id,
+			data,
+			name,
+			parent_folder_id,
+			user_id,
+			(err, newFileId) ->
+				return LinkedFilesController.handleError(err, req, res, next) if err?
+				res.json(new_file_id: newFileId)
 
 	refreshLinkedFile: (req, res, next) ->
 		{project_id, file_id} = req.params
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {project_id, file_id, user_id}, 'refresh linked file request'
 
-		LinkedFilesController._getFileById project_id, file_id, (err, file, path, parentFolder) ->
+		LinkedFilesHandler.getFileById project_id, file_id, (err, file, path, parentFolder) ->
 			return next(err) if err?
 			return res.sendStatus(404) if !file?
 			name = file.name
@@ -65,37 +72,51 @@ module.exports = LinkedFilesController = {
 			Agent = LinkedFilesController._getAgent(provider)
 			if !Agent?
 				return res.sendStatus(400)
-			LinkedFilesController._doImport(
-				req, res, next, Agent, project_id, user_id,
-				parent_folder_id, name, linkedFileData
+
+			Agent.refreshLinkedFile project_id,
+				linkedFileData,
+				name,
+				parent_folder_id,
+				user_id,
+				(err, newFileId) ->
+					return LinkedFilesController.handleError(err, req, res, next) if err?
+					res.json(new_file_id: newFileId)
+
+	handleError: (error, req, res, next) ->
+		if error instanceof BadDataError
+			res.status(400).send("The submitted data is not valid")
+
+		else 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 if error instanceof V1ProjectNotFoundError
+			res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
+
+		else if error instanceof OutputFileFetchFailedError
+			res.status(404).send("Could not get output file")
+
+		else if error instanceof UrlFetchFailedError
+			res.status(422).send(
+				"Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
 			)
 
-	_doImport: (req, res, next, Agent, project_id, user_id, parent_folder_id, name, linkedFileData) ->
-		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
+		else if error instanceof InvalidUrlError
+			res.status(422).send(
+				"Your URL is not valid. Please check it and try again."
+			)
 
-	}
+		else
+			next(error)
+}

+ 84 - 0
services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee

@@ -0,0 +1,84 @@
+UrlFetchFailedError = (message) ->
+	error = new Error(message)
+	error.name = 'UrlFetchFailedError'
+	error.__proto__ = UrlFetchFailedError.prototype
+	return error
+UrlFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+InvalidUrlError = (message) ->
+	error = new Error(message)
+	error.name = 'InvalidUrlError'
+	error.__proto__ = InvalidUrlError.prototype
+	return error
+InvalidUrlError.prototype.__proto__ = Error.prototype
+
+
+OutputFileFetchFailedError = (message) ->
+	error = new Error(message)
+	error.name = 'OutputFileFetchFailedError'
+	error.__proto__ = OutputFileFetchFailedError.prototype
+	return error
+OutputFileFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+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
+
+
+V1ProjectNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'V1ProjectNotFound'
+	error.__proto__ = V1ProjectNotFoundError.prototype
+	return error
+V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'SourceFileNotFound'
+	error.__proto__ = SourceFileNotFoundError.prototype
+	return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = {
+
+	UrlFetchFailedError,
+	InvalidUrlError,
+	OutputFileFetchFailedError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	BadDataError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	SourceFileNotFoundError,
+}

+ 86 - 0
services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee

@@ -0,0 +1,86 @@
+FileWriter = require '../../infrastructure/FileWriter'
+EditorController = require '../Editor/EditorController'
+ProjectLocator = require '../Project/ProjectLocator'
+Project = require("../../models/Project").Project
+ProjectGetter = require("../Project/ProjectGetter")
+_ = require 'underscore'
+{
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	BadDataError
+} = require './LinkedFilesErrors'
+
+
+module.exports = LinkedFilesHandler =
+
+	getFileById: (project_id, file_id, callback=(err, file)->) ->
+		ProjectLocator.findElement {
+			project_id,
+			element_id: file_id,
+			type: 'file'
+		}, (err, file, path, parentFolder) ->
+			return callback(err) if err?
+			callback(null, file, path, parentFolder)
+
+	getSourceProject: (data, callback=(err, project)->) ->
+		projection = {_id: 1, name: 1}
+		if data.v1_source_doc_id?
+			Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
+				return callback(err) if err?
+				if !project?
+					return callback(new V1ProjectNotFoundError())
+				callback(null, project)
+		else if data.source_project_id?
+			ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
+				return callback(err) if err?
+				if !project?
+					return callback(new ProjectNotFoundError())
+				callback(null, project)
+		else
+			callback(new BadDataError('neither v1 nor v2 id present'))
+
+	importFromStream: (
+		project_id,
+		readStream,
+		linkedFileData,
+		name,
+		parent_folder_id,
+		user_id,
+		callback=(err, file)->
+	) ->
+		callback = _.once(callback)
+		FileWriter.writeStreamToDisk project_id, readStream, (err, fsPath) ->
+			return callback(err) if err?
+			EditorController.upsertFile project_id,
+				parent_folder_id,
+				name,
+				fsPath,
+				linkedFileData,
+				"upload",
+				user_id,
+				(err, file) =>
+					return callback(err) if err?
+					callback(null, file)
+
+	importContent: (
+		project_id,
+		content,
+		linkedFileData,
+		name,
+		parent_folder_id,
+		user_id,
+		callback=(err, file)->
+	) ->
+		callback = _.once(callback)
+		FileWriter.writeContentToDisk project_id, content, (err, fsPath) ->
+			return callback(err) if err?
+			EditorController.upsertFile project_id,
+				parent_folder_id,
+				name,
+				fsPath,
+				linkedFileData,
+				"upload",
+				user_id,
+				(err, file) =>
+					return callback(err) if err?
+					callback(null, file)

+ 84 - 127
services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee

@@ -1,68 +1,94 @@
-FileWriter = require('../../infrastructure/FileWriter')
 AuthorizationManager = require('../Authorization/AuthorizationManager')
 ProjectLocator = require('../Project/ProjectLocator')
 ProjectGetter = require('../Project/ProjectGetter')
-Project = require("../../models/Project").Project
 DocstoreManager = require('../Docstore/DocstoreManager')
 FileStoreHandler = require('../FileStore/FileStoreHandler')
-FileWriter = require('../../infrastructure/FileWriter')
 _ = require "underscore"
 Settings = require 'settings-sharelatex'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+	BadDataError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	SourceFileNotFoundError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError
+} = require './LinkedFilesErrors'
+
+module.exports = ProjectFileAgent = {
+
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		if !@_canCreate(linkedFileData)
+			return callback(new AccessDeniedError())
+		@_go(project_id, linkedFileData, name, parent_folder_id, user_id, callback)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@_go project_id, linkedFileData, name, parent_folder_id, user_id, callback
+
+	_prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+		@_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+			return callback(err) if err?
+			return callback(new AccessDeniedError()) if !allowed
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			callback(null, linkedFileData)
 
+	_go: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		linkedFileData = @_sanitizeData(linkedFileData)
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			@_getEntity linkedFileData, user_id, (err, source_project, entity, type) =>
+				return callback(err) if err?
+				if type == 'doc'
+					DocstoreManager.getDoc source_project._id, entity._id, (err, lines) ->
+						return callback(err) if err?
+						LinkedFilesHandler.importContent project_id,
+							lines.join('\n'),
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+				else if type == 'file'
+					FileStoreHandler.getFileStream source_project._id, entity._id, null, (err, fileStream) ->
+						return callback(err) if err?
+						LinkedFilesHandler.importFromStream project_id,
+							fileStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+				else
+					callback(new BadEntityTypeError())
+
+	_getEntity:
+		(linkedFileData, current_user_id, callback = (err, entity, type) ->) ->
+			callback = _.once(callback)
+			{ source_entity_path } = linkedFileData
+			@_getSourceProject linkedFileData, (err, project) ->
+				return callback(err) if err?
+				source_project_id = project._id
+				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)
+					callback(null, project, entity, type)
 
-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
-
-
-V1ProjectNotFoundError = (message) ->
-	error = new Error(message)
-	error.name = 'V1ProjectNotFound'
-	error.__proto__ = V1ProjectNotFoundError.prototype
-	return error
-V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
-
-
-SourceFileNotFoundError = (message) ->
-	error = new Error(message)
-	error.name = 'SourceFileNotFound'
-	error.__proto__ = SourceFileNotFoundError.prototype
-	return error
-SourceFileNotFoundError.prototype.__proto__ = Error.prototype
-
-
-module.exports = ProjectFileAgent =
-
-	sanitizeData: (data) ->
+	_sanitizeData: (data) ->
 		return _.pick(
 			data,
+			'provider',
 			'source_project_id',
 			'v1_source_doc_id',
 			'source_entity_path'
@@ -74,34 +100,13 @@ module.exports = ProjectFileAgent =
 			data.source_entity_path?
 		)
 
-	canCreate: (data) ->
+	_canCreate: (data) ->
 		# Don't allow creation of linked-files with v1 doc ids
 		!data.v1_source_doc_id?
 
-	_getSourceProject: (data, callback=(err, project)->) ->
-		projection = {_id: 1, name: 1}
-		if data.v1_source_doc_id?
-			Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
-				return callback(err) if err?
-				if !project?
-					return callback(new V1ProjectNotFoundError())
-				callback(null, project)
-		else if data.source_project_id?
-			ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
-				return callback(err) if err?
-				if !project?
-					return callback(new ProjectNotFoundError())
-				callback(null, project)
-		else
-			callback(new BadDataError('neither v1 nor v2 id present'))
+	_getSourceProject: LinkedFilesHandler.getSourceProject
 
-	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
-		callback = _.once(callback)
-		@_getSourceProject data, (err, project) ->
-			return callback(err) if err?
-			callback(err, _.extend(data, {source_project_display_name: project.name}))
-
-	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+	_checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
 		callback = _.once(callback)
 		if !ProjectFileAgent._validate(data)
 			return callback(new BadDataError())
@@ -110,52 +115,4 @@ module.exports = ProjectFileAgent =
 			AuthorizationManager.canUserReadProject current_user_id, 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_entity_path } = data
-			@_getSourceProject data, (err, project) ->
-				return callback(err) if err?
-				source_project_id = project._id
-				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 if error instanceof V1ProjectNotFoundError
-			res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
-		else
-			next(error)
-		next()
+}

+ 158 - 0
services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee

@@ -0,0 +1,158 @@
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectGetter = require('../Project/ProjectGetter')
+Settings = require 'settings-sharelatex'
+CompileManager = require '../Compile/CompileManager'
+ClsiManager = require '../Compile/ClsiManager'
+ProjectFileAgent = require './ProjectFileAgent'
+_ = require "underscore"
+{
+	BadDataError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	OutputFileFetchFailedError
+} = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
+logger = require 'logger-sharelatex'
+
+
+module.exports = ProjectOutputFileAgent = {
+
+	_prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+		@_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+			return callback(err) if err?
+			return callback(new AccessDeniedError()) if !allowed
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			callback(null, linkedFileData)
+
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		if !@_canCreate(linkedFileData)
+			return callback(new AccessDeniedError())
+		linkedFileData = @_sanitizeData(linkedFileData)
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			@_getFileStream linkedFileData, user_id, (err, readStream) =>
+				return callback(err) if err?
+				readStream.on "error", callback
+				readStream.on "response", (response) =>
+					if 200 <= response.statusCode < 300
+						readStream.resume()
+						LinkedFilesHandler.importFromStream project_id,
+							readStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+					else
+						err = new OutputFileFetchFailedError(
+							"Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+						)
+						err.statusCode = response.statusCode
+						callback(err)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			@_compileAndGetFileStream linkedFileData, user_id, (err, readStream, new_build_id) =>
+				return callback(err) if err?
+				readStream.on "error", callback
+				readStream.on "response", (response) =>
+					if 200 <= response.statusCode < 300
+						readStream.resume()
+						linkedFileData.build_id = new_build_id
+						LinkedFilesHandler.importFromStream project_id,
+							readStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+					else
+						err = new OutputFileFetchFailedError(
+							"Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+						)
+						err.statusCode = response.statusCode
+						callback(err)
+
+
+	_sanitizeData: (data) ->
+		return {
+			provider: data.provider,
+			source_project_id: data.source_project_id,
+			source_output_file_path: data.source_output_file_path,
+			build_id: data.build_id
+		}
+
+	_canCreate: ProjectFileAgent._canCreate
+
+	_getSourceProject: LinkedFilesHandler.getSourceProject
+
+	_validate: (data) ->
+		return (
+			(data.source_project_id? || data.v1_source_doc_id?) &&
+			data.source_output_file_path? &&
+			data.build_id?
+		)
+
+	_checkAuth: (project_id, data, current_user_id, callback = (err, allowed)->) ->
+		callback = _.once(callback)
+		if !@_validate(data)
+			return callback(new BadDataError())
+		@_getSourceProject data, (err, project) ->
+			return callback(err) if err?
+			AuthorizationManager.canUserReadProject current_user_id,
+				project._id,
+				null,
+				(err, canRead) ->
+					return callback(err) if err?
+					callback(null, canRead)
+
+	_getFileStream: (linkedFileData, user_id, callback=(err, fileStream)->) ->
+		callback = _.once(callback)
+		{ source_output_file_path, build_id } = linkedFileData
+		@_getSourceProject linkedFileData, (err, project) ->
+			return callback(err) if err?
+			source_project_id = project._id
+			ClsiManager.getOutputFileStream source_project_id,
+				user_id,
+				build_id,
+				source_output_file_path,
+				(err, readStream) ->
+					return callback(err) if err?
+					readStream.pause()
+					callback(null, readStream)
+
+	_compileAndGetFileStream: (linkedFileData, user_id, callback=(err, stream, build_id)->) ->
+		callback = _.once(callback)
+		{ source_output_file_path } = linkedFileData
+		@_getSourceProject linkedFileData, (err, project) ->
+			return callback(err) if err?
+			source_project_id = project._id
+			CompileManager.compile source_project_id,
+				user_id,
+				{},
+				(err, status, outputFiles) ->
+					return callback(err) if err?
+					if status != 'success'
+						return callback(new OutputFileFetchFailedError())
+					outputFile = _.find(
+						outputFiles,
+						(o) => o.path == source_output_file_path
+					)
+					if !outputFile?
+						return callback(new OutputFileFetchFailedError())
+					build_id = outputFile.build
+					ClsiManager.getOutputFileStream source_project_id,
+						user_id,
+						build_id,
+						source_output_file_path,
+						(err, readStream) ->
+							return callback(err) if err?
+							readStream.pause()
+							callback(null, readStream, build_id)
+}

+ 33 - 47
services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee

@@ -1,67 +1,53 @@
 request = require 'request'
-FileWriter = require('../../infrastructure/FileWriter')
 _ = require "underscore"
 urlValidator = require 'valid-url'
 Settings = require 'settings-sharelatex'
+{ InvalidUrlError, UrlFetchFailedError } = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
 
-UrlFetchFailedError = (message) ->
-	error = new Error(message)
-	error.name = 'UrlFetchFailedError'
-	error.__proto__ = UrlFetchFailedError.prototype
-	return error
-UrlFetchFailedError.prototype.__proto__ = Error.prototype
-
-InvalidUrlError = (message) ->
-	error = new Error(message)
-	error.name = 'InvalidUrlError'
-	error.__proto__ = InvalidUrlError.prototype
-	return error
-InvalidUrlError.prototype.__proto__ = Error.prototype
 
 module.exports = UrlAgent = {
-	UrlFetchFailedError: UrlFetchFailedError
-	InvalidUrlError: InvalidUrlError
 
-	sanitizeData: (data) ->
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		linkedFileData = @._sanitizeData(linkedFileData)
+		@_getUrlStream project_id, linkedFileData, user_id, (err, readStream) ->
+			return callback(err) if err?
+			readStream.on "error", callback
+			readStream.on "response", (response) ->
+				if 200 <= response.statusCode < 300
+					readStream.resume()
+					LinkedFilesHandler.importFromStream project_id,
+						readStream,
+						linkedFileData,
+						name,
+						parent_folder_id,
+						user_id,
+						(err, file) ->
+							return callback(err) if err?
+							callback(null, file._id) # Created
+				else
+					error = new UrlFetchFailedError("url fetch failed: #{linkedFileData.url}")
+					error.statusCode = response.statusCode
+					callback(error)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@createLinkedFile project_id, linkedFileData, name, parent_folder_id, user_id, callback
+
+	_sanitizeData: (data) ->
 		return {
+			provider: data.provider
 			url: @._prependHttpIfNeeded(data.url)
 		}
 
-	canCreate: (data) -> true
-
-	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) ->) ->
+	_getUrlStream: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		url = data.url
 		if !urlValidator.isWebUri(url)
 			return callback(new InvalidUrlError("invalid url: #{url}"))
-		url = UrlAgent._wrapWithProxy(url)
+		url = @_wrapWithProxy(url)
 		readStream = request.get(url)
-		readStream.on "error", callback
-		readStream.on "response", (response) ->
-			if 200 <= response.statusCode < 300
-				FileWriter.writeStreamToDisk project_id, readStream, callback
-			else
-				error = new UrlFetchFailedError("url fetch failed: #{url}")
-				error.statusCode = response.statusCode
-				callback(error)
-
-	handleError: (error, req, res, next) ->
-		if error instanceof UrlFetchFailedError
-			res.status(422).send(
-				"Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
-			)
-		else if error instanceof InvalidUrlError
-			res.status(422).send(
-				"Your URL is not valid. Please check it and try again."
-			)
-		else
-			next(error)
+		readStream.pause()
+		callback(null, readStream)
 
 	_prependHttpIfNeeded: (url) ->
 		if !url.match('://')

+ 4 - 1
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -55,7 +55,10 @@ module.exports = ProjectEntityUpdateHandler = self =
 						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)
+					fileProperties = name : SafePath.clean(origonalFileRef.name)
+					if origonalFileRef.linkedFileData?
+						fileProperties.linkedFileData = origonalFileRef.linkedFileData
+					fileRef = new File(fileProperties)
 					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"

+ 4 - 1
services/web/app/coffee/infrastructure/FileWriter.coffee

@@ -15,11 +15,14 @@ module.exports = FileWriter =
 			callback(null)
 
 	writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+		FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
+
+	writeContentToDisk: (identifier, content, 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) ->
+			fs.writeFile fsPath, content, (error) ->
 				return callback(error) if error?
 				callback(null, fsPath)
 

+ 21 - 3
services/web/app/views/project/editor/binary-file.pug

@@ -38,6 +38,7 @@ div.binary-file.full-size(
 	) #{translate("no_preview_available")}
 
 	div.binary-file-footer
+		// Linked Files: URL
 		div(ng-if="openFile.linkedFileData.provider == 'url'")
 			p
 				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -47,6 +48,7 @@ div.binary-file.full-size(
 				|
 				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
 
+		// Linked Files: Project File
 		div(ng-if="openFile.linkedFileData.provider == 'project_file'")
 			p
 				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -54,14 +56,30 @@ div.binary-file.full-size(
 				|
 				a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
 					ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
-					| {{ openFile.linkedFileData.source_project_display_name }}
+					| Another project
 				span(ng-if='openFile.linkedFileData.v1_source_doc_id')
-					| {{ openFile.linkedFileData.source_project_display_name }}
+					| Another project
 				| /{{ 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'")
+		// Linked Files: Project Output File
+		div(ng-if="openFile.linkedFileData.provider == 'project_output_file'")
+			p
+				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+				| Imported from the output of
+				|
+				a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
+					ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+					| Another project
+				span(ng-if='openFile.linkedFileData.v1_source_doc_id')
+					| Another project
+				| : {{ openFile.linkedFileData.source_output_file_path }},
+				|
+				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+		// Bottom Controls
+		span(ng-if="openFile.linkedFileData.provider")
 			button.btn.btn-success(
 				href, ng-click="refreshFile(openFile)",
 				ng-disabled="refreshing"

+ 7 - 221
services/web/app/views/project/editor/file-tree.pug

@@ -308,165 +308,10 @@ script(type='text/ng-template', id='entityListItemTemplate')
 					ng-repeat="child in entity.children | orderBy:[orderByFoldersFirst, 'name']"
 				)
 
-script(type='text/ng-template', id='newDocModalTemplate')
-	.modal-header
-		h3 #{translate("new_file")}
-	.modal-body
-		form(novalidate, name="newDocForm")
-			div.alert.alert-danger(ng-if="error")
-				div(ng-switch="error") 
-					span(ng-switch-when="already exists") #{translate("file_already_exists")}
-					span(ng-switch-default) {{error}}
-			input.form-control(
-				type="text",
-				placeholder="File Name",
-				required,
-				ng-model="inputs.name",
-				on-enter="create()",
-				select-name-on="open",
-				valid-file,
-				name="name"
-			)
-			.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
-				| #{translate('files_cannot_include_invalid_characters')}
-	.modal-footer
-		button.btn.btn-default(
-			ng-disabled="state.inflight"
-			ng-click="cancel()"
-		) #{translate("cancel")}
-		button.btn.btn-primary(
-			ng-disabled="newDocForm.$invalid || state.inflight"
-			ng-click="create()"
-		)
-			span(ng-hide="state.inflight") #{translate("create")}
-			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")
-						| &nbsp;
-						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")
-						| &nbsp;
-						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
-			| &nbsp;
-		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
-	.modal-body
-		form(novalidate, name="newLinkedFileForm")
-			div.alert.alert-danger(ng-if="error")
-				div(ng-switch="error")
-					span(ng-switch-when="already exists") #{translate("file_already_exists")}
-					span(ng-switch-default) {{error}}
-			label(for="url") URL to fetch the file from
-			input.form-control(
-				type="text",
-				placeholder="www.example.com/my_file",
-				required,
-				ng-model="inputs.url",
-				focus-on="open",
-				on-enter="create()",
-				name="url"
-			)
-			.row-spaced
-				label(for="name") File name in this project
-				input.form-control(
-					type="text",
-					placeholder="my_file",
-					required,
-					ng-model="inputs.name",
-					ng-change="nameChangedByUser = true"
-					valid-file,
-					on-enter="create()",
-					name="name"
-				)
-				.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
-					| #{translate('files_cannot_include_invalid_characters')}
-	.modal-footer
-		button.btn.btn-default(
-			ng-disabled="state.inflight"
-			ng-click="cancel()"
-		) #{translate("cancel")}
-		button.btn.btn-primary(
-			ng-disabled="newLinkedFileForm.$invalid || state.inflight"
-			ng-click="create()"
-		)
-			span(ng-hide="state.inflight") #{translate("create")}
-			span(ng-show="state.inflight") #{translate("creating")}...
-
-
 script(type='text/ng-template', id='newFolderModalTemplate')
 	.modal-header
 		h3 #{translate("new_folder")}
 	.modal-body
-		div.alert.alert-danger(ng-if="error")
-			div(ng-switch="error") 
-				span(ng-switch-when="already exists") #{translate("file_already_exists")}
-				span(ng-switch-default) {{error}}
 		form(novalidate, name="newFolderForm")
 			input.form-control(
 				type="text",
@@ -478,8 +323,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 				valid-file,
 				name="name"
 			)
-			.text-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
-				| #{translate('files_cannot_include_invalid_characters')}
+		div.alert.alert-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
+			| #{translate('files_cannot_include_invalid_characters')}
+		div.alert.alert-danger.row-spaced-small(ng-if="error")
+			div(ng-switch="error")
+				span(ng-switch-when="already exists") #{translate("file_already_exists")}
+				span(ng-switch-default) {{error}}
 	.modal-footer
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -492,70 +341,7 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 			span(ng-hide="state.inflight") #{translate("create")}
 			span(ng-show="state.inflight") #{translate("creating")}...
 
-
-script(type="text/template", id="qq-file-uploader-template")
-	div.qq-uploader-selector
-		div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
-			span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
-		div.qq-upload-button-selector.btn.btn-primary.btn-lg
-			div #{translate('upload')}
-		span.or.btn-lg #{translate('or')}
-		span.drag-here.btn-lg #{translate('drag_here')}
-		span.qq-drop-processing-selector
-			span #{translate('processing')}
-			span.qq-drop-processing-spinner-selector
-		ul.qq-upload-list-selector
-			li
-				div.qq-progress-bar-container-selector
-					div(
-						role="progressbar"
-						aria-valuenow="0"
-						aria-valuemin="0"
-						aria-valuemax="100"
-						class="qq-progress-bar-selector qq-progress-bar"
-					)
-				span.qq-upload-file-selector.qq-upload-file
-				span.qq-upload-size-selector.qq-upload-size
-				a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
-				button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
-				span(role="status").qq-upload-status-text-selector.qq-upload-status-text
-
-script(type="text/ng-template", id="uploadFileModalTemplate")
-	.modal-header
-		h3 #{translate("upload_files")}
-		.alert.alert-warning.small.modal-alert(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
-		.alert.alert-warning.small.modal-alert(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
-		.alert.alert-warning.small.modal-alert(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
-		.alert.alert-warning.small.modal-alert(ng-if="conflicts.length > 0")
-			p.text-center
-				| The following files already exist in this project:
-			ul.text-center.list-unstyled.row-spaced-small
-				li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
-			p.text-center.row-spaced-small
-				| Do you want to overwrite them?
-			p.text-center
-				a(href, ng-click="doUpload()").btn.btn-primary Overwrite
-				| &nbsp;
-				a(href, ng-click="cancel()").btn.btn-default Cancel
-
-	.modal-body(
-		fine-upload
-		endpoint="/project/{{ project_id }}/upload"
-		template-id="qq-file-uploader-template"
-		multiple="true"
-		auto-upload="false"
-		on-complete-callback="onComplete"
-		on-upload-callback="onUpload"
-		on-validate-batch="onValidateBatch"
-		on-error-callback="onError"
-		on-submit-callback="onSubmit"
-		on-cancel-callback="onCancel"
-		control="control"
-		params="{'folder_id': parent_folder_id}"
-	)
-	.modal-footer
-		button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
-
+include ./new-file-modal
 
 script(type='text/ng-template', id='deleteEntityModalTemplate')
 	.modal-header

+ 0 - 8
services/web/app/views/project/editor/left-menu.pug

@@ -69,14 +69,6 @@ aside#left-menu.full-size(
 				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")}

+ 217 - 0
services/web/app/views/project/editor/new-file-modal.pug

@@ -0,0 +1,217 @@
+script(type='text/ng-template', id='newFileModalTemplate')
+	.modal-header
+		h3 Add Files
+	.modal-body.modal-new-file
+		table
+			tr
+				td.modal-new-file--list
+					ul.list-unstyled
+						li(ng-class="type == 'doc' ? 'active' : null")
+							a(href, ng-click="type = 'doc'")
+								i.fa.fa-fw.fa-file
+								|
+								| New File
+						li(ng-class="type == 'upload' ? 'active' : null")
+							a(href, ng-click="type = 'upload'")
+								i.fa.fa-fw.fa-upload
+								|
+								| Upload
+						li(ng-class="type == 'project' ? 'active' : null")
+							a(href, ng-click="type = 'project'")
+								i.fa.fa-fw.fa-folder-open
+								|
+								| From Another Project
+						li(ng-class="type == 'url' ? 'active' : null")
+							a(href, ng-click="type = 'url'")
+								i.fa.fa-fw.fa-globe
+								|
+								| From External URL
+				td(class="modal-new-file--body modal-new-file--body-{{type}}")
+					div(ng-if="type == 'doc'", ng-controller="NewDocModalController")
+						form(novalidate, name="newDocForm")
+							label(for="name") File Name
+							input.form-control(
+								type="text",
+								placeholder="File Name",
+								required,
+								ng-model="inputs.name",
+								on-enter="create()",
+								select-name-on="open",
+								valid-file,
+								name="name"
+							)
+						div.alert.alert-danger.row-spaced-small(ng-if="error")
+							div(ng-switch="error")
+								span(ng-switch-when="already exists") #{translate("file_already_exists")}
+								span(ng-switch-default) {{error}}
+						div.alert.alert-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+							| #{translate('files_cannot_include_invalid_characters')}
+					div(ng-if="type == 'upload'", ng-controller="UploadFileModalController")
+						.alert.alert-warning.small.modal(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
+						.alert.alert-warning.small.modal(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
+						.alert.alert-warning.small.modal(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
+						.alert.alert-warning.small.modal(ng-if="conflicts.length > 0")
+							p.text-center
+								| The following files already exist in this project:
+							ul.text-center.list-unstyled.row-spaced-small
+								li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
+							p.text-center.row-spaced-small
+								| Do you want to overwrite them?
+							p.text-center
+								a(href, ng-click="doUpload()").btn.btn-primary Overwrite
+								| &nbsp;
+								a(href, ng-click="cancel()").btn.btn-default Cancel
+						div(
+							fine-upload
+							endpoint="/project/{{ project_id }}/upload"
+							template-id="qq-file-uploader-template"
+							multiple="true"
+							auto-upload="false"
+							on-complete-callback="onComplete"
+							on-upload-callback="onUpload"
+							on-validate-batch="onValidateBatch"
+							on-error-callback="onError"
+							on-submit-callback="onSubmit"
+							on-cancel-callback="onCancel"
+							control="control"
+							params="{'folder_id': parent_folder_id}"
+						)
+					div(ng-if="type == 'project'", ng-controller="ProjectLinkedFileModalController")
+						div
+							form
+								.form-controls
+									label(for="project-select") Select a Project
+									span(ng-show="state.inFlight.projects")
+										| &nbsp;
+										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 }}
+
+								.form-controls.row-spaced-small(ng-if="!state.isOutputFilesMode")
+									label(for="project-entity-select") Select a File
+									span(ng-show="state.inFlight.entities")
+										| &nbsp;
+										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) }}
+
+								.form-controls.row-spaced-small(ng-if="state.isOutputFilesMode")
+									label(for="project-entity-select") Select an Output File
+									span(ng-show="state.inFlight.compile")
+										| &nbsp;
+										i.fa.fa-spinner.fa-spin
+									select.form-control(
+										name="project-output-file-select"
+										ng-model="data.selectedProjectOutputFile"
+										ng-disabled="!shouldEnableProjectOutputFileSelect()"
+									)
+										option(value="" disabled selected) - Please Select an Output File
+										option(
+											ng-repeat="outputFile in data.projectOutputFiles"
+											value="{{ outputFile.path }}"
+										) {{ outputFile.path }}
+								div.toggle-output-files-button
+									| or&nbsp;
+									a(
+										href="#"
+										ng-click="toggleOutputFilesMode()"
+									)
+										span(ng-show="state.isOutputFilesMode") select from source files
+										span(ng-show="!state.isOutputFilesMode") select from output files
+
+								.form-controls.row-spaced-small
+									label(for="name") File Name In This Project
+									input.form-control(
+										type="text"
+										placeholder="example.tex"
+										required
+										ng-model="data.name"
+										name="name"
+									)
+						div.alert.alert-danger.row-spaced-small(ng-if="state.error") Error, something went wrong!
+					div(ng-if="type == 'url'", ng-controller="UrlLinkedFileModalController")
+						form(novalidate, name="newLinkedFileForm")
+							label(for="url") URL to fetch the file from
+							input.form-control(
+								type="text",
+								placeholder="www.example.com/my_file",
+								required,
+								ng-model="inputs.url",
+								focus-on="open",
+								on-enter="create()",
+								name="url"
+							)
+							.row-spaced.small
+								label(for="name") File name in this project
+								input.form-control(
+									type="text",
+									placeholder="my_file",
+									required,
+									ng-model="inputs.name",
+									ng-change="nameChangedByUser = true"
+									valid-file,
+									on-enter="create()",
+									name="name"
+								)
+								.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+									| #{translate('files_cannot_include_invalid_characters')}
+						div.alert.alert-danger.row-spaced-small(ng-if="error")
+							div(ng-switch="error")
+								span(ng-switch-when="already exists") #{translate("file_already_exists")}
+								span(ng-switch-default) {{error}}
+	.modal-footer
+		button.btn.btn-default(
+			ng-disabled="state.inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-primary(
+			ng-disabled="state.inflight || !state.valid"
+			ng-click="create()"
+			ng-hide="type == 'upload'"
+		)
+			span(ng-hide="state.inflight") #{translate("create")}
+			span(ng-show="state.inflight") #{translate("creating")}...
+
+script(type="text/template", id="qq-file-uploader-template")
+	div.qq-uploader-selector
+		div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
+			span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
+		div Drag here
+		div.row-spaced-small.small #{translate('or')}
+		div.row-spaced-small
+			div.qq-upload-button-selector.btn.btn-primary
+				| Select from your computer
+		span.qq-drop-processing-selector
+			span #{translate('processing')}
+			span.qq-drop-processing-spinner-selector
+		ul.qq-upload-list-selector
+			li
+				div.qq-progress-bar-container-selector
+					div(
+						role="progressbar"
+						aria-valuenow="0"
+						aria-valuemin="0"
+						aria-valuemax="100"
+						class="qq-progress-bar-selector qq-progress-bar"
+					)
+				span.qq-upload-file-selector.qq-upload-file
+				span.qq-upload-size-selector.qq-upload-size
+				a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
+				button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
+				span(role="status").qq-upload-status-text-selector.qq-upload-status-text

+ 1 - 1
services/web/docker-compose.yml

@@ -17,7 +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'
+      ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
     depends_on:
       - redis

+ 172 - 98
services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee

@@ -4,10 +4,12 @@ define [
 	App.controller "FileTreeController", ["$scope", "$modal", "ide", "$rootScope", ($scope, $modal, ide, $rootScope) ->
 		$scope.openNewDocModal = () ->
 			$modal.open(
-				templateUrl: "newDocModalTemplate"
-				controller:  "NewDocModalController"
+				templateUrl: "newFileModalTemplate"
+				controller:  "NewFileModalController"
+				size: 'lg'
 				resolve: {
 					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+					type: () -> 'doc'
 				}
 			)
 
@@ -22,37 +24,12 @@ define [
 
 		$scope.openUploadFileModal = () ->
 			$modal.open(
-				templateUrl: "uploadFileModalTemplate"
-				controller:  "UploadFileModalController"
-				scope: $scope
-				resolve: {
-					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
-				}
-			)
-
-		$scope.openLinkedFileModal = window.openLinkedFileModal = () ->
-			unless 'url' in window.data.enabledLinkedFileTypes
-				console.warn("Url linked files are not enabled")
-				return
-			$modal.open(
-				templateUrl: "linkedFileModalTemplate"
-				controller:  "LinkedFileModalController"
-				scope: $scope
-				resolve: {
-					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
-				}
-			)
-
-		$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
+				templateUrl: "newFileModalTemplate"
+				controller:  "NewFileModalController"
+				size: 'lg'
 				resolve: {
 					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+					type: () -> 'upload'
 				}
 			)
 
@@ -67,11 +44,11 @@ define [
 			$scope.$broadcast "delete:selected"
 	]
 
-	App.controller "NewDocModalController", [
+	App.controller "NewFolderModalController", [
 		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
 		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
-			$scope.inputs = 
-				name: "name.tex"
+			$scope.inputs =
+				name: "name"
 			$scope.state =
 				inflight: false
 
@@ -86,10 +63,10 @@ define [
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createDoc(name, parent_folder)
+					.createFolder(name, $scope.parent_folder)
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$modalInstance.dismiss('done')
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
@@ -99,42 +76,60 @@ define [
 				$modalInstance.dismiss('cancel')
 	]
 
-	App.controller "NewFolderModalController", [
-		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
-		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
-			$scope.inputs = 
-				name: "name"
-			$scope.state =
+	App.controller "NewFileModalController", [
+		"$scope", "type", "parent_folder", "$modalInstance"
+		($scope,   type,   parent_folder,   $modalInstance) ->
+			$scope.type = type
+			$scope.parent_folder = parent_folder
+			$scope.state = {
 				inflight: false
+				valid: true
+			}
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+			$scope.create = () ->
+				$scope.$broadcast 'create'
+			$scope.$on 'done', () ->
+				$modalInstance.dismiss('done')
+	]
 
-			$modalInstance.opened.then () ->
-				$timeout () ->
-					$scope.$broadcast "open"
-				, 200
+	App.controller "NewDocModalController", [
+		"$scope", "ide", "$timeout"
+		($scope,   ide,   $timeout) ->
+			$scope.inputs = 
+				name: "name.tex"
 
-			$scope.create = () ->
+			validate = () ->
+				name = $scope.inputs.name
+				$scope.state.valid = (name? and name.length > 0)
+			$scope.$watch 'inputs.name', validate
+
+			$timeout () ->
+				$scope.$broadcast "open"
+			, 200
+
+			$scope.$on 'create', () ->
 				name = $scope.inputs.name
 				if !name? or name.length == 0
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createFolder(name, parent_folder)
+					.createDoc(name, $scope.parent_folder)
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
 						$scope.state.inflight = false
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]
 
 	App.controller "UploadFileModalController", [
-		"$scope", "$rootScope", "ide", "$modalInstance", "$timeout", "parent_folder", "$window"
-		($scope,   $rootScope,   ide,   $modalInstance,   $timeout,   parent_folder, $window) ->
-			$scope.parent_folder_id = parent_folder?.id
+		"$scope", "$rootScope", "ide", "$timeout", "$window"
+		($scope,   $rootScope,   ide,   $timeout,   $window) ->
+			$scope.parent_folder_id = $scope.parent_folder?.id
+			$scope.project_id = ide.project_id
 			$scope.tooManyFiles = false
 			$scope.rateLimitHit = false
 			$scope.secondsToRedirect = 10
@@ -162,7 +157,7 @@ define [
 					if response.success
 						$rootScope.$broadcast 'file:upload:complete', response
 					if uploadCount == 0 and response? and response.success
-						$modalInstance.close("done")
+						$scope.$emit 'done'
 				), 250
 
 			$scope.onValidateBatch = (files)->
@@ -210,30 +205,44 @@ define [
 			$scope.doUpload = () ->
 				$scope.control?.q?.uploadStoredFiles()
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]
 
 	App.controller "ProjectLinkedFileModalController", [
-		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
-		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+		"$scope", "ide", "$timeout",
+		($scope,   ide,   $timeout) ->
+
 			$scope.data =
 				projects: null # or []
 				selectedProjectId: null
 				projectEntities: null # or []
+				projectOutputFiles: null # or []
 				selectedProjectEntity: null
+				selectedProjectOutputFile: null
+				buildId: null
 				name: null
-			$scope.state =
-				inFlight:
-					projects: false
-					entities: false
-					create: false
-				error: false
+			$scope.state.inFlight =
+				projects: false
+				entities: false
+				compile: false
+			$scope.state.isOutputFilesMode = false
+			$scope.state.error = false
 
 			$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
 				return if !newVal
 				$scope.data.selectedProjectEntity = null
-				$scope.getProjectEntities($scope.data.selectedProjectId)
+				$scope.data.selectedProjectOutputFile = null
+				if $scope.state.isOutputFilesMode
+					$scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+				else
+					$scope.getProjectEntities($scope.data.selectedProjectId)
+
+			$scope.$watch 'state.isOutputFilesMode', (newVal, oldVal) ->
+				return if !newVal and !oldVal
+				$scope.data.selectedProjectOutputFile = null
+				if newVal == true
+					$scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+				else
+					$scope.getProjectEntities($scope.data.selectedProjectId)
 
 			# auto-set filename based on selected file
 			$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
@@ -242,15 +251,31 @@ define [
 				if fileName
 					$scope.data.name = fileName
 
+			# auto-set filename based on selected file
+			$scope.$watch 'data.selectedProjectOutputFile', (newVal, oldVal) ->
+				return if !newVal
+				if newVal == 'output.pdf'
+					project = _.find($scope.data.projects, (p) -> p._id == $scope.data.selectedProjectId)
+					$scope.data.name = if project?.name? then "#{project.name}.pdf" else 'output.pdf'
+				else
+					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
+				inFlight.projects = inFlight.entities = inFlight.compile = false
+				$scope.state.inflight = false
 				$scope.state.error = isError
 
+			$scope.toggleOutputFilesMode = () ->
+				return if !$scope.data.selectedProjectId
+				$scope.state.isOutputFilesMode = !$scope.state.isOutputFilesMode
+
 			$scope.shouldEnableProjectSelect = () ->
 				{ state, data } = $scope
 				return !state.inFlight.projects && data.projects
@@ -259,16 +284,33 @@ define [
 				{ state, data } = $scope
 				return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
 
-			$scope.shouldEnableCreateButton = () ->
+			$scope.shouldEnableProjectOutputFileSelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && !state.inFlight.compile && data.projects && data.selectedProjectId
+
+
+			validate = () ->
 				state = $scope.state
 				data = $scope.data
-				return !state.inFlight.projects &&
+				$scope.state.valid = !state.inFlight.projects &&
 					!state.inFlight.entities &&
 					data.projects &&
 					data.selectedProjectId &&
-					data.projectEntities &&
-					data.selectedProjectEntity &&
+					(
+						(
+							!$scope.state.isOutputFilesMode &&
+							data.projectEntities &&
+							data.selectedProjectEntity
+						) ||
+						(
+							$scope.state.isOutputFilesMode &&
+							data.projectOutputFiles &&
+							data.selectedProjectOutputFile
+						)
+					) &&
 					data.name
+			$scope.$watch 'state', validate, true
+			$scope.$watch 'data', validate, true
 
 			$scope.getUserProjects = () ->
 				_setInFlight('projects')
@@ -295,50 +337,84 @@ define [
 				.catch (err) ->
 					_reset(err: true)
 
+			$scope.compileProjectAndGetOutputFiles = (project_id) =>
+				_setInFlight('compile')
+				ide.$http.post("/project/#{project_id}/compile", {
+					check: "silent",
+					draft: false,
+					incrementalCompilesEnabled: false
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					if resp.data.status == 'success'
+						filteredFiles = resp.data.outputFiles.filter (f) ->
+							f.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
+						$scope.data.projectOutputFiles = filteredFiles
+						$scope.data.buildId = filteredFiles?[0]?.build
+						console.log ">> build_id", $scope.data.buildId
+						_reset(err: false)
+					else
+						$scope.data.projectOutputFiles = null
+						_reset(err: true)
+				.catch (err) ->
+					console.error(err)
+					_reset(err: true)
+
 			$scope.init = () ->
 				$scope.getUserProjects()
 			$timeout($scope.init, 0)
 
-			$scope.create = () ->
+			$scope.$on 'create', () ->
 				projectId = $scope.data.selectedProjectId
-				path = $scope.data.selectedProjectEntity
 				name = $scope.data.name
-				if !name || !path || !projectId
-					_reset(err: true)
-					return
+				if $scope.state.isOutputFilesMode
+					provider = 'project_output_file'
+					payload = {
+						source_project_id: projectId,
+						source_output_file_path: $scope.data.selectedProjectOutputFile,
+						build_id: $scope.data.buildId
+					}
+				else
+					provider = 'project_file'
+					payload = {
+						source_project_id: projectId,
+						source_entity_path: $scope.data.selectedProjectEntity
+					}
 				_setInFlight('create')
 				ide.fileTreeManager
-					.createLinkedFile(name, parent_folder, 'project_file', {
-						source_project_id: projectId,
-						source_entity_path: path
-					})
+					.createLinkedFile(name, $scope.parent_folder, provider, payload)
 					.then () ->
 						_reset(err: false)
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.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) ->
+	App.controller "UrlLinkedFileModalController", [
+		"$scope", "ide", "$timeout"
+		($scope,   ide,   $timeout) ->
 			$scope.inputs =
 				name: ""
 				url: ""
 			$scope.nameChangedByUser = false
-			$scope.state =
-				inflight: false
 
-			$modalInstance.opened.then () ->
-				$timeout () ->
-					$scope.$broadcast "open"
-				, 200
+			$timeout () ->
+				$scope.$broadcast "open"
+			, 200
+
+			validate = () ->
+				{name, url} = $scope.inputs
+				if !name? or name.length == 0
+					$scope.state.valid = false
+				else if !url? or url.length == 0
+					$scope.state.valid = false
+				else
+					$scope.state.valid = true
+			$scope.$watch 'inputs.name', validate
+			$scope.$watch 'inputs.url', validate
 
 			$scope.$watch "inputs.url", (url) ->
 				if url? and url != "" and !$scope.nameChangedByUser
@@ -347,7 +423,7 @@ define [
 					if parts.length > 1 # Wait for at one /
 						$scope.inputs.name = parts[0]
 
-			$scope.create = () ->
+			$scope.$on 'create', () ->
 				{name, url} = $scope.inputs
 				if !name? or name.length == 0
 					return
@@ -355,15 +431,13 @@ define [
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createLinkedFile(name, parent_folder, 'url', {url})
+					.createLinkedFile(name, $scope.parent_folder, 'url', {url})
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
 						$scope.state.inflight = false
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]

+ 0 - 6
services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee

@@ -4,12 +4,6 @@ define [
 ], (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"

+ 40 - 0
services/web/public/stylesheets/app/editor/file-tree.less

@@ -261,3 +261,43 @@
 		}
 	}
 }
+
+.modal-new-file {
+	padding: 0;
+	table {
+		width: 100%;
+		td {
+			vertical-align: top;
+		}
+	}
+	.toggle-output-files-button {
+		font-size: 80%;
+	}
+}
+	.modal-new-file--list {
+		background-color: @modal-footer-background-color;
+		width: 220px;
+		ul {
+			li {
+				padding: (@line-height-computed / 4);
+				a {
+					color: @text-color;
+				}
+			}
+			li.active {
+				background-color: white;
+				a {
+					color: @link-color;
+				}
+			}
+		}
+	}
+
+	.modal-new-file--body {
+		padding: 20px;
+		padding-top: (@line-height-computed / 4);
+	}
+
+	.modal-new-file--body-upload {
+		padding-top: 20px;
+	}

+ 8 - 4
services/web/public/stylesheets/components/fineupload.less

@@ -10,13 +10,17 @@
 }
 .qq-uploader-selector {
 	text-align: center;
-	.drag-here {
-		border: 1px dashed #666;
-		vertical-align: middle;
-	}
+	border: 1px dashed #666;
+    border-radius: 6px;
+	vertical-align: middle;
 	.help {
 		margin-top: 6px;
 	}
+    min-height: 200px;
+    padding: 20px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
 }
 /*.qq-upload-button-selector {
     display: block;

+ 105 - 2
services/web/test/acceptance/coffee/LinkedFilesTests.coffee

@@ -8,6 +8,8 @@ MockFileStoreApi = require './helpers/MockFileStoreApi'
 request = require "./helpers/request"
 User = require "./helpers/User"
 
+MockClsiApi = require "./helpers/MockClsiApi"
+
 
 express = require("express")
 LinkedUrlProxy = express()
@@ -116,7 +118,6 @@ describe "LinkedFiles", ->
 						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()
@@ -149,7 +150,7 @@ describe "LinkedFiles", ->
 						source_entity_path: "/#{@source_doc_name}",
 			}, (error, response, body) =>
 				expect(response.statusCode).to.equal 403
-				expect(body).to.equal 'Cannot create linked file'
+				expect(body).to.equal 'You do not have access to this project'
 				done()
 
 	describe "with a linked project_file from a v1 project that has not been imported", ->
@@ -344,3 +345,105 @@ describe "LinkedFiles", ->
 
 		# TODO: Add test for asking for host that return ENOTFOUND
 		# (This will probably end up handled by the proxy)
+
+	describe "creating a linked output file", ->
+		before (done) ->
+			async.series [
+				(cb) =>
+					@owner.createProject 'output-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 'output-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)
+			], done
+
+		it 'should import the project.pdf file from the source project', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test.pdf',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_output_file',
+					data:
+						source_project_id: @project_two_id,
+						source_output_file_path: "project.pdf",
+						build_id: '1234-abcd'
+			}, (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_output_file',
+						source_project_id: @project_two_id,
+						source_output_file_path: "project.pdf",
+						build_id: '1234-abcd'
+					}
+					expect(firstFile.name).to.equal('test.pdf')
+					done()
+
+		it 'should refresh the file', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file/#{@existing_file_id}/refresh",
+				json: true
+			}, (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
+				@refreshed_file_id = new_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.pdf')
+					done()
+
+	describe "with a linked project_output_file from a v1 project that has not been imported", ->
+		before (done) ->
+			async.series [
+				(cb) =>
+					@owner.createProject 'output-v1-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()
+						@project_one.rootFolder[0].fileRefs.push {
+							linkedFileData: {
+								provider: "project_output_file",
+								v1_source_doc_id: 9999999,  # We won't find this id in the database
+								source_output_file_path: "project.pdf",
+								build_id: '123'
+							},
+							_id: "abcdef",
+							rev: 0,
+							created: new Date(),
+							name: "whatever.pdf"
+						}
+						@owner.saveProject @project_one, cb
+			], done
+
+		it 'should refuse to refresh', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file/abcdef/refresh",
+				json: true
+			}, (error, response, body) =>
+				expect(response.statusCode).to.equal 409
+				expect(body).to.equal "Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file"
+				done()

+ 12 - 1
services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee

@@ -1,9 +1,11 @@
 express = require("express")
+bodyParser = require "body-parser"
 app = express()
 
 module.exports = MockClsiApi =
 	run: () ->
-		app.post "/project/:project_id/compile", (req, res, next) =>
+
+		compile = (req, res, next) =>
 			res.status(200).send {
 				compile:
 					status: 'success'
@@ -21,6 +23,9 @@ module.exports = MockClsiApi =
 					]
 			}
 
+		app.post "/project/:project_id/compile", compile
+		app.post "/project/:project_id/user/:user_id/compile", compile
+
 		app.get "/project/:project_id/build/:build_id/output/*", (req, res, next) ->
 			filename = req.params[0]
 			if filename == 'project.pdf'
@@ -30,6 +35,12 @@ module.exports = MockClsiApi =
 			else
 				res.sendStatus(404)
 
+		app.get "/project/:project_id/user/:user_id/build/:build_id/output/:output_path", (req, res, next) =>
+			res.status(200).send("hello")
+
+		app.get "/project/:project_id/status", (req, res, next) =>
+			res.status(200).send()
+
 		app.listen 3013, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 33 - 2
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -44,6 +44,8 @@ describe 'ProjectEntityUpdateHandler', ->
 				else
 					@._id = file_id
 				@rev = 0
+				if options.linkedFileData?
+					@linkedFileData = options.linkedFileData
 
 		@docName = "doc-name"
 		@docLines = ['1234','abc']
@@ -121,6 +123,35 @@ describe 'ProjectEntityUpdateHandler', ->
 				.calledWithMatch(project_id, projectHistoryId, userId, changesMatcher)
 				.should.equal true
 
+	describe 'copyFileFromExistingProjectWithProject, with linkedFileData', ->
+
+		beforeEach ->
+			@oldProject_id = "123kljadas"
+			@oldFileRef = {
+				_id:"oldFileRef",
+				name:@fileName,
+				linkedFileData: @linkedFileData
+			}
+			@ProjectEntityMongoUpdateHandler._confirmFolder = sinon.stub().yields(folder_id)
+			@ProjectEntityMongoUpdateHandler._putElement = sinon.stub().yields(null, {path:{fileSystem: @fileSystemPath}})
+
+			@ProjectEntityUpdateHandler.copyFileFromExistingProjectWithProject @project, folder_id, @oldProject_id, @oldFileRef, userId, @callback
+
+		it 'should copy the file in FileStoreHandler', ->
+			@FileStoreHandler.copyFile
+				.calledWith(@oldProject_id, @oldFileRef._id, project_id, file_id)
+				.should.equal true
+
+		it 'should put file into folder by calling put element, with the linkedFileData', ->
+			@ProjectEntityMongoUpdateHandler._putElement
+				.calledWithMatch(
+					@project,
+					folder_id,
+					{ _id: file_id, name: @fileName, linkedFileData: @linkedFileData},
+					"file"
+				)
+				.should.equal true
+
 	describe 'updateDocLines', ->
 		beforeEach ->
 			@path = "/somewhere/something.tex"
@@ -285,7 +316,7 @@ describe 'ProjectEntityUpdateHandler', ->
 		beforeEach ->
 			@path = "/path/to/file"
 
-			@newFile = {_id: file_id, rev: 0, name: @fileName}
+			@newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
 			@TpdsUpdateSender.addFile = sinon.stub().yields()
 			@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
 			@ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
@@ -330,7 +361,7 @@ describe 'ProjectEntityUpdateHandler', ->
 			@newFileUrl = "new-file-url"
 			@FileStoreHandler.uploadFileFromDisk = sinon.stub().yields(null, @newFileUrl)
 
-			@newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0
+			@newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0, linkedFileData: @linkedFileData
 			@oldFile = _id: file_id
 			@path = "/path/to/file"
 			@ProjectEntityMongoUpdateHandler._insertDeletedFileReference = sinon.stub().yields()