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

Merge branch 'ns-package-aware-autocomplete'

Nate Stemen 9 лет назад
Родитель
Сommit
2a50a18d23

+ 28 - 0
services/web/app/coffee/Features/Metadata/MetaController.coffee

@@ -0,0 +1,28 @@
+EditorRealTimeController = require "../Editor/EditorRealTimeController"
+MetaHandler = require './MetaHandler'
+logger = require 'logger-sharelatex'
+
+
+module.exports = MetaController =
+
+	getMetadata: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "getting all labels for project"
+		MetaHandler.getAllMetaForProject project_id, (err, projectMeta) ->
+			if err?
+				logger.err {project_id, err}, "[MetaController] error getting all labels from project"
+				return next err
+			res.json {projectId: project_id, projectMeta: projectMeta}
+
+	broadcastMetadataForDoc: (req, res, next) ->
+		project_id = req.params.project_id
+		doc_id = req.params.doc_id
+		logger.log {project_id, doc_id}, "getting labels for doc"
+		MetaHandler.getMetaForDoc project_id, doc_id, (err, docMeta) ->
+			if err?
+				logger.err {project_id, doc_id, err}, "[MetaController] error getting labels from doc"
+				return next err
+			EditorRealTimeController.emitToRoom project_id, 'broadcastDocMeta', {
+				docId: doc_id, meta: docMeta
+			}
+			res.sendStatus 200

+ 66 - 0
services/web/app/coffee/Features/Metadata/MetaHandler.coffee

@@ -0,0 +1,66 @@
+ProjectEntityHandler = require "../Project/ProjectEntityHandler"
+DocumentUpdaterHandler = require '../DocumentUpdater/DocumentUpdaterHandler'
+packageMapping = require "./packageMapping"
+
+
+module.exports = MetaHandler =
+
+	labelRegex: () ->
+		/\\label{(.{0,80}?)}/g
+
+	usepackageRegex: () ->
+		/^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
+
+	ReqPackageRegex: () ->
+		/^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/g
+
+	getAllMetaForProject: (projectId, callback=(err, projectMeta)->) ->
+		DocumentUpdaterHandler.flushProjectToMongo projectId, (err) ->
+			if err?
+				return callback err
+			ProjectEntityHandler.getAllDocs projectId, (err, docs) ->
+				if err?
+					return callback err
+				projectMeta = MetaHandler.extractMetaFromProjectDocs docs
+				callback null, projectMeta
+
+	getMetaForDoc: (projectId, docId, callback=(err, docMeta)->) ->
+		DocumentUpdaterHandler.flushDocToMongo projectId, docId, (err) ->
+			if err?
+				return callback err
+			ProjectEntityHandler.getDoc projectId, docId, (err, lines) ->
+				if err?
+					return callback err
+				docMeta = MetaHandler.extractMetaFromDoc lines
+				callback null, docMeta
+
+	extractMetaFromDoc: (lines) ->
+		docMeta = {labels: [], packages: {}}
+		packages = []
+		label_re = MetaHandler.labelRegex()
+		package_re = MetaHandler.usepackageRegex()
+		req_package_re = MetaHandler.ReqPackageRegex()
+		for line in lines
+			while labelMatch = label_re.exec line
+				if label = labelMatch[1]
+					docMeta.labels.push label
+			while packageMatch = package_re.exec line
+				if messy = packageMatch[1]
+					for pkg in messy.split ','
+						if clean = pkg.trim()
+							packages.push clean
+			while packageMatch = req_package_re.exec line
+				if messy = packageMatch[1]
+					for pkg in messy.split ','
+						if clean = pkg.trim()
+							packages.push clean
+		for pkg in packages
+			if packageMapping[pkg]?
+				docMeta.packages[pkg] = packageMapping[pkg]
+		return docMeta
+
+	extractMetaFromProjectDocs: (projectDocs) ->
+		projectMeta = {}
+		for _path, doc of projectDocs
+			projectMeta[doc._id] = MetaHandler.extractMetaFromDoc doc.lines
+		return projectMeta

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
services/web/app/coffee/Features/Metadata/packageMapping.coffee


+ 4 - 0
services/web/app/coffee/router.coffee

@@ -43,6 +43,7 @@ SudoModeController = require('./Features/SudoMode/SudoModeController')
 SudoModeMiddlewear = require('./Features/SudoMode/SudoModeMiddlewear')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+MetaController = require('./Features/Metadata/MetaController')
 LabelsController = require('./Features/Labels/LabelsController')
 TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
 Features = require('./infrastructure/Features')
@@ -202,6 +203,9 @@ module.exports = class Router
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 
+		webRouter.get '/project/:project_id/metadata', AuthorizationMiddlewear.ensureUserCanReadProject, AuthenticationController.requireLogin(), MetaController.getMetadata
+		webRouter.post '/project/:project_id/doc/:doc_id/metadata', AuthorizationMiddlewear.ensureUserCanReadProject, AuthenticationController.requireLogin(), MetaController.broadcastMetadataForDoc
+
 		webRouter.get '/project/:project_id/labels', AuthorizationMiddlewear.ensureUserCanReadProject, AuthenticationController.requireLogin(), LabelsController.getAllLabels
 		webRouter.post '/project/:project_id/doc/:doc_id/labels', AuthorizationMiddlewear.ensureUserCanReadProject, AuthenticationController.requireLogin(), LabelsController.broadcastLabelsForDoc
 

+ 9 - 9
services/web/public/coffee/ide.coffee

@@ -9,7 +9,7 @@ define [
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
 	"ide/references/ReferencesManager"
-	"ide/labels/LabelsManager"
+	"ide/metadata/MetadataManager"
 	"ide/review-panel/ReviewPanelManager"
 	"ide/SafariScrollPatcher"
 	"ide/AutoCompileOnboardingController",
@@ -47,12 +47,12 @@ define [
 	PdfManager
 	BinaryFilesManager
 	ReferencesManager
-	LabelsManager
+	MetadataManager
 	ReviewPanelManager
 	SafariScrollPatcher
 ) ->
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, labels) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) ->
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->
@@ -72,10 +72,10 @@ define [
 			view: "editor"
 			chatOpen: false
 			pdfLayout: 'sideBySide'
-			pdfHidden: false,
-			pdfWidth: 0,
-			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}"),
-			miniReviewPanelVisible: false,
+			pdfHidden: false
+			pdfWidth: 0
+			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}")
+			miniReviewPanelVisible: false
 		}
 		$scope.onboarding = {
 			autoCompile: if window.showAutoCompileOnboarding then 'unseen' else 'dismissed'
@@ -138,7 +138,7 @@ define [
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
-		ide.labelsManager = new LabelsManager(ide, $scope, labels)
+		ide.metadataManager = new MetadataManager(ide, $scope, metadata)
 
 		inited = false
 		$scope.$on "project:joined", () ->
@@ -153,7 +153,7 @@ define [
 			$timeout(
 				() ->
 					if $scope.permissions.write
-						ide.labelsManager.loadProjectLabelsFromServer()
+						ide.metadataManager.loadProjectMetaFromServer()
 						_labelsInitialLoadDone = true
 				, 200
 			)

+ 20 - 17
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -9,11 +9,13 @@ define [
 	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
 	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
 	"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
+	"ide/editor/directives/aceEditor/metadata/MetadataManager"
 	"ide/editor/directives/aceEditor/labels/LabelsManager"
 	"ide/labels/services/labels"
+	"ide/metadata/services/metadata"
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
-], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, LabelsManager) ->
+], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager, LabelsManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
 
@@ -35,7 +37,7 @@ define [
 			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
 			return url
 
-	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, graphics, preamble, $http, $q) ->
+	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, labels, metadata, graphics, preamble, $http, $q) ->
 		monkeyPatchSearch($rootScope, $compile)
 
 		return  {
@@ -103,7 +105,8 @@ define [
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
 				trackChangesManager   = new TrackChangesManager(scope, editor, element)
 				labelsManager = new LabelsManager(scope, editor, element, labels)
-				autoCompleteManager = new AutoCompleteManager(scope, editor, element, labelsManager, graphics, preamble)
+				metadataManager = new MetadataManager(scope, editor, element, metadata)
+				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, labelsManager, graphics, preamble)
 
 
 				# Prevert Ctrl|Cmd-S from triggering save dialog
@@ -115,16 +118,16 @@ define [
 				editor.commands.removeCommand "transposeletters"
 				editor.commands.removeCommand "showSettingsMenu"
 				editor.commands.removeCommand "foldall"
-				
+
 				# For European keyboards, the / is above 7 so needs Shift pressing.
-				# This comes through as Command-Shift-/ on OS X, which is mapped to 
+				# This comes through as Command-Shift-/ on OS X, which is mapped to
 				# toggleBlockComment.
 				# This doesn't do anything for LaTeX, so remap this to togglecomment to
 				# work for European keyboards as normal.
 				# On Windows, the key combo comes as Ctrl-Shift-7.
 				editor.commands.removeCommand "toggleBlockComment"
 				editor.commands.removeCommand "togglecomment"
-				
+
 				editor.commands.addCommand {
 					name: "togglecomment",
 					bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" },
@@ -140,7 +143,7 @@ define [
 					exec: (editor) ->
 						ace.require("ace/ext/searchbox").Search(editor, true)
 					readOnly: true
-				
+
 				# Bold text on CMD+B
 				editor.commands.addCommand
 					name: "bold",
@@ -154,7 +157,7 @@ define [
 							text = editor.getCopyText()
 							editor.insert("\\textbf{" + text + "}")
 					readOnly: false
-                    
+
 				# Italicise text on CMD+I
 				editor.commands.addCommand
 					name: "italics",
@@ -171,7 +174,7 @@ define [
 
 				scope.$watch "onCtrlEnter", (callback) ->
 					if callback?
-						editor.commands.addCommand 
+						editor.commands.addCommand
 							name: "compile",
 							bindKey: win: "Ctrl-Enter", mac: "Command-Enter"
 							exec: (editor) =>
@@ -180,7 +183,7 @@ define [
 
 				scope.$watch "onCtrlJ", (callback) ->
 					if callback?
-						editor.commands.addCommand 
+						editor.commands.addCommand
 							name: "toggle-review-panel",
 							bindKey: win: "Ctrl-J", mac: "Command-J"
 							exec: (editor) =>
@@ -189,7 +192,7 @@ define [
 
 				scope.$watch "onCtrlShiftC", (callback) ->
 					if callback?
-						editor.commands.addCommand 
+						editor.commands.addCommand
 							name: "add-new-comment",
 							bindKey: win: "Ctrl-Shift-C", mac: "Command-Shift-C"
 							exec: (editor) =>
@@ -198,7 +201,7 @@ define [
 
 				scope.$watch "onCtrlShiftA", (callback) ->
 					if callback?
-						editor.commands.addCommand 
+						editor.commands.addCommand
 							name: "toggle-track-changes",
 							bindKey: win: "Ctrl-Shift-A", mac: "Command-Shift-A"
 							exec: (editor) =>
@@ -302,7 +305,7 @@ define [
 					if updateCount == 100
 						event_tracking.send 'editor-interaction', 'multi-doc-update'
 					scope.$emit "#{scope.name}:change"
-				
+
 				onScroll = (scrollTop) ->
 					return if !scope.eventsBridge?
 					height = editor.renderer.layerConfig.maxHeight
@@ -311,7 +314,7 @@ define [
 				onScrollbarVisibilityChanged = (event, vRenderer) ->
 					return if !scope.eventsBridge?
 					scope.eventsBridge.emit "aceScrollbarVisibilityChanged", vRenderer.scrollBarV.isVisible, vRenderer.scrollBarV.width
-					
+
 				if scope.eventsBridge?
 					editor.renderer.on "scrollbarVisibilityChanged", onScrollbarVisibilityChanged
 
@@ -404,14 +407,14 @@ define [
 
 					session = editor.getSession()
 					session.off "changeScrollTop"
-					
+
 					doc = session.getDocument()
 					doc.off "change", onChange
-				
+
 				editor.renderer.on "changeCharacterSize", () ->
 					scope.$apply () ->
 						scope.rendererData.lineHeight = editor.renderer.lineHeight
-				
+
 				scope.$watch "rendererData", (rendererData) ->
 					if rendererData?
 						rendererData.lineHeight = editor.renderer.lineHeight

+ 14 - 12
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -10,8 +10,7 @@ define [
 	aceSnippetManager = ace.require('ace/snippets').snippetManager
 
 	class AutoCompleteManager
-		constructor: (@$scope, @editor, @element, @labelsManager, @graphics, @preamble) ->
-			@suggestionManager = new CommandManager()
+		constructor: (@$scope, @editor, @element, @metadataManager, @labelsManager, @graphics, @preamble) ->
 
 			@monkeyPatchAutocomplete()
 
@@ -35,6 +34,8 @@ define [
 				enableLiveAutocompletion: false
 			})
 
+			commandCompleter = new CommandManager(@metadataManager)
+
 			SnippetCompleter = new EnvironmentManager()
 			PackageCompleter = new PackageManager()
 
@@ -65,7 +66,7 @@ define [
 								}
 							callback null, result
 
-			labelsManager = @labelsManager
+			metadataManager = @metadataManager
 			LabelsCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
 					context = Helpers.getContext(editor, pos)
@@ -76,13 +77,14 @@ define [
 							commandName = refMatch[1]
 							currentArg = refMatch[2]
 							result = []
-							result.push {
-								caption: "\\#{commandName}{}",
-								snippet: "\\#{commandName}{}",
-								meta: "cross-reference",
-								score: 60
-							}
-							for label in labelsManager.getAllLabels()
+							if commandName != 'ref' # ref is in top 100 commands
+								result.push {
+									caption: "\\#{commandName}{}",
+									snippet: "\\#{commandName}{}",
+									meta: "cross-reference",
+									score: 60
+								}
+							for label in metadataManager.getAllLabels()
 								result.push {
 									caption: "\\#{commandName}{#{label}#{if needsClosingBrace then '}' else ''}",
 									value: "\\#{commandName}{#{label}#{if needsClosingBrace then '}' else ''}",
@@ -128,9 +130,9 @@ define [
 								callback null, result
 
 			@editor.completers = [
-				@suggestionManager
+				commandCompleter
 				SnippetCompleter
-				PackageCompleter
+                PackageCompleter
 				ReferencesCompleter
 				LabelsCompleter
 				GraphicsCompleter

+ 18 - 83
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee

@@ -1,81 +1,8 @@
-define [], () ->
-	noArgumentCommands = [
-		'item', 'hline', 'lipsum', 'centering', 'noindent', 'textwidth', 'draw',
-		'maketitle', 'newpage', 'verb', 'bibliography', 'hfill', 'par',
-		'in', 'sum', 'cdot', 'ldots', 'linewidth', 'left', 'right', 'today',
-		'clearpage', 'newline', 'endinput', 'tableofcontents', 'vfill',
-		'bigskip', 'fill', 'cleardoublepage', 'infty', 'leq', 'geq', 'times',
-		'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'varepsilon', 'zeta',
-		'eta', 'theta', 'vartheta', 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi',
-		'pi', 'varpi', 'rho', 'varrho', 'sigma', 'varsigma', 'tau', 'upsilon',
-		'phi', 'varphi', 'chi', 'psi', 'omega', 'Gamma', 'Delta', 'Theta',
-		'Lambda', 'Xi', 'Pi', 'Sigma', 'Upsilon', 'Phi', 'Psi', 'Omega'
-	]
-	singleArgumentCommands = [
-		'chapter', 'section', 'label', 'textbf', 'subsection',
-		'vspace', 'cite', 'textit', 'documentclass', 'includegraphics', 'input',
-		'emph','caption', 'ref', 'title', 'author', 'texttt', 'include',
-		'hspace', 'bibitem', 'url', 'large', 'subsubsection', 'textsc', 'date',
-		'footnote', 'small', 'thanks', 'underline', 'graphicspath', 'pageref',
-		'section*', 'subsection*', 'subsubsection*', 'sqrt', 'text',
-		'normalsize', 'footnotesize', 'Large', 'paragraph', 'pagestyle',
-		'thispagestyle', 'bibliographystyle', 'hat'
-	]
-	doubleArgumentCommands = [
-		'newcommand', 'frac', 'dfrac', 'renewcommand', 'setlength', 'href',
-		'newtheorem'
-	]
-	tripleArgumentCommands = [
-		'addcontentsline', 'newacronym', 'multicolumn'
-	]
-	special = ['LaTeX', 'TeX']
-
-	rawCommands = ['usepackage'].concat(
-		noArgumentCommands,
-		singleArgumentCommands,
-		doubleArgumentCommands,
-		tripleArgumentCommands,
-		special
-	)
-
-	noArgumentCommands = for cmd in noArgumentCommands
-		{
-			caption: "\\#{cmd}"
-			snippet: "\\#{cmd}"
-			meta: "cmd"
-		}
-	singleArgumentCommands = for cmd in singleArgumentCommands
-		{
-			caption: "\\#{cmd}{}"
-			snippet: "\\#{cmd}{$1}"
-			meta: "cmd"
-		}
-	doubleArgumentCommands = for cmd in doubleArgumentCommands
-		{
-			caption: "\\#{cmd}{}{}"
-			snippet: "\\#{cmd}{$1}{$2}"
-			meta: "cmd"
-		}
-	tripleArgumentCommands = for cmd in tripleArgumentCommands
-		{
-			caption: "\\#{cmd}{}{}{}"
-			snippet: "\\#{cmd}{$1}{$2}{$3}"
-			meta: "cmd"
-		}
-	special = for cmd in special
-		{
-			caption: "\\#{cmd}{}"
-			snippet: "\\#{cmd}{}"
-			meta: "cmd"
-		}
-
-	staticCommands = [].concat(
-		noArgumentCommands,
-		singleArgumentCommands,
-		doubleArgumentCommands,
-		tripleArgumentCommands,
-		special
-	)
+define [
+	"./top_hundred_snippets"
+], (topHundred) ->
+
+	commandNames = (snippet.caption.match(/\w+/)[0] for snippet in topHundred)
 
 	class Parser
 		constructor: (@doc, @prefix) ->
@@ -129,10 +56,10 @@ define [], () ->
 			return realCommands
 
 		# Ignore single letter commands since auto complete is moot then.
-		commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
+		commandRegex: /\\([a-zA-Z]{2,})/
 
 		nextCommand: () ->
-			i = @doc.search(@commandRegex)
+			i = @doc.search @commandRegex
 			if i == -1
 				return false
 			else
@@ -166,13 +93,21 @@ define [], () ->
 				return false
 
 	class CommandManager
+		constructor: (@metadataManager) ->
+
 		getCompletions: (editor, session, pos, prefix, callback) ->
+			packages = @metadataManager.getAllPackages()
+			packageCommands = []
+			for pkg, snippets of packages
+				for snippet in snippets
+					packageCommands.push snippet
+
 			doc = session.getValue()
 			parser = new Parser(doc, prefix)
 			commands = parser.parse()
 			completions = []
 			for command in commands
-				if command[0] not in rawCommands
+				if command[0] not in commandNames
 					caption = "\\#{command[0]}"
 					score = if caption == prefix then 99 else 50
 					snippet = caption
@@ -191,9 +126,9 @@ define [], () ->
 						meta: "cmd"
 						score: score
 					}
-			completions = completions.concat staticCommands
+			completions = completions.concat topHundred, packageCommands
 
-			callback(null, completions)
+			callback null, completions
 
 		loadCommandsFromDoc: (doc) ->
 			parser = new Parser(doc)

+ 691 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee

@@ -0,0 +1,691 @@
+define -> [{
+    "caption": "\\begin{}",
+    "snippet": "\\begin{$1}",
+    "meta": "cmd",
+    "score": 7.849662248028187
+}, {
+    "caption": "\\begin{}[]",
+    "snippet": "\\begin{$1}[$2]",
+    "meta": "cmd",
+    "score": 7.849662248028187
+}, {
+    "caption": "\\begin{}{}",
+    "snippet": "\\begin{$1}{$2}",
+    "meta": "cmd",
+    "score": 7.849662248028187
+}, {
+    "caption": "\\end{}",
+    "snippet": "\\end{$1}",
+    "meta": "cmd",
+    "score": 7.847906405228455
+}, {
+    "caption": "\\usepackage{}",
+    "snippet": "\\usepackage{$1}",
+    "meta": "cmd",
+    "score": 5.427890758130527
+}, {
+    "caption": "\\usepackage[]{}",
+    "snippet": "\\usepackage[$1]{$2}",
+    "meta": "cmd",
+    "score": 5.427890758130527
+}, {
+    "caption": "\\item",
+    "snippet": "\\item",
+    "meta": "cmd",
+    "score": 3.800886892251021
+}, {
+    "caption": "\\item[]",
+    "snippet": "\\item[$1]",
+    "meta": "cmd",
+    "score": 3.800886892251021
+}, {
+    "caption": "\\section{}",
+    "snippet": "\\section{$1}",
+    "meta": "cmd",
+    "score": 3.0952612541683835
+}, {
+    "caption": "\\textbf{}",
+    "snippet": "\\textbf{$1}",
+    "meta": "cmd",
+    "score": 2.627755982816738
+}, {
+    "caption": "\\cite{}",
+    "snippet": "\\cite{$1}",
+    "meta": "cmd",
+    "score": 2.341195220791228
+}, {
+    "caption": "\\label{}",
+    "snippet": "\\label{$1}",
+    "meta": "cmd",
+    "score": 1.897791904799601
+}, {
+    "caption": "\\textit{}",
+    "snippet": "\\textit{$1}",
+    "meta": "cmd",
+    "score": 1.6842996195493385
+}, {
+    "caption": "\\includegraphics[]{}",
+    "snippet": "\\includegraphics[$1]{$2}",
+    "meta": "cmd",
+    "score": 1.4595731795525781
+}, {
+    "caption": "\\documentclass[]{}",
+    "snippet": "\\documentclass[$1]{$2}",
+    "meta": "cmd",
+    "score": 1.4425339817971206
+}, {
+    "caption": "\\documentclass{}",
+    "snippet": "\\documentclass{$1}",
+    "meta": "cmd",
+    "score": 1.4425339817971206
+}, {
+    "caption": "\\frac{}{}",
+    "snippet": "\\frac{$1}{$2}",
+    "meta": "cmd",
+    "score": 1.4341091141105058
+}, {
+    "caption": "\\subsection{}",
+    "snippet": "\\subsection{$1}",
+    "meta": "cmd",
+    "score": 1.3890912739512353
+}, {
+    "caption": "\\hline",
+    "snippet": "\\hline",
+    "meta": "cmd",
+    "score": 1.3209538327406387
+}, {
+    "caption": "\\caption{}",
+    "snippet": "\\caption{$1}",
+    "meta": "cmd",
+    "score": 1.2569477427490174
+}, {
+    "caption": "\\centering",
+    "snippet": "\\centering",
+    "meta": "cmd",
+    "score": 1.1642881814937829
+}, {
+    "caption": "\\vspace{}",
+    "snippet": "\\vspace{$1}",
+    "meta": "cmd",
+    "score": 0.9533807826673939
+}, {
+    "caption": "\\title{}",
+    "snippet": "\\title{$1}",
+    "meta": "cmd",
+    "score": 0.9202908262245683
+}, {
+    "caption": "\\author{}",
+    "snippet": "\\author{$1}",
+    "meta": "cmd",
+    "score": 0.8973590434087177
+}, {
+    "caption": "\\author[]{}",
+    "snippet": "\\author[$1]{$2}",
+    "meta": "cmd",
+    "score": 0.8973590434087177
+}, {
+    "caption": "\\maketitle",
+    "snippet": "\\maketitle",
+    "meta": "cmd",
+    "score": 0.7504160124360846
+}, {
+    "caption": "\\textwidth",
+    "snippet": "\\textwidth",
+    "meta": "cmd",
+    "score": 0.7355328080889112
+}, {
+    "caption": "\\newcommand{}{}",
+    "snippet": "\\newcommand{$1}{$2}",
+    "meta": "cmd",
+    "score": 0.7264891987129375
+}, {
+    "caption": "\\newcommand{}[]{}",
+    "snippet": "\\newcommand{$1}[$2]{$3}",
+    "meta": "cmd",
+    "score": 0.7264891987129375
+}, {
+    "caption": "\\date{}",
+    "snippet": "\\date{$1}",
+    "meta": "cmd",
+    "score": 0.7225518453076786
+}, {
+    "caption": "\\emph{}",
+    "snippet": "\\emph{$1}",
+    "meta": "cmd",
+    "score": 0.7060308784832261
+}, {
+    "caption": "\\textsc{}",
+    "snippet": "\\textsc{$1}",
+    "meta": "cmd",
+    "score": 0.6926466355384758
+}, {
+    "caption": "\\multicolumn{}{}{}",
+    "snippet": "\\multicolumn{$1}{$2}{$3}",
+    "meta": "cmd",
+    "score": 0.5473606021405326
+}, {
+    "caption": "\\input{}",
+    "snippet": "\\input{$1}",
+    "meta": "cmd",
+    "score": 0.4966021927742672
+}, {
+    "caption": "\\alpha",
+    "snippet": "\\alpha",
+    "meta": "cmd",
+    "score": 0.49520006391384913
+}, {
+    "caption": "\\in",
+    "snippet": "\\in",
+    "meta": "cmd",
+    "score": 0.4716039670146658
+}, {
+    "caption": "\\mathbf{}",
+    "snippet": "\\mathbf{$1}",
+    "meta": "cmd",
+    "score": 0.4682018419466319
+}, {
+    "caption": "\\right",
+    "snippet": "\\right",
+    "meta": "cmd",
+    "score": 0.4299239459457309
+}, {
+    "caption": "\\left",
+    "snippet": "\\left",
+    "meta": "cmd",
+    "score": 0.42937815279867964
+}, {
+    "caption": "\\left[]",
+    "snippet": "\\left[$1]",
+    "meta": "cmd",
+    "score": 0.42937815279867964
+}, {
+    "caption": "\\sum",
+    "snippet": "\\sum",
+    "meta": "cmd",
+    "score": 0.42607994509619934
+}, {
+    "caption": "\\noindent",
+    "snippet": "\\noindent",
+    "meta": "cmd",
+    "score": 0.42355747798114207
+}, {
+    "caption": "\\chapter{}",
+    "snippet": "\\chapter{$1}",
+    "meta": "cmd",
+    "score": 0.422097569591803
+}, {
+    "caption": "\\par",
+    "snippet": "\\par",
+    "meta": "cmd",
+    "score": 0.413853376001159
+}, {
+    "caption": "\\lambda",
+    "snippet": "\\lambda",
+    "meta": "cmd",
+    "score": 0.39389600578684125
+}, {
+    "caption": "\\subsubsection{}",
+    "snippet": "\\subsubsection{$1}",
+    "meta": "cmd",
+    "score": 0.3727781330132016
+}, {
+    "caption": "\\bibitem{}",
+    "snippet": "\\bibitem{$1}",
+    "meta": "cmd",
+    "score": 0.3689547570562042
+}, {
+    "caption": "\\bibitem[]{}",
+    "snippet": "\\bibitem[$1]{$2}",
+    "meta": "cmd",
+    "score": 0.3689547570562042
+}, {
+    "caption": "\\text{}",
+    "snippet": "\\text{$1}",
+    "meta": "cmd",
+    "score": 0.3608680734736821
+}, {
+    "caption": "\\setlength{}{}",
+    "snippet": "\\setlength{$1}{$2}",
+    "meta": "cmd",
+    "score": 0.354445763583904
+}, {
+    "caption": "\\setlength",
+    "snippet": "\\setlength",
+    "meta": "cmd",
+    "score": 0.354445763583904
+}, {
+    "caption": "\\mathcal{}",
+    "snippet": "\\mathcal{$1}",
+    "meta": "cmd",
+    "score": 0.35084018920966636
+}, {
+    "caption": "\\newline",
+    "snippet": "\\newline",
+    "meta": "cmd",
+    "score": 0.3311721696201715
+}, {
+    "caption": "\\newpage",
+    "snippet": "\\newpage",
+    "meta": "cmd",
+    "score": 0.3277033727934986
+}, {
+    "caption": "\\renewcommand{}{}",
+    "snippet": "\\renewcommand{$1}{$2}",
+    "meta": "cmd",
+    "score": 0.3267437011085663
+}, {
+    "caption": "\\renewcommand",
+    "snippet": "\\renewcommand",
+    "meta": "cmd",
+    "score": 0.3267437011085663
+}, {
+    "caption": "\\theta",
+    "snippet": "\\theta",
+    "meta": "cmd",
+    "score": 0.3210417159232142
+}, {
+    "caption": "\\hspace{}",
+    "snippet": "\\hspace{$1}",
+    "meta": "cmd",
+    "score": 0.3147206476372336
+}, {
+    "caption": "\\beta",
+    "snippet": "\\beta",
+    "meta": "cmd",
+    "score": 0.3061799530337638
+}, {
+    "caption": "\\texttt{}",
+    "snippet": "\\texttt{$1}",
+    "meta": "cmd",
+    "score": 0.3019066753744355
+}, {
+    "caption": "\\times",
+    "snippet": "\\times",
+    "meta": "cmd",
+    "score": 0.2957960629411553
+}, {
+    "caption": "\\citep{}",
+    "snippet": "\\citep{$1}",
+    "meta": "cmd",
+    "score": 0.2941882834697057
+}, {
+    "caption": "\\color[]{}",
+    "snippet": "\\color[$1]{$2}",
+    "meta": "cmd",
+    "score": 0.2864294797053033
+}, {
+    "caption": "\\color{}",
+    "snippet": "\\color{$1}",
+    "meta": "cmd",
+    "score": 0.2864294797053033
+}, {
+    "caption": "\\mu",
+    "snippet": "\\mu",
+    "meta": "cmd",
+    "score": 0.27635652476799255
+}, {
+    "caption": "\\bibliography{}",
+    "snippet": "\\bibliography{$1}",
+    "meta": "cmd",
+    "score": 0.2659628337907604
+}, {
+    "caption": "\\linewidth",
+    "snippet": "\\linewidth",
+    "meta": "cmd",
+    "score": 0.2639498312518439
+}, {
+    "caption": "\\delta",
+    "snippet": "\\delta",
+    "meta": "cmd",
+    "score": 0.2620578600722735
+}, {
+    "caption": "\\sigma",
+    "snippet": "\\sigma",
+    "meta": "cmd",
+    "score": 0.25940147926344487
+}, {
+    "caption": "\\pi",
+    "snippet": "\\pi",
+    "meta": "cmd",
+    "score": 0.25920934567729714
+}, {
+    "caption": "\\hat{}",
+    "snippet": "\\hat{$1}",
+    "meta": "cmd",
+    "score": 0.25264309033778715
+}, {
+    "caption": "\\hat",
+    "snippet": "\\hat",
+    "meta": "cmd",
+    "score": 0.25264309033778715
+}, {
+    "caption": "\\bibliographystyle{}",
+    "snippet": "\\bibliographystyle{$1}",
+    "meta": "cmd",
+    "score": 0.25122317941387773
+}, {
+    "caption": "\\small",
+    "snippet": "\\small",
+    "meta": "cmd",
+    "score": 0.2447632045426295
+}, {
+    "caption": "\\small{}",
+    "snippet": "\\small{$1}",
+    "meta": "cmd",
+    "score": 0.2447632045426295
+}, {
+    "caption": "\\LaTeX",
+    "snippet": "\\LaTeX",
+    "meta": "cmd",
+    "score": 0.2334089308452787
+}, {
+    "caption": "\\LaTeX{}",
+    "snippet": "\\LaTeX{$1}",
+    "meta": "cmd",
+    "score": 0.2334089308452787
+}, {
+    "caption": "\\cdot",
+    "snippet": "\\cdot",
+    "meta": "cmd",
+    "score": 0.23029085545522762
+}, {
+    "caption": "\\footnote{}",
+    "snippet": "\\footnote{$1}",
+    "meta": "cmd",
+    "score": 0.2253056071787701
+}, {
+    "caption": "\\newtheorem{}[]{}",
+    "snippet": "\\newtheorem{$1}[$2]{$3}",
+    "meta": "cmd",
+    "score": 0.215689795055434
+}, {
+    "caption": "\\newtheorem{}{}",
+    "snippet": "\\newtheorem{$1}{$2}",
+    "meta": "cmd",
+    "score": 0.215689795055434
+}, {
+    "caption": "\\newtheorem{}{}[]",
+    "snippet": "\\newtheorem{$1}{$2}[$3]",
+    "meta": "cmd",
+    "score": 0.215689795055434
+}, {
+    "caption": "\\Delta",
+    "snippet": "\\Delta",
+    "meta": "cmd",
+    "score": 0.21386475063892618
+}, {
+    "caption": "\\tau",
+    "snippet": "\\tau",
+    "meta": "cmd",
+    "score": 0.21236188205859796
+}, {
+    "caption": "\\hfill",
+    "snippet": "\\hfill",
+    "meta": "cmd",
+    "score": 0.2058248088519886
+}, {
+    "caption": "\\leq",
+    "snippet": "\\leq",
+    "meta": "cmd",
+    "score": 0.20498894440637172
+}, {
+    "caption": "\\footnotesize",
+    "snippet": "\\footnotesize",
+    "meta": "cmd",
+    "score": 0.2038592081252624
+}, {
+    "caption": "\\footnotesize{}",
+    "snippet": "\\footnotesize{$1}",
+    "meta": "cmd",
+    "score": 0.2038592081252624
+}, {
+    "caption": "\\large",
+    "snippet": "\\large",
+    "meta": "cmd",
+    "score": 0.20377416734108866
+}, {
+    "caption": "\\large{}",
+    "snippet": "\\large{$1}",
+    "meta": "cmd",
+    "score": 0.20377416734108866
+}, {
+    "caption": "\\sqrt{}",
+    "snippet": "\\sqrt{$1}",
+    "meta": "cmd",
+    "score": 0.20240160977404634
+}, {
+    "caption": "\\epsilon",
+    "snippet": "\\epsilon",
+    "meta": "cmd",
+    "score": 0.2005136761359043
+}, {
+    "caption": "\\Large",
+    "snippet": "\\Large",
+    "meta": "cmd",
+    "score": 0.1987771081149759
+}, {
+    "caption": "\\Large{}",
+    "snippet": "\\Large{$1}",
+    "meta": "cmd",
+    "score": 0.1987771081149759
+}, {
+    "caption": "\\cvitem{}{}",
+    "snippet": "\\cvitem{$1}{$2}",
+    "meta": "cmd",
+    "score": 0.19605476980016281
+}, {
+    "caption": "\\rho",
+    "snippet": "\\rho",
+    "meta": "cmd",
+    "score": 0.1959287380541684
+}, {
+    "caption": "\\omega",
+    "snippet": "\\omega",
+    "meta": "cmd",
+    "score": 0.19326783415115262
+}, {
+    "caption": "\\mathrm{}",
+    "snippet": "\\mathrm{$1}",
+    "meta": "cmd",
+    "score": 0.19117752976172653
+}, {
+    "caption": "\\boldsymbol{}",
+    "snippet": "\\boldsymbol{$1}",
+    "meta": "cmd",
+    "score": 0.18137737738638837
+}, {
+    "caption": "\\boldsymbol",
+    "snippet": "\\boldsymbol",
+    "meta": "cmd",
+    "score": 0.18137737738638837
+}, {
+    "caption": "\\gamma",
+    "snippet": "\\gamma",
+    "meta": "cmd",
+    "score": 0.17940276535431304
+}, {
+    "caption": "\\clearpage",
+    "snippet": "\\clearpage",
+    "meta": "cmd",
+    "score": 0.1789117552185788
+}, {
+    "caption": "\\infty",
+    "snippet": "\\infty",
+    "meta": "cmd",
+    "score": 0.17837290019711305
+}, {
+    "caption": "\\phi",
+    "snippet": "\\phi",
+    "meta": "cmd",
+    "score": 0.17405809173097808
+}, {
+    "caption": "\\partial",
+    "snippet": "\\partial",
+    "meta": "cmd",
+    "score": 0.17168102367966637
+}, {
+    "caption": "\\include{}",
+    "snippet": "\\include{$1}",
+    "meta": "cmd",
+    "score": 0.1547080054979312
+}, {
+    "caption": "\\address{}",
+    "snippet": "\\address{$1}",
+    "meta": "cmd",
+    "score": 0.1525055392611109
+}, {
+    "caption": "\\address[]{}",
+    "snippet": "\\address[$1]{$2}",
+    "meta": "cmd",
+    "score": 0.1525055392611109
+}, {
+    "caption": "\\address{}{}{}",
+    "snippet": "\\address{$1}{$2}{$3}",
+    "meta": "cmd",
+    "score": 0.1525055392611109
+}, {
+    "caption": "\\quad",
+    "snippet": "\\quad",
+    "meta": "cmd",
+    "score": 0.15242755832392743
+}, {
+    "caption": "\\email{}",
+    "snippet": "\\email{$1}",
+    "meta": "cmd",
+    "score": 0.1522294670109857
+}, {
+    "caption": "\\paragraph{}",
+    "snippet": "\\paragraph{$1}",
+    "meta": "cmd",
+    "score": 0.152074250347974
+}, {
+    "caption": "\\varepsilon",
+    "snippet": "\\varepsilon",
+    "meta": "cmd",
+    "score": 0.05411564201390573
+}, {
+    "caption": "\\zeta",
+    "snippet": "\\zeta",
+    "meta": "cmd",
+    "score": 0.023330249803752954
+}, {
+    "caption": "\\eta",
+    "snippet": "\\eta",
+    "meta": "cmd",
+    "score": 0.11088718379889091
+}, {
+    "caption": "\\vartheta",
+    "snippet": "\\vartheta",
+    "meta": "cmd",
+    "score": 0.0025822992078068712
+}, {
+    "caption": "\\iota",
+    "snippet": "\\iota",
+    "meta": "cmd",
+    "score": 0.0024774003791525486
+}, {
+    "caption": "\\kappa",
+    "snippet": "\\kappa",
+    "meta": "cmd",
+    "score": 0.04887876299369008
+}, {
+    "caption": "\\nu",
+    "snippet": "\\nu",
+    "meta": "cmd",
+    "score": 0.09206962821059342
+}, {
+    "caption": "\\xi",
+    "snippet": "\\xi",
+    "meta": "cmd",
+    "score": 0.06496042899265699
+}, {
+    "caption": "\\varpi",
+    "snippet": "\\varpi",
+    "meta": "cmd",
+    "score": 0.0007039358167790341
+}, {
+    "caption": "\\varrho",
+    "snippet": "\\varrho",
+    "meta": "cmd",
+    "score": 0.0011279491613898612
+}, {
+    "caption": "\\varsigma",
+    "snippet": "\\varsigma",
+    "meta": "cmd",
+    "score": 0.0010424880711234978
+}, {
+    "caption": "\\varsigma{}",
+    "snippet": "\\varsigma{$1}",
+    "meta": "cmd",
+    "score": 0.0010424880711234978
+}, {
+    "caption": "\\upsilon",
+    "snippet": "\\upsilon",
+    "meta": "cmd",
+    "score": 0.00420715572598688
+}, {
+    "caption": "\\varphi",
+    "snippet": "\\varphi",
+    "meta": "cmd",
+    "score": 0.03351251516668212
+}, {
+    "caption": "\\chi",
+    "snippet": "\\chi",
+    "meta": "cmd",
+    "score": 0.043373492287805675
+}, {
+    "caption": "\\psi",
+    "snippet": "\\psi",
+    "meta": "cmd",
+    "score": 0.09994508706163642
+}, {
+    "caption": "\\Gamma",
+    "snippet": "\\Gamma",
+    "meta": "cmd",
+    "score": 0.04801549269801977
+}, {
+    "caption": "\\Theta",
+    "snippet": "\\Theta",
+    "meta": "cmd",
+    "score": 0.038090902146599444
+}, {
+    "caption": "\\Lambda",
+    "snippet": "\\Lambda",
+    "meta": "cmd",
+    "score": 0.032206594305977686
+}, {
+    "caption": "\\Xi",
+    "snippet": "\\Xi",
+    "meta": "cmd",
+    "score": 0.01060997225400494
+}, {
+    "caption": "\\Pi",
+    "snippet": "\\Pi",
+    "meta": "cmd",
+    "score": 0.021264671817473237
+}, {
+    "caption": "\\Sigma",
+    "snippet": "\\Sigma",
+    "meta": "cmd",
+    "score": 0.05769642802079917
+}, {
+    "caption": "\\Upsilon",
+    "snippet": "\\Upsilon",
+    "meta": "cmd",
+    "score": 0.00032875192955749566
+}, {
+    "caption": "\\Phi",
+    "snippet": "\\Phi",
+    "meta": "cmd",
+    "score": 0.0538724950042562
+}, {
+    "caption": "\\Psi",
+    "snippet": "\\Psi",
+    "meta": "cmd",
+    "score": 0.03056589143021648
+}, {
+    "caption": "\\Omega",
+    "snippet": "\\Omega",
+    "meta": "cmd",
+    "score": 0.09490387997853639
+}]

+ 85 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/metadata/MetadataManager.coffee

@@ -0,0 +1,85 @@
+define [
+	"ace/ace"
+], () ->
+	Range = ace.require("ace/range").Range
+
+	getLastCommandFragment = (lineUpToCursor) ->
+		if m = lineUpToCursor.match(/(\\[^\\]+)$/)
+			return m[1]
+		else
+			return null
+
+	class MetadataManager
+		constructor: (@$scope, @editor, @element, @Metadata) ->
+			@debouncer = {}  # DocId => Timeout
+
+			onChange = (change) =>
+				if change.remote
+					return
+				if change.action not in ['remove', 'insert']
+					return
+				cursorPosition = @editor.getCursorPosition()
+				end = change.end
+				range = new Range(end.row, 0, end.row, end.column)
+				lineUpToCursor = @editor.getSession().getTextRange range
+				if lineUpToCursor.trim() == '%' or lineUpToCursor.startsWith '\\'
+					range = new Range(end.row, 0, end.row, end.column + 80)
+					lineUpToCursor = @editor.getSession().getTextRange range
+				commandFragment = getLastCommandFragment lineUpToCursor
+
+				linesContainPackage = _.any(
+					change.lines,
+					(line) -> line.match(/^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
+				)
+				linesContainReqPackage = _.any(
+					change.lines,
+					(line) -> line.match(/^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
+				)
+				linesContainLabel = _.any(
+					change.lines,
+					(line) -> line.match(/\\label{(.{0,80}?)}/)
+				)
+				linesContainMeta =
+					linesContainPackage or
+					linesContainLabel or
+					linesContainReqPackage
+
+				lastCommandFragmentIsLabel = commandFragment?.startsWith '\\label{'
+				lastCommandFragmentIsPackage = commandFragment?.startsWith '\\usepackage'
+				lastCommandFragmentIsReqPack = commandFragment?.startsWith '\\RequirePackage'
+				lastCommandFragmentIsMeta =
+					lastCommandFragmentIsPackage or
+					lastCommandFragmentIsLabel or
+					lastCommandFragmentIsReqPack
+
+				if linesContainMeta or lastCommandFragmentIsMeta
+					@scheduleLoadCurrentDocMetaFromServer()
+
+			@editor.on "changeSession", (e) =>
+				e.oldSession.off "change", onChange
+				e.session.on "change", onChange
+
+
+		loadDocMetaFromServer: (docId) ->
+			@Metadata.loadDocMetaFromServer docId
+
+		scheduleLoadCurrentDocMetaFromServer: () ->
+			# De-bounce loading labels with a timeout
+			currentDocId = @$scope.docId
+			existingTimeout = @debouncer[currentDocId]
+			if existingTimeout?
+				clearTimeout(existingTimeout)
+				delete @debouncer[currentDocId]
+			@debouncer[currentDocId] = setTimeout(
+				() =>
+					@loadDocMetaFromServer currentDocId
+					delete @debouncer[currentDocId]
+				, 1000
+				, this
+			)
+
+		getAllLabels: () ->
+			@Metadata.getAllLabels()
+
+		getAllPackages: () ->
+			@Metadata.getAllPackages()

+ 1 - 2
services/web/public/coffee/ide/labels/LabelsManager.coffee

@@ -1,5 +1,4 @@
-define [
-], () ->
+define [], () ->
 
 	class LabelsManager
 

+ 13 - 0
services/web/public/coffee/ide/metadata/MetadataManager.coffee

@@ -0,0 +1,13 @@
+define [], () ->
+
+	class MetadataManager
+
+		constructor: (@ide, @$scope, @metadata) ->
+
+			@ide.socket.on 'broadcastDocMeta', (data) =>
+				@metadata.onBroadcastDocMeta data
+			@$scope.$on 'entity:deleted', @metadata.onEntityDeleted
+			@$scope.$on 'file:upload:complete', @metadata.fileUploadComplete
+
+		loadProjectMetaFromServer: () ->
+			@metadata.loadProjectMetaFromServer()

+ 49 - 0
services/web/public/coffee/ide/metadata/services/metadata.coffee

@@ -0,0 +1,49 @@
+define [
+	"base"
+], (App) ->
+
+	App.factory 'metadata', ($http, ide) ->
+
+		state = {documents: {}}
+
+		metadata = {state: state}
+
+		metadata.onBroadcastDocMeta = (data) ->
+			if data.docId? and data.meta?
+				state.documents[data.docId] = data.meta
+
+		metadata.onEntityDeleted = (e, entity) ->
+			if entity.type == 'doc'
+				delete state.documents[entity.id]
+
+		metadata.onFileUploadComplete = (e, upload) ->
+			if upload.entity_type == 'doc'
+				metadata.loadDocMetaFromServer upload.entity_id
+
+		metadata.getAllLabels = () ->
+			_.flatten(meta.labels for docId, meta of state.documents)
+
+		metadata.getAllPackages = () ->
+			packageCommandMapping = {}
+			for _docId, meta of state.documents
+				for packageName, commandSnippets of meta.packages
+					packageCommandMapping[packageName] = commandSnippets
+			return packageCommandMapping
+
+		metadata.loadProjectMetaFromServer = () ->
+			$http
+				.get("/project/#{window.project_id}/metadata")
+				.then (response) ->
+					{ data } = response
+					if data.projectMeta
+						for docId, docMeta of data.projectMeta
+							state.documents[docId] = docMeta
+
+		metadata.loadDocMetaFromServer = (docId) ->
+			$http
+				.post(
+					"/project/#{window.project_id}/doc/#{docId}/metadata",
+					{_csrf: window.csrfToken}
+				)
+
+		return metadata

+ 0 - 119
services/web/test/unit/coffee/Labels/LabelsControllerTests.coffee

@@ -1,119 +0,0 @@
-chai = require('chai')
-chai.should()
-expect = chai.expect
-sinon = require("sinon")
-modulePath = "../../../../app/js/Features/Labels/LabelsController"
-SandboxedModule = require('sandboxed-module')
-
-
-describe 'LabelsController', ->
-	beforeEach ->
-		@projectId = 'somekindofid'
-		@EditorRealTimeController = {
-			emitToRoom: sinon.stub()
-		}
-		@LabelsHandler = {
-			getAllLabelsForProject: sinon.stub()
-			getLabelsForDoc: sinon.stub()
-		}
-		@LabelsController = SandboxedModule.require modulePath, requires:
-			'logger-sharelatex': {log: sinon.stub(), err: sinon.stub()}
-			'../Editor/EditorRealTimeController': @EditorRealTimeController
-			'./LabelsHandler': @LabelsHandler
-
-	describe 'getAllLabels', ->
-		beforeEach ->
-			@fakeLabels = {'somedoc': ['a_label']}
-			@LabelsHandler.getAllLabelsForProject = sinon.stub().callsArgWith(1, null, @fakeLabels)
-			@req = {params: {project_id: @projectId}}
-			@res = {json: sinon.stub()}
-			@next = sinon.stub()
-
-		it 'should call LabelsHandler.getAllLabelsForProject', () ->
-			@LabelsController.getAllLabels(@req, @res, @next)
-			@LabelsHandler.getAllLabelsForProject.callCount.should.equal 1
-			@LabelsHandler.getAllLabelsForProject.calledWith(@projectId).should.equal true
-
-		it 'should call not call next with an error', () ->
-			@LabelsController.getAllLabels(@req, @res, @next)
-			@next.callCount.should.equal 0
-
-		it 'should send a json response', () ->
-			@LabelsController.getAllLabels(@req, @res, @next)
-			@res.json.callCount.should.equal 1
-			expect(@res.json.lastCall.args[0]).to.have.all.keys ['projectId', 'projectLabels']
-
-		describe 'when LabelsHandler.getAllLabelsForProject produces an error', ->
-			beforeEach ->
-				@LabelsHandler.getAllLabelsForProject = sinon.stub().callsArgWith(1, new Error('woops'))
-				@req = {params: {project_id: @projectId}}
-				@res = {json: sinon.stub()}
-				@next = sinon.stub()
-
-			it 'should call LabelsHandler.getAllLabelsForProject', () ->
-				@LabelsController.getAllLabels(@req, @res, @next)
-				@LabelsHandler.getAllLabelsForProject.callCount.should.equal 1
-				@LabelsHandler.getAllLabelsForProject.calledWith(@projectId).should.equal true
-
-			it 'should call next with an error', ->
-				@LabelsController.getAllLabels(@req, @res, @next)
-				@next.callCount.should.equal 1
-				expect(@next.lastCall.args[0]).to.be.instanceof Error
-
-			it 'should not send a json response', ->
-				@LabelsController.getAllLabels(@req, @res, @next)
-				@res.json.callCount.should.equal 0
-
-	describe 'broadcastLabelsForDoc', ->
-		beforeEach ->
-			@LabelsHandler.getLabelsForDoc = sinon.stub().callsArgWith(2, null, @fakeLabels)
-			@EditorRealTimeController.emitToRoom = sinon.stub()
-			@docId = 'somedoc'
-			@req = {params: {project_id: @projectId, doc_id: @docId}}
-			@res = {sendStatus: sinon.stub()}
-			@next = sinon.stub()
-
-		it 'should call LabelsHandler.getLabelsForDoc', () ->
-			@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-			@LabelsHandler.getLabelsForDoc.callCount.should.equal 1
-			@LabelsHandler.getLabelsForDoc.calledWith(@projectId).should.equal true
-
-		it 'should call not call next with an error', () ->
-			@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-			@next.callCount.should.equal 0
-
-		it 'should send a success response', () ->
-			@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-			@res.sendStatus.callCount.should.equal 1
-			@res.sendStatus.calledWith(200).should.equal true
-
-		it 'should emit a message to room', () ->
-			@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-			@EditorRealTimeController.emitToRoom.callCount.should.equal 1
-			lastCall = @EditorRealTimeController.emitToRoom.lastCall
-			expect(lastCall.args[0]).to.equal @projectId
-			expect(lastCall.args[1]).to.equal 'broadcastDocLabels'
-			expect(lastCall.args[2]).to.have.all.keys ['docId', 'labels']
-
-		describe 'when LabelsHandler.getLabelsForDoc produces an error', ->
-			beforeEach ->
-				@LabelsHandler.getLabelsForDoc = sinon.stub().callsArgWith(2, new Error('woops'))
-				@EditorRealTimeController.emitToRoom = sinon.stub()
-				@docId = 'somedoc'
-				@req = {params: {project_id: @projectId, doc_id: @docId}}
-				@res = {json: sinon.stub()}
-				@next = sinon.stub()
-
-			it 'should call LabelsHandler.getLabelsForDoc', () ->
-				@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-				@LabelsHandler.getLabelsForDoc.callCount.should.equal 1
-				@LabelsHandler.getLabelsForDoc.calledWith(@projectId).should.equal true
-
-			it 'should call next with an error', ->
-				@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-				@next.callCount.should.equal 1
-				expect(@next.lastCall.args[0]).to.be.instanceof Error
-
-			it 'should not send a json response', ->
-				@LabelsController.broadcastLabelsForDoc(@req, @res, @next)
-				@res.json.callCount.should.equal 0

+ 0 - 134
services/web/test/unit/coffee/Labels/LabelsHandlerTests.coffee

@@ -1,134 +0,0 @@
-chai = require('chai')
-chai.should()
-expect = chai.expect
-sinon = require("sinon")
-modulePath = "../../../../app/js/Features/Labels/LabelsHandler"
-SandboxedModule = require('sandboxed-module')
-
-
-describe 'LabelsHandler', ->
-	beforeEach ->
-		@projectId = 'someprojectid'
-		@docId = 'somedocid'
-		@ProjectEntityHandler = {
-			getAllDocs: sinon.stub()
-			getDoc: sinon.stub()
-		}
-		@DocumentUpdaterHandler = {
-			flushDocToMongo: sinon.stub()
-		}
-		@LabelsHandler = SandboxedModule.require modulePath, requires:
-			'../Project/ProjectEntityHandler': @ProjectEntityHandler
-			'../DocumentUpdater/DocumentUpdaterHandler': @DocumentUpdaterHandler
-
-	describe 'extractLabelsFromDoc', ->
-		beforeEach ->
-			@lines = [
-				'one',
-				'two',
-				'three \\label{aaa}',
-				'four five',
-				'\\label{bbb}',
-				'six seven'
-			]
-
-		it 'should extract all the labels', ->
-			docLabels = @LabelsHandler.extractLabelsFromDoc @lines
-			expect(docLabels).to.deep.equal ['aaa', 'bbb']
-
-	describe 'extractLabelsFromProjectDocs', ->
-		beforeEach ->
-			@docs = {
-				'doc_one': {
-					_id: 'id_one',
-					lines: ['one', '\\label{aaa} two', 'three']
-				},
-				'doc_two': {
-					_id: 'id_two',
-					lines: ['four']
-				},
-				'doc_three': {
-					_id: 'id_three',
-					lines: ['\\label{bbb}', 'five six', 'seven eight \\label{ccc} nine']
-				}
-			}
-
-		it 'should extract all the labels', ->
-			projectLabels = @LabelsHandler.extractLabelsFromProjectDocs @docs
-			expect(projectLabels).to.deep.equal {
-				'id_one': ['aaa'],
-				'id_two': [],
-				'id_three': ['bbb', 'ccc']
-			}
-
-	describe 'getLabelsForDoc', ->
-		beforeEach ->
-			@fakeLines = ['one', '\\label{aaa}', 'two']
-			@fakeLabels = ['aaa']
-			@DocumentUpdaterHandler.flushDocToMongo = sinon.stub().callsArgWith(2, null)
-			@ProjectEntityHandler.getDoc = sinon.stub().callsArgWith(2, null, @fakeLines)
-			@LabelsHandler.extractLabelsFromDoc = sinon.stub().returns(@fakeLabels)
-			@call = (callback) =>
-				@LabelsHandler.getLabelsForDoc @projectId, @docId, callback
-
-		it 'should not produce an error', (done) ->
-			@call (err, docLabels) =>
-				expect(err).to.equal null
-				done()
-
-		it 'should produce docLabels', (done) ->
-			@call (err, docLabels) =>
-				expect(docLabels).to.equal @fakeLabels
-				done()
-
-		it 'should call flushDocToMongo', (done) ->
-			@call (err, docLabels) =>
-				@DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal 1
-				@DocumentUpdaterHandler.flushDocToMongo.calledWith(@projectId, @docId).should.equal true
-				done()
-
-		it 'should call getDoc', (done) ->
-			@call (err, docLabels) =>
-				@ProjectEntityHandler.getDoc.callCount.should.equal 1
-				@ProjectEntityHandler.getDoc.calledWith(@projectId, @docId).should.equal true
-				done()
-
-		it 'should call extractLabelsFromDoc', (done) ->
-			@call (err, docLabels) =>
-				@LabelsHandler.extractLabelsFromDoc.callCount.should.equal 1
-				@LabelsHandler.extractLabelsFromDoc.calledWith(@fakeLines).should.equal true
-				done()
-
-	describe 'getAllLabelsForProject', ->
-		beforeEach ->
-			@fakeDocs = {
-				'doc_one': {lines: ['\\label{aaa}']}
-			}
-			@fakeLabels = ['aaa']
-			@DocumentUpdaterHandler.flushProjectToMongo = sinon.stub().callsArgWith(1, null)
-			@ProjectEntityHandler.getAllDocs = sinon.stub().callsArgWith(1, null, @fakeDocs)
-			@LabelsHandler.extractLabelsFromProjectDocs = sinon.stub().returns(@fakeLabels)
-			@call = (callback) =>
-				@LabelsHandler.getAllLabelsForProject @projectId, callback
-
-		it 'should not produce an error', (done) ->
-			@call (err, projectLabels) =>
-				expect(err).to.equal null
-				done()
-
-		it 'should produce projectLabels', (done) ->
-			@call (err, projectLabels) =>
-				expect(projectLabels).to.equal @fakeLabels
-				done()
-
-		it 'should call getAllDocs', (done) ->
-			@call (err, projectLabels) =>
-				@ProjectEntityHandler.getAllDocs.callCount.should.equal 1
-				@ProjectEntityHandler.getAllDocs.calledWith(@projectId).should.equal true
-				done()
-
-		it 'should call extractLabelsFromDoc', (done) ->
-			@call (err, docLabels) =>
-				@LabelsHandler.extractLabelsFromProjectDocs.callCount.should.equal 1
-				@LabelsHandler.extractLabelsFromProjectDocs.calledWith(@fakeDocs).should.equal true
-				done()

+ 119 - 0
services/web/test/unit/coffee/Metadata/MetaControllerTests.coffee

@@ -0,0 +1,119 @@
+chai = require('chai')
+chai.should()
+expect = chai.expect
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/Metadata/MetaController"
+SandboxedModule = require('sandboxed-module')
+
+
+describe 'MetaController', ->
+	beforeEach ->
+		@projectId = 'somekindofid'
+		@EditorRealTimeController = {
+			emitToRoom: sinon.stub()
+		}
+		@MetaHandler = {
+			getAllMetaForProject: sinon.stub()
+			getMetaForDoc: sinon.stub()
+		}
+		@MetadataController = SandboxedModule.require modulePath, requires:
+			'logger-sharelatex': {log: sinon.stub(), err: sinon.stub()}
+			'../Editor/EditorRealTimeController': @EditorRealTimeController
+			'./MetaHandler': @MetaHandler
+
+	describe 'getMetadata', ->
+		beforeEach ->
+			@fakeLabels = {'somedoc': ['a_label']}
+			@MetaHandler.getAllMetaForProject = sinon.stub().callsArgWith(1, null, @fakeLabels)
+			@req = {params: {project_id: @projectId}}
+			@res = {json: sinon.stub()}
+			@next = sinon.stub()
+
+		it 'should call MetaHandler.getAllMetaForProject', () ->
+			@MetadataController.getMetadata(@req, @res, @next)
+			@MetaHandler.getAllMetaForProject.callCount.should.equal 1
+			@MetaHandler.getAllMetaForProject.calledWith(@projectId).should.equal true
+
+		it 'should call not call next with an error', () ->
+			@MetadataController.getMetadata(@req, @res, @next)
+			@next.callCount.should.equal 0
+
+		it 'should send a json response', () ->
+			@MetadataController.getMetadata(@req, @res, @next)
+			@res.json.callCount.should.equal 1
+			expect(@res.json.lastCall.args[0]).to.have.all.keys ['projectId', 'projectMeta']
+
+		describe 'when MetaHandler.getAllMetaForProject produces an error', ->
+			beforeEach ->
+				@MetaHandler.getAllMetaForProject = sinon.stub().callsArgWith(1, new Error('woops'))
+				@req = {params: {project_id: @projectId}}
+				@res = {json: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'should call MetaHandler.getAllMetaForProject', () ->
+				@MetadataController.getMetadata(@req, @res, @next)
+				@MetaHandler.getAllMetaForProject.callCount.should.equal 1
+				@MetaHandler.getAllMetaForProject.calledWith(@projectId).should.equal true
+
+			it 'should call next with an error', ->
+				@MetadataController.getMetadata(@req, @res, @next)
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+			it 'should not send a json response', ->
+				@MetadataController.getMetadata(@req, @res, @next)
+				@res.json.callCount.should.equal 0
+
+	describe 'broadcastMetadataForDoc', ->
+		beforeEach ->
+			@MetaHandler.getMetaForDoc = sinon.stub().callsArgWith(2, null, @fakeLabels)
+			@EditorRealTimeController.emitToRoom = sinon.stub()
+			@docId = 'somedoc'
+			@req = {params: {project_id: @projectId, doc_id: @docId}}
+			@res = {sendStatus: sinon.stub()}
+			@next = sinon.stub()
+
+		it 'should call MetaHandler.getMetaForDoc', () ->
+			@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+			@MetaHandler.getMetaForDoc.callCount.should.equal 1
+			@MetaHandler.getMetaForDoc.calledWith(@projectId).should.equal true
+
+		it 'should call not call next with an error', () ->
+			@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+			@next.callCount.should.equal 0
+
+		it 'should send a success response', () ->
+			@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+			@res.sendStatus.callCount.should.equal 1
+			@res.sendStatus.calledWith(200).should.equal true
+
+		it 'should emit a message to room', () ->
+			@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+			@EditorRealTimeController.emitToRoom.callCount.should.equal 1
+			lastCall = @EditorRealTimeController.emitToRoom.lastCall
+			expect(lastCall.args[0]).to.equal @projectId
+			expect(lastCall.args[1]).to.equal 'broadcastDocMeta'
+			expect(lastCall.args[2]).to.have.all.keys ['docId', 'meta']
+
+		describe 'when MetaHandler.getMetaForDoc produces an error', ->
+			beforeEach ->
+				@MetaHandler.getMetaForDoc = sinon.stub().callsArgWith(2, new Error('woops'))
+				@EditorRealTimeController.emitToRoom = sinon.stub()
+				@docId = 'somedoc'
+				@req = {params: {project_id: @projectId, doc_id: @docId}}
+				@res = {json: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'should call MetaHandler.getMetaForDoc', () ->
+				@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+				@MetaHandler.getMetaForDoc.callCount.should.equal 1
+				@MetaHandler.getMetaForDoc.calledWith(@projectId).should.equal true
+
+			it 'should call next with an error', ->
+				@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+			it 'should not send a json response', ->
+				@MetadataController.broadcastMetadataForDoc(@req, @res, @next)
+				@res.json.callCount.should.equal 0

+ 243 - 0
services/web/test/unit/coffee/Metadata/MetaHandlerTests.coffee

@@ -0,0 +1,243 @@
+chai = require('chai')
+chai.should()
+expect = chai.expect
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/Metadata/MetaHandler"
+SandboxedModule = require('sandboxed-module')
+
+
+describe 'MetaHandler', ->
+	beforeEach ->
+		@projectId = 'someprojectid'
+		@docId = 'somedocid'
+		@ProjectEntityHandler = {
+			getAllDocs: sinon.stub()
+			getDoc: sinon.stub()
+		}
+		@DocumentUpdaterHandler = {
+			flushDocToMongo: sinon.stub()
+		}
+		@packageMapping =
+			foo: [
+				{
+					caption: '\\bar'
+					snippet: '\\bar'
+					meta: 'foo-cmd'
+					score: 12
+				}, {
+					caption: '\\bat[]{}'
+					snippet: '\\bar[$1]{$2}'
+					meta: 'foo-cmd'
+					score: 10
+				}
+			],
+			baz: [
+				{
+					caption: '\\longercommandtest{}'
+					snippet: '\\longercommandtest{$1}'
+					meta: 'baz-cmd'
+					score: 50
+				}
+			]
+
+		@MetaHandler = SandboxedModule.require modulePath, requires:
+			'../Project/ProjectEntityHandler': @ProjectEntityHandler
+			'../DocumentUpdater/DocumentUpdaterHandler': @DocumentUpdaterHandler
+			'./packageMapping': @packageMapping
+
+	describe 'extractMetaFromDoc', ->
+		beforeEach ->
+			@lines = [
+				'\\usepackage{foo}'
+				'\\usepackage{amsmath, booktabs}'
+				'one'
+				'two'
+				'three \\label{aaa}'
+				'four five'
+				'\\label{bbb}'
+				'six seven'
+			]
+
+		it 'should extract all the labels and packages', ->
+			docMeta = @MetaHandler.extractMetaFromDoc @lines
+			expect(docMeta).to.deep.equal {
+				labels: ['aaa', 'bbb']
+				packages:
+					foo: [
+						{
+							caption: '\\bar'
+							snippet: '\\bar'
+							meta: 'foo-cmd'
+							score: 12
+						}, {
+							caption: '\\bat[]{}'
+							snippet: '\\bar[$1]{$2}'
+							meta: 'foo-cmd'
+							score: 10
+						}
+					]
+			}
+
+	describe 'extractMetaFromProjectDocs', ->
+		beforeEach ->
+			@docs =
+				'doc_one':
+					_id: 'id_one'
+					lines: ['one', '\\label{aaa} two', 'three']
+				'doc_two':
+					_id: 'id_two'
+					lines: ['four']
+				'doc_three':
+					_id: 'id_three'
+					lines: [
+						'\\label{bbb}'
+						'five six'
+						'seven eight \\label{ccc} nine'
+					]
+				'doc_four':
+					_id: 'id_four'
+					lines: [
+						'\\usepackage[width=\\textwidth]{baz}'
+						'\\usepackage{amsmath}'
+					]
+				'doc_five':
+					_id: 'id_five'
+					lines: [
+						'\\usepackage{foo,baz}'
+						'\\usepackage[options=foo]{hello}'
+						'some text'
+						'\\section{this}\\label{sec:intro}'
+						'In Section \\ref{sec:intro} we saw'
+						'nothing'
+					]
+
+		it 'should extract all metadata', ->
+			projectMeta = @MetaHandler.extractMetaFromProjectDocs @docs
+			expect(projectMeta).to.deep.equal {
+				'id_one': {labels: ['aaa'], packages: {}}
+				'id_two': {labels: [], packages: {}}
+				'id_three': {labels: ['bbb', 'ccc'], packages: {}}
+				'id_four':
+					labels: []
+					packages:
+						baz: [{
+							caption: '\\longercommandtest{}'
+							snippet: '\\longercommandtest{$1}'
+							meta: 'baz-cmd'
+							score: 50}]
+				'id_five':
+					labels: ['sec:intro']
+					packages:
+						foo: [
+							{
+								caption: '\\bar'
+								snippet: '\\bar'
+								meta: 'foo-cmd'
+								score: 12
+							}, {
+								caption: '\\bat[]{}'
+								snippet: '\\bar[$1]{$2}'
+								meta: 'foo-cmd'
+								score: 10
+							}
+						]
+						baz: [
+							{
+								caption: '\\longercommandtest{}'
+								snippet: '\\longercommandtest{$1}'
+								meta: 'baz-cmd'
+								score: 50
+							}
+						]
+			}
+
+	describe 'getMetaForDoc', ->
+		beforeEach ->
+			@fakeLines = ['\\usepackage{abc}', 'one', '\\label{aaa}', 'two']
+			@fakeMeta = {labels: ['aaa'], packages: ['abc']}
+			@DocumentUpdaterHandler.flushDocToMongo = sinon.stub().callsArgWith 2, null
+			@ProjectEntityHandler.getDoc = sinon.stub().callsArgWith 2, null, @fakeLines
+			@MetaHandler.extractMetaFromDoc = sinon.stub().returns @fakeMeta
+			@call = (callback) =>
+				@MetaHandler.getMetaForDoc @projectId, @docId, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err, docMeta) =>
+				expect(err).to.equal null
+				done()
+
+		it 'should produce docMeta', (done) ->
+			@call (err, docMeta) =>
+				expect(docMeta).to.equal @fakeMeta
+				done()
+
+		it 'should call flushDocToMongo', (done) ->
+			@call (err, docMeta) =>
+				@DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal 1
+				@DocumentUpdaterHandler.flushDocToMongo.calledWith(@projectId, @docId).should.equal true
+				done()
+
+		it 'should call getDoc', (done) ->
+			@call (err, docMeta) =>
+				@ProjectEntityHandler.getDoc.callCount.should.equal 1
+				@ProjectEntityHandler.getDoc.calledWith(@projectId, @docId).should.equal true
+				done()
+
+		it 'should call extractMetaFromDoc', (done) ->
+			@call (err, docMeta) =>
+				@MetaHandler.extractMetaFromDoc.callCount.should.equal 1
+				@MetaHandler.extractMetaFromDoc.calledWith(@fakeLines).should.equal true
+				done()
+
+	describe 'getAllMetaForProject', ->
+		beforeEach ->
+			@fakeDocs =
+				'doc_one':
+					lines: [
+						'\\usepackage[some-options,more=foo]{foo}'
+						'\\label{aaa}'
+					]
+
+			@fakeMeta =
+				labels: ['aaa']
+				packages:
+					foo: [
+						{
+							caption: '\\bar'
+							snippet: '\\bar'
+							meta: 'foo-cmd'
+							score: 12
+						}, {
+							caption: '\\bat[]{}'
+							snippet: '\\bar[$1]{$2}'
+							meta: 'foo-cmd'
+							score: 10
+						}
+					]
+			@DocumentUpdaterHandler.flushProjectToMongo = sinon.stub().callsArgWith 1, null
+			@ProjectEntityHandler.getAllDocs = sinon.stub().callsArgWith 1, null, @fakeDocs
+			@MetaHandler.extractMetaFromProjectDocs = sinon.stub().returns @fakeMeta
+			@call = (callback) =>
+				@MetaHandler.getAllMetaForProject @projectId, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err, projectMeta) =>
+				expect(err).to.equal null
+				done()
+
+		it 'should produce projectMeta', (done) ->
+			@call (err, projectMeta) =>
+				expect(projectMeta).to.equal @fakeMeta
+				done()
+
+		it 'should call getAllDocs', (done) ->
+			@call (err, projectMeta) =>
+				@ProjectEntityHandler.getAllDocs.callCount.should.equal 1
+				@ProjectEntityHandler.getAllDocs.calledWith(@projectId).should.equal true
+				done()
+
+		it 'should call extractMetaFromDoc', (done) ->
+			@call (err, docMeta) =>
+				@MetaHandler.extractMetaFromProjectDocs.callCount.should.equal 1
+				@MetaHandler.extractMetaFromProjectDocs.calledWith(@fakeDocs).should.equal true
+				done()

Некоторые файлы не были показаны из-за большого количества измененных файлов