From 12fd807493072c54948da1e14939626202dd27d1 Mon Sep 17 00:00:00 2001
From: Eike Cochu <eike@cochu.com>
Date: Thu, 7 Apr 2016 03:12:25 +0200
Subject: [PATCH] added textentities as entities, added ui view

---
 .../vipra/rest/resource/ArticleResource.java  |   2 +-
 .../rest/resource/TextEntityResource.java     | 104 ++++++++++++++++++
 .../vipra/cmd/option/DeleteModelCommand.java  |   3 +
 .../de/vipra/cmd/option/ImportCommand.java    |  33 ++++--
 .../de/vipra/cmd/text/SpotlightResponse.java  |  18 +--
 vipra-ui/app/html/articles/index.html         |   3 +-
 vipra-ui/app/html/articles/show.html          |   8 +-
 .../app/html/directives/article-link.html     |   1 +
 vipra-ui/app/html/directives/entity-link.html |   6 +-
 vipra-ui/app/html/directives/entity-menu.html |   2 +-
 vipra-ui/app/html/directives/topic-link.html  |   4 +-
 vipra-ui/app/html/directives/topic-menu.html  |   1 -
 vipra-ui/app/html/directives/word-link.html   |   2 +-
 vipra-ui/app/html/entities/articles.html      |   2 +-
 vipra-ui/app/html/entities/index.html         |  43 +++++++-
 vipra-ui/app/html/index.html                  |   4 +-
 vipra-ui/app/html/topics/index.html           |   2 +-
 vipra-ui/app/html/topics/show.html            |   4 +-
 vipra-ui/app/html/words/index.html            |  14 +--
 vipra-ui/app/html/words/topics.html           |   2 +-
 vipra-ui/app/index.html                       |   3 +
 vipra-ui/app/js/controllers.js                |  41 ++++++-
 vipra-ui/app/js/directives.js                 |  35 ++++--
 vipra-ui/app/js/factories.js                  |   4 +
 .../java/de/vipra/util/model/ArticleFull.java |  16 +--
 .../java/de/vipra/util/model/TextEntity.java  |  51 ++++-----
 .../de/vipra/util/model/TextEntityCount.java  |  37 +++++++
 .../de/vipra/util/model/TextEntityFull.java   |  98 +++++++++++++++++
 28 files changed, 448 insertions(+), 95 deletions(-)
 create mode 100644 vipra-backend/src/main/java/de/vipra/rest/resource/TextEntityResource.java
 create mode 100644 vipra-util/src/main/java/de/vipra/util/model/TextEntityCount.java
 create mode 100644 vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java

diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
index 29cc5aac..2e647eb4 100644
--- a/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
+++ b/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
@@ -74,7 +74,7 @@ public class ArticleResource {
 				query.criteria("words.id", word);
 
 			if (entity != null && !entity.isEmpty())
-				query.criteria("entities.entity", entity);
+				query.criteria("entities.entity.id", entity);
 
 			final List<ArticleFull> articles = dbArticles.getMultiple(query);
 
diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/TextEntityResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/TextEntityResource.java
new file mode 100644
index 00000000..25c59ed4
--- /dev/null
+++ b/vipra-backend/src/main/java/de/vipra/rest/resource/TextEntityResource.java
@@ -0,0 +1,104 @@
+package de.vipra.rest.resource;
+
+import java.io.IOException;
+import java.util.List;
+
+import javax.servlet.ServletContext;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
+import de.vipra.rest.Messages;
+import de.vipra.rest.model.APIError;
+import de.vipra.rest.model.ResponseWrapper;
+import de.vipra.util.Config;
+import de.vipra.util.StringUtils;
+import de.vipra.util.ex.ConfigException;
+import de.vipra.util.model.TextEntityFull;
+import de.vipra.util.model.TopicModel;
+import de.vipra.util.service.MongoService;
+import de.vipra.util.service.Service.QueryBuilder;
+
+@Path("entities")
+public class TextEntityResource {
+
+	@Context
+	UriInfo uri;
+
+	final MongoService<TextEntityFull, String> dbEntities;
+
+	public TextEntityResource(@Context final ServletContext servletContext) throws ConfigException, IOException {
+		final Config config = Config.getConfig();
+		dbEntities = MongoService.getDatabaseService(config, TextEntityFull.class);
+	}
+
+	@GET
+	@Produces(MediaType.APPLICATION_JSON)
+	public Response getEntities(@QueryParam("topicModel") final String topicModel, @QueryParam("skip") final Integer skip,
+			@QueryParam("limit") final Integer limit, @QueryParam("sort") @DefaultValue("id") final String sortBy,
+			@QueryParam("fields") final String fields) {
+		final ResponseWrapper<List<TextEntityFull>> res = new ResponseWrapper<>();
+
+		if (res.hasErrors())
+			return res.badRequest();
+
+		try {
+			final QueryBuilder query = QueryBuilder.builder().skip(skip).limit(limit).sortBy(sortBy);
+			if (fields != null && !fields.isEmpty())
+				query.fields(true, StringUtils.getFields(fields));
+
+			if (topicModel != null && !topicModel.isEmpty())
+				query.criteria("topicModel", new TopicModel(topicModel));
+
+			final List<TextEntityFull> entities = dbEntities.getMultiple(query);
+
+			if ((skip != null && skip > 0) || (limit != null && limit > 0))
+				res.addHeader("total", dbEntities.count(null));
+			else
+				res.addHeader("total", entities.size());
+
+			return res.ok(entities);
+		} catch (final Exception e) {
+			e.printStackTrace();
+			res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage()));
+			return res.badRequest();
+		}
+	}
+
+	@GET
+	@Produces(MediaType.APPLICATION_JSON)
+	@Consumes(MediaType.APPLICATION_JSON)
+	@Path("{id}")
+	public Response getEntity(@PathParam("id") final String id, @QueryParam("fields") final String fields) {
+		final ResponseWrapper<TextEntityFull> res = new ResponseWrapper<>();
+		if (id == null || id.trim().length() == 0) {
+			res.addError(new APIError(Response.Status.BAD_REQUEST, "ID is empty", String.format(Messages.BAD_REQUEST, "id cannot be empty")));
+			return res.badRequest();
+		}
+
+		TextEntityFull entity;
+		try {
+			entity = dbEntities.getSingle(id, StringUtils.getFields(fields));
+		} catch (final Exception e) {
+			e.printStackTrace();
+			res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage()));
+			return res.badRequest();
+		}
+
+		if (entity != null) {
+			return res.ok(entity);
+		} else {
+			res.addError(new APIError(Response.Status.NOT_FOUND, "Resource not found", String.format(Messages.NOT_FOUND, "entity", id)));
+			return res.notFound();
+		}
+	}
+
+}
diff --git a/vipra-cmd/src/main/java/de/vipra/cmd/option/DeleteModelCommand.java b/vipra-cmd/src/main/java/de/vipra/cmd/option/DeleteModelCommand.java
index af9f91b3..00114a42 100644
--- a/vipra-cmd/src/main/java/de/vipra/cmd/option/DeleteModelCommand.java
+++ b/vipra-cmd/src/main/java/de/vipra/cmd/option/DeleteModelCommand.java
@@ -9,6 +9,7 @@ import de.vipra.util.Config;
 import de.vipra.util.ConsoleUtils;
 import de.vipra.util.model.ArticleFull;
 import de.vipra.util.model.SequenceFull;
+import de.vipra.util.model.TextEntityFull;
 import de.vipra.util.model.TopicFull;
 import de.vipra.util.model.TopicModel;
 import de.vipra.util.model.WindowFull;
@@ -33,6 +34,7 @@ public class DeleteModelCommand implements Command {
 		final MongoService<WindowFull, Integer> dbWindows = MongoService.getDatabaseService(config, WindowFull.class);
 		final MongoService<SequenceFull, ObjectId> dbSequences = MongoService.getDatabaseService(config, SequenceFull.class);
 		final MongoService<WordFull, String> dbWords = MongoService.getDatabaseService(config, WordFull.class);
+		final MongoService<TextEntityFull, String> dbEntities = MongoService.getDatabaseService(config, TextEntityFull.class);
 
 		for (final String name : names) {
 			final File modelDir = new File(config.getDataDirectory(), name);
@@ -51,6 +53,7 @@ public class DeleteModelCommand implements Command {
 			dbWindows.deleteMultiple(builder);
 			dbSequences.deleteMultiple(builder);
 			dbWords.deleteMultiple(builder);
+			dbEntities.deleteMultiple(builder);
 		}
 	}
 
diff --git a/vipra-cmd/src/main/java/de/vipra/cmd/option/ImportCommand.java b/vipra-cmd/src/main/java/de/vipra/cmd/option/ImportCommand.java
index fa09572a..00191346 100644
--- a/vipra-cmd/src/main/java/de/vipra/cmd/option/ImportCommand.java
+++ b/vipra-cmd/src/main/java/de/vipra/cmd/option/ImportCommand.java
@@ -6,7 +6,9 @@ import java.io.FileReader;
 import java.io.FilenameFilter;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -34,7 +36,8 @@ import de.vipra.util.ex.ConfigException;
 import de.vipra.util.ex.DatabaseException;
 import de.vipra.util.model.ArticleFull;
 import de.vipra.util.model.ArticleStats;
-import de.vipra.util.model.TextEntity;
+import de.vipra.util.model.TextEntityCount;
+import de.vipra.util.model.TextEntityFull;
 import de.vipra.util.model.TopicModel;
 import de.vipra.util.model.TopicModelConfig;
 import de.vipra.util.model.TopicModelFull;
@@ -74,12 +77,14 @@ public class ImportCommand implements Command {
 	private MongoService<ArticleFull, ObjectId> dbArticles;
 	private MongoService<TopicModelFull, String> dbTopicModels;
 	private MongoService<WordFull, String> dbWords;
+	private MongoService<TextEntityFull, String> dbEntities;
 	private TopicModelConfig modelConfig;
 	private SpotlightAnalyzer spotlightAnalyzer;
 	private Filebase filebase;
 	private Processor processor;
 	private ArticleBuffer buffer;
 	private TopicModelFull topicModel;
+	private Set<TextEntityFull> newTextEntities;
 
 	/**
 	 * Import command to import articles into the database, do topic modeling
@@ -153,14 +158,21 @@ public class ImportCommand implements Command {
 			// spotlight analysis
 			if (spotlightAnalyzer != null) {
 				final SpotlightResponse spotlightResponse = spotlightAnalyzer.analyze(article.getText());
-				final List<TextEntity> textEntities = spotlightResponse.getEntities();
-				article.setEntities(textEntities);
 
-				// insert entities into text
 				String articleText = article.getText();
-				for (final TextEntity textEntity : textEntities)
-					articleText = articleText.replaceAll("(?i)\\b" + Pattern.quote(textEntity.getEntity()) + "\\b(?![^<]*>|[^<>]*</)",
-							Matcher.quoteReplacement(textEntity.aTag()));
+
+				for (final TextEntityCount textEntityCount : spotlightResponse.getEntities()) {
+					// get new text entity
+					final TextEntityFull newTextEntity = new TextEntityFull(textEntityCount.getEntity());
+					newTextEntity.setTopicModel(new TopicModel(topicModel.getId()));
+					newTextEntities.add(newTextEntity);
+
+					// insert entity into text
+					articleText = articleText.replaceAll("(?i)\\b" + Pattern.quote(textEntityCount.getEntity().getId()) + "\\b(?![^<]*>|[^<>]*</)",
+							Matcher.quoteReplacement(textEntityCount.getEntity().aTag()));
+				}
+
+				article.setEntities(spotlightResponse.getEntities());
 				article.setText(articleText);
 			}
 
@@ -251,6 +263,7 @@ public class ImportCommand implements Command {
 		buffer = new ArticleBuffer(dbArticles);
 		filebase = new Filebase(modelConfig, config.getDataDirectory());
 		topicModel = new TopicModelFull(modelConfig.getName(), modelConfig);
+		newTextEntities = new HashSet<>();
 
 		dbTopicModels.replaceSingle(topicModel);
 
@@ -281,6 +294,11 @@ public class ImportCommand implements Command {
 		}
 		dbWords.createMultiple(newWords);
 
+		/*
+		 * add new entities
+		 */
+		dbEntities.createMultiple(newTextEntities);
+
 		/*
 		 * run information
 		 */
@@ -292,6 +310,7 @@ public class ImportCommand implements Command {
 		config = Config.getConfig();
 		dbArticles = MongoService.getDatabaseService(config, ArticleFull.class);
 		dbTopicModels = MongoService.getDatabaseService(config, TopicModelFull.class);
+		dbEntities = MongoService.getDatabaseService(config, TextEntityFull.class);
 		dbWords = MongoService.getDatabaseService(config, WordFull.class);
 		processor = new Processor();
 		for (final TopicModelConfig modelConfig : config.getTopicModelConfigs(models)) {
diff --git a/vipra-cmd/src/main/java/de/vipra/cmd/text/SpotlightResponse.java b/vipra-cmd/src/main/java/de/vipra/cmd/text/SpotlightResponse.java
index e1f904cf..19f2cc40 100644
--- a/vipra-cmd/src/main/java/de/vipra/cmd/text/SpotlightResponse.java
+++ b/vipra-cmd/src/main/java/de/vipra/cmd/text/SpotlightResponse.java
@@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 
 import de.vipra.util.CountMap;
 import de.vipra.util.model.TextEntity;
+import de.vipra.util.model.TextEntityCount;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class SpotlightResponse {
@@ -27,7 +28,7 @@ public class SpotlightResponse {
 		this.resources = resources;
 	}
 
-	public List<TextEntity> getEntities() {
+	public List<TextEntityCount> getEntities() {
 		final CountMap<String> textEntitiesCount = new CountMap<>(resources.size());
 		final Set<TextEntity> textEntities = new HashSet<>(resources.size());
 
@@ -54,14 +55,17 @@ public class SpotlightResponse {
 		}
 
 		// insert count
-		for (final TextEntity textEntity : textEntities)
-			textEntity.setCount(textEntitiesCount.get(textEntity.getEntity()));
+		final List<TextEntityCount> textEntitiesCountList = new ArrayList<>(textEntities.size());
+		for (final TextEntity textEntity : textEntities) {
+			final TextEntityCount textEntityCount = new TextEntityCount();
+			textEntityCount.setEntity(textEntity);
+			textEntityCount.setCount(textEntitiesCount.get(textEntity.getId()));
+			textEntitiesCountList.add(textEntityCount);
+		}
 
-		// to list and sort
-		final List<TextEntity> textEntitiesList = new ArrayList<>(textEntities);
-		Collections.sort(textEntitiesList, Comparator.reverseOrder());
+		Collections.sort(textEntitiesCountList, Comparator.reverseOrder());
 
-		return textEntitiesList;
+		return textEntitiesCountList;
 	}
 
 }
diff --git a/vipra-ui/app/html/articles/index.html b/vipra-ui/app/html/articles/index.html
index 048e680a..413ca3c4 100644
--- a/vipra-ui/app/html/articles/index.html
+++ b/vipra-ui/app/html/articles/index.html
@@ -25,8 +25,7 @@
           <tbody>
             <tr ng-repeat="article in articles">
               <td>
-                <a ui-sref="articles.show({id: article.id})" ng-bind="::article.title"></a>
-                <span class="badge pull-right" ng-bind="::article.topicsCount" ng-attr-title="{{::article.topicsCount}} topic(s)"></span>
+                <article-link article="::article"/>
               </td>
             </tr>
           </tbody>
diff --git a/vipra-ui/app/html/articles/show.html b/vipra-ui/app/html/articles/show.html
index 545e7f54..709ce323 100644
--- a/vipra-ui/app/html/articles/show.html
+++ b/vipra-ui/app/html/articles/show.html
@@ -62,7 +62,7 @@
                 <tr ng-repeat="topic in article.topics | orderBy:articlesShowModels.topicsSort" ng-mouseenter="highlightSlice(topic.topic.id, true)" ng-mouseleave="highlightSlice(topic.topic.id, false)">
                   <td class="text-right" ng-bind-template="{{(topic.share*100).toFixed(0)}}%"></td>
                   <td>
-                    <topic-link topic="topic.topic" />
+                    <topic-link topic="::topic.topic" />
                   </td>
                   <td>
                     <span class="colorbox" style="background:{{::topic.color}}"></span>
@@ -89,7 +89,7 @@
             <tr ng-repeat="simArticle in article.similarArticles | orderBy:articlesShowModels.similarSort">
               <td class="text-right" ng-bind-template="{{::simArticle.share}}%"></td>
               <td>
-                <a ui-sref="articles.show({id: simArticle.article.id})" ng-attr-title="{{::simArticle.article.title}}" ng-bind="::simArticle.article.title"></a>
+                <article-link article="::simArticle.article" />
               </td>
             </tr>
           </tbody>
@@ -109,14 +109,14 @@
               <table class="table table-bordered table-condensed table-fixed">
                 <thead>
                   <tr>
-                    <th ng-model="articlesShowModels.entitiesSort" sort-by="entity">Entity</th>
+                    <th ng-model="articlesShowModels.entitiesSort" sort-by="entity.id">Entity</th>
                     <th ng-model="articlesShowModels.entitiesSort" sort-by="count">Count</th>
                   </tr>
                 </thead>
                 <tbody>
                   <tr ng-repeat="entity in entities | orderBy:articlesShowModels.entitiesSort">
                     <td>
-                      <entity-link entity="::entity" />
+                      <entity-link entity="::entity.entity" />
                     </td>
                     <td ng-bind="::entity.count"></td>
                   </tr>
diff --git a/vipra-ui/app/html/directives/article-link.html b/vipra-ui/app/html/directives/article-link.html
index 9a087e50..7a64f2c7 100644
--- a/vipra-ui/app/html/directives/article-link.html
+++ b/vipra-ui/app/html/directives/article-link.html
@@ -3,4 +3,5 @@
 		<span ng-bind="article.title"></span>
 		<ng-transclude/>
 	</a>
+  <span class="badge pull-right" ng-bind="::article.topicsCount" ng-attr-title="{{::article.topicsCount}} topic(s)" ng-if="::showBadge"></span>
 </span>
diff --git a/vipra-ui/app/html/directives/entity-link.html b/vipra-ui/app/html/directives/entity-link.html
index a54f5363..569cdb43 100644
--- a/vipra-ui/app/html/directives/entity-link.html
+++ b/vipra-ui/app/html/directives/entity-link.html
@@ -1,7 +1,7 @@
 <span>
-	<entity-menu entity="entity" />
-	<a class="entity-link" ui-sref="entities.show({id:entity.entity})">
-		<span ng-bind="entity.entity"></span>
+	<entity-menu entity="entity" ng-if="::showMenu" />
+	<a class="entity-link" ui-sref="entities.show({id:entity.id})">
+		<span ng-bind="entity.id"></span>
 		<ng-transclude/>
 	</a>
 </span>
\ No newline at end of file
diff --git a/vipra-ui/app/html/directives/entity-menu.html b/vipra-ui/app/html/directives/entity-menu.html
index 08f1ca79..bd253dcd 100644
--- a/vipra-ui/app/html/directives/entity-menu.html
+++ b/vipra-ui/app/html/directives/entity-menu.html
@@ -3,7 +3,7 @@
     <i class="fa fa-caret-down"></i>
   </a>
   <ul class="dropdown-menu" ng-class="{'dropdown-menu-right':dropdownRight}">
-    <li><a ui-sref="entities.show.articles({id:entity.entity})">Articles</a></li>
+    <li><a ui-sref="entities.show.articles({id:entity.id})">Articles</a></li>
     <li role="separator" class="divider"></li>
     <li><a ng-href="{{entity.url}}" target="_blank"><span class="dbpedia-logo"></span> DBPedia</a></li>
   </ul>
diff --git a/vipra-ui/app/html/directives/topic-link.html b/vipra-ui/app/html/directives/topic-link.html
index 4cd594c7..e0821bd9 100644
--- a/vipra-ui/app/html/directives/topic-link.html
+++ b/vipra-ui/app/html/directives/topic-link.html
@@ -1,8 +1,8 @@
 <span>
-  <topic-menu topic="topic" />
+  <topic-menu topic="topic" ng-if="::showMenu" />
   <a class="topic-link" ui-sref="topics.show({id:topic.id})">
     <span ng-bind="topic.name"></span>
     <ng-transclude/>
   </a>
-  <span class="badge pull-right" ng-bind="::topic.articlesCount" ng-attr-title="{{::topic.articlesCount}} article(s)"></span>
+  <span class="badge pull-right" ng-bind="::topic.articlesCount" ng-attr-title="{{::topic.articlesCount}} article(s)" ng-if="::showBadge"></span>
 </span>
diff --git a/vipra-ui/app/html/directives/topic-menu.html b/vipra-ui/app/html/directives/topic-menu.html
index f08e2a20..02497524 100644
--- a/vipra-ui/app/html/directives/topic-menu.html
+++ b/vipra-ui/app/html/directives/topic-menu.html
@@ -3,7 +3,6 @@
     <i class="fa fa-caret-down"></i>
   </a>
   <ul class="dropdown-menu" ng-class="{'dropdown-menu-right':dropdownRight}">
-    <li><a ui-sref="topics.show({id:topic.id})">Show</a></li>
     <li><a ui-sref="network({type:'topics',id:topic.id})">Network</a></li>
     <li><a ui-sref="topics.show.articles({id:topic.id})">Articles</a></li>
     <li role="separator" class="divider"></li>
diff --git a/vipra-ui/app/html/directives/word-link.html b/vipra-ui/app/html/directives/word-link.html
index 73cc32b8..06ae886f 100644
--- a/vipra-ui/app/html/directives/word-link.html
+++ b/vipra-ui/app/html/directives/word-link.html
@@ -1,4 +1,4 @@
 <span>
-  <word-menu word="word" />
+  <word-menu word="word" ng-if="::showMenu" />
   <a ui-sref="words.show({id: word.id})" ng-bind="word.id"></a>
 </span>
\ No newline at end of file
diff --git a/vipra-ui/app/html/entities/articles.html b/vipra-ui/app/html/entities/articles.html
index be1fda44..ba2d7bc1 100644
--- a/vipra-ui/app/html/entities/articles.html
+++ b/vipra-ui/app/html/entities/articles.html
@@ -38,7 +38,7 @@
           <tbody>
             <tr ng-repeat="article in articles">
               <td>
-                <a ui-sref="articles.show({id: article.id})" ng-bind="::article.title"></a>
+                <article-link article="::article"/>
               </td>
             </tr>
           </tbody>
diff --git a/vipra-ui/app/html/entities/index.html b/vipra-ui/app/html/entities/index.html
index 0b019785..63d53c59 100644
--- a/vipra-ui/app/html/entities/index.html
+++ b/vipra-ui/app/html/entities/index.html
@@ -1 +1,42 @@
-<div ng-cloak ui-view></div>
\ No newline at end of file
+<div class="container" ng-cloak ng-hide="!rootModels.topicModel || state.name !== 'entities'">
+  <div class="row">
+    <div class="col-md-12 text-center">
+      <pagination total="entitiesTotal" page="entitiesIndexModels.page" limit="entitiesIndexModels.limit" />
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-12">
+      <div class="panel panel-default">
+        <div class="panel-heading">
+          Found
+          <ng-pluralize count="entitiesTotal||0" when="{0:'no entities',1:'1 entity',other:'{} entities'}"></ng-pluralize> in the database.
+          <span ng-show="entitiesTotal">
+            Sort by
+            <ol class="nya-bs-select nya-bs-condensed" ng-model="entitiesIndexModels.sortkey">
+              <li value="id" class="nya-bs-option"><a>Entity</a></li>
+            </ol>
+            <sort-dir ng-model="entitiesIndexModels.sortdir" />
+          </span>
+        </div>
+        <table class="table table-hover table-condensed">
+          <tbody>
+            <tr ng-repeat="entity in entities">
+              <td>
+              	<entity-link entity="::entity" />
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <div class="panel-footer">
+          Page <span ng-bind="entitiesIndexModels.page||1"></span> of <span ng-bind="maxPage||1"></span>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-12 text-center">
+      <pagination total="entitiesTotal" page="entitiesIndexModels.page" limit="entitiesIndexModels.limit" />
+    </div>
+  </div>
+</div>
+<div ng-cloak ui-view></div>
diff --git a/vipra-ui/app/html/index.html b/vipra-ui/app/html/index.html
index e76878b5..cf456ef8 100644
--- a/vipra-ui/app/html/index.html
+++ b/vipra-ui/app/html/index.html
@@ -14,7 +14,7 @@
       <h4>Latest articles</h4>
       <ul class="list-unstyled">
         <li class="ellipsis" ng-repeat="article in latestArticles">
-          <a ui-sref="articles.show({id:article.id})" ng-bind="article.title"></a>
+          <article-link article="::article" badge="false" menu="false"/>
         </li>
       </ul>
     </div>
@@ -22,7 +22,7 @@
       <h4>Latest topics</h4>
       <ul class="list-unstyled">
         <li class="ellipsis" ng-repeat="topic in latestTopics">
-          <a ui-sref="topics.show({id:topic.id})" ng-bind="topic.name"></a>
+          <topic-link topic="::topic" badge="false" menu="false"/>
         </li>
       </ul>
     </div>
diff --git a/vipra-ui/app/html/topics/index.html b/vipra-ui/app/html/topics/index.html
index a39d5eb0..3754a27d 100644
--- a/vipra-ui/app/html/topics/index.html
+++ b/vipra-ui/app/html/topics/index.html
@@ -24,7 +24,7 @@
           <tbody>
             <tr ng-repeat="topic in topics">
               <td>
-                <topic-link topic="topic" />
+                <topic-link topic="::topic" />
               </td>
             </tr>
           </tbody>
diff --git a/vipra-ui/app/html/topics/show.html b/vipra-ui/app/html/topics/show.html
index 5c745179..ceb2a222 100644
--- a/vipra-ui/app/html/topics/show.html
+++ b/vipra-ui/app/html/topics/show.html
@@ -92,7 +92,7 @@
                     <div class="checkbox checkbox-condensed" ng-class="{selected:word.selected}">
                       <input tabindex="0" type="checkbox" ng-model="word.selected" ng-attr-id="{{::word.id}}" ng-change="redrawWordEvolutionChart()">
                       <label class="check" ng-attr-for="{{::word.id}}">
-                        <word-link word="word" />
+                        <word-link word="::word" />
                       </label>
                     </div>
                   </li>
@@ -123,7 +123,7 @@
             <tbody>
               <tr ng-repeat="word in sequence.words | orderBy:topicsShowModels.seqSortWords">
                 <td>
-                  <word-link word="word" />
+                  <word-link word="::word" />
                 </td>
                 <td ng-bind="word.probability.toFixed(4)"></td>
               </tr>
diff --git a/vipra-ui/app/html/words/index.html b/vipra-ui/app/html/words/index.html
index 16a03a7f..eb1c10cd 100644
--- a/vipra-ui/app/html/words/index.html
+++ b/vipra-ui/app/html/words/index.html
@@ -11,18 +11,18 @@
           Found
           <ng-pluralize count="wordsTotal||0" when="{0:'no words',1:'1 word',other:'{} words'}"></ng-pluralize> in the database.
           <span ng-show="wordsTotal">
-          Sort by
-          <ol class="nya-bs-select nya-bs-condensed" ng-model="wordsIndexModels.sortkey">
-            <li value="id" class="nya-bs-option"><a>Word</a></li>
-          </ol>
-          <sort-dir ng-model="wordsIndexModels.sortdir" />
-        </span>
+            Sort by
+            <ol class="nya-bs-select nya-bs-condensed" ng-model="wordsIndexModels.sortkey">
+              <li value="id" class="nya-bs-option"><a>Word</a></li>
+            </ol>
+            <sort-dir ng-model="wordsIndexModels.sortdir" />
+          </span>
         </div>
         <table class="table table-hover table-condensed">
           <tbody>
             <tr ng-repeat="word in words">
               <td>
-              	<word-link word="word" />
+              	<word-link word="::word" />
               </td>
             </tr>
           </tbody>
diff --git a/vipra-ui/app/html/words/topics.html b/vipra-ui/app/html/words/topics.html
index f96c9696..162d93f3 100644
--- a/vipra-ui/app/html/words/topics.html
+++ b/vipra-ui/app/html/words/topics.html
@@ -37,7 +37,7 @@
           <tbody>
             <tr ng-repeat="topic in topics">
               <td>
-                <topic-link topic="topic" />
+                <topic-link topic="::topic" />
               </td>
             </tr>
           </tbody>
diff --git a/vipra-ui/app/index.html b/vipra-ui/app/index.html
index ddb769d7..f65433a5 100644
--- a/vipra-ui/app/index.html
+++ b/vipra-ui/app/index.html
@@ -64,6 +64,9 @@
           <li ui-sref-active="active">
             <a tabindex="0" ui-sref="topics"><span class="mnemonic">T</span>opics</a>
           </li>
+          <li ui-sref-active="active">
+            <a tabindex="0" ui-sref="entities">E<span class="mnemonic">n</span>tities</a>
+          </li>
           <li ui-sref-active="active">
             <a tabindex="0" ui-sref="words"><span class="mnemonic">W</span>ords</a>
           </li>
diff --git a/vipra-ui/app/js/controllers.js b/vipra-ui/app/js/controllers.js
index 8f10c902..6510ac23 100644
--- a/vipra-ui/app/js/controllers.js
+++ b/vipra-ui/app/js/controllers.js
@@ -119,6 +119,15 @@
         }
       });
 
+      hotkeys.add({
+        combo: 'n',
+        description: 'Go to entities',
+        callback: function() {
+          if ($scope.rootModels.topicModel && $state.current.name !== 'entities')
+            $state.transitionTo('entities');
+        }
+      });
+
       hotkeys.add({
         combo: 'w',
         description: 'Go to words',
@@ -653,7 +662,7 @@
         // calculate share from divergence
         if($scope.article.similarArticles) {
           for(var i = 0; i < $scope.article.similarArticles.length; i++)
-            $scope.article.similarArticles[i].share = ((1 - $scope.article.similarArticles[i].divergence) * 100).toFixed(0);
+            $scope.article.similarArticles[i].share = Math.round(((1 - $scope.article.similarArticles[i].divergence) * 100));
         }
 
         // take topic model from article
@@ -1003,8 +1012,34 @@
    * Entity Controllers
    ****************************************************************************/
 
-  app.controller('EntitiesIndexController', ['$scope',
-    function($scope) {
+  app.controller('EntitiesIndexController', ['$scope', '$state', 'EntityFactory',
+    function($scope, $state, EntityFactory) {
+
+      // page was reloaded, choose topic model
+      if (!$scope.rootModels.topicModel && $state.current.name === 'entities')
+        $scope.chooseTopicModel();
+
+      $scope.entitiesIndexModels = {
+        sortkey: 'id',
+        sortdir: true,
+        page: 1,
+        limit: 100
+      };
+
+      $scope.$watchGroup(['entitiesIndexModels.page', 'entitiesIndexModels.sortkey', 'entitiesIndexModels.sortdir', 'rootModels.topicModel'], function() {
+        if (!$scope.rootModels.topicModel) return;
+
+        EntityFactory.query({
+          topicModel: $scope.rootModels.topicModel.id,
+          skip: ($scope.entitiesIndexModels.page - 1) * $scope.entitiesIndexModels.limit,
+          limit: $scope.entitiesIndexModels.limit,
+          sort: ($scope.entitiesIndexModels.sortdir ? '' : '-') + $scope.entitiesIndexModels.sortkey
+        }, function(data, headers) {
+          $scope.entities = data;
+          $scope.entitiesTotal = headers("V-Total");
+          $scope.maxPage = Math.ceil($scope.entitiesTotal / $scope.entitiesIndexModels.limit);
+        });
+      });
 
     }
   ]);
diff --git a/vipra-ui/app/js/directives.js b/vipra-ui/app/js/directives.js
index 78b9736d..41f83f96 100644
--- a/vipra-ui/app/js/directives.js
+++ b/vipra-ui/app/js/directives.js
@@ -14,48 +14,67 @@
   app.directive('topicLink', [function() {
     return {
       scope: {
-        topic: '='
+        topic: '=',
+        badge: '@',
+        menu: '@'
       },
       restrict: 'E',
       replace: true,
       transclude: true,
-      templateUrl: 'html/directives/topic-link.html'
+      templateUrl: 'html/directives/topic-link.html',
+      link: function($scope) {
+        $scope.showBadge = $scope.badge !== 'false';
+        $scope.showMenu = $scope.menu !== 'false';
+      }
     };
   }]);
 
   app.directive('wordLink', [function() {
     return {
       scope: {
-        word: '='
+        word: '=',
+        menu: '@'
       },
       restrict: 'E',
       replace: true,
       transclude: true,
-      templateUrl: 'html/directives/word-link.html'
+      templateUrl: 'html/directives/word-link.html',
+      link: function($scope) {
+        $scope.showBadge = $scope.badge !== 'false';
+        $scope.showMenu = $scope.menu !== 'false';
+      }
     };
   }]);
 
   app.directive('articleLink', [function() {
     return {
       scope: {
-        article: '='
+        article: '=',
+        badge: '@'
       },
       restrict: 'E',
       replace: true,
       transclude: true,
-      templateUrl: 'html/directives/article-link.html'
+      templateUrl: 'html/directives/article-link.html',
+      link: function($scope) {
+        $scope.showBadge = $scope.badge !== 'false';
+      }
     };
   }]);
 
   app.directive('entityLink', [function() {
     return {
       scope: {
-        entity: '='
+        entity: '=',
+        menu: '@'
       },
       restrict: 'E',
       replace: true,
       transclude: true,
-      templateUrl: 'html/directives/entity-link.html'
+      templateUrl: 'html/directives/entity-link.html',
+      link: function($scope) {
+        $scope.showMenu = $scope.menu !== 'false';
+      }
     };
   }]);
 
diff --git a/vipra-ui/app/js/factories.js b/vipra-ui/app/js/factories.js
index b331235d..4fe9d147 100644
--- a/vipra-ui/app/js/factories.js
+++ b/vipra-ui/app/js/factories.js
@@ -76,4 +76,8 @@
     return $myResource(Vipra.config.restUrl + '/words/:id');
   }]);
 
+  app.factory('EntityFactory', ['$myResource', function($myResource) {
+    return $myResource(Vipra.config.restUrl + '/entities/:id');
+  }]);
+
 })();
\ No newline at end of file
diff --git a/vipra-util/src/main/java/de/vipra/util/model/ArticleFull.java b/vipra-util/src/main/java/de/vipra/util/model/ArticleFull.java
index 20b09a25..98a7453f 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/ArticleFull.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/ArticleFull.java
@@ -74,7 +74,7 @@ public class ArticleFull implements Model<ObjectId>, Serializable {
 
 	@Embedded
 	@QueryIgnore(all = true)
-	private List<TextEntity> entities;
+	private List<TextEntityCount> entities;
 
 	@Embedded
 	@QueryIgnore(multi = true)
@@ -214,11 +214,11 @@ public class ArticleFull implements Model<ObjectId>, Serializable {
 		this.words = words;
 	}
 
-	public List<TextEntity> getEntities() {
+	public List<TextEntityCount> getEntities() {
 		return entities;
 	}
 
-	public void setEntities(final List<TextEntity> entities) {
+	public void setEntities(final List<TextEntityCount> entities) {
 		this.entities = entities;
 	}
 
@@ -262,14 +262,14 @@ public class ArticleFull implements Model<ObjectId>, Serializable {
 
 	public String[] entitiesWithTypes() {
 		int size = 0;
-		for (final TextEntity textEntity : entities) {
+		for (final TextEntityCount textEntity : entities) {
 			size++;
-			if (textEntity.getTypes() != null)
-				size += textEntity.getTypes().size();
+			if (textEntity.getEntity().getTypes() != null)
+				size += textEntity.getEntity().getTypes().size();
 		}
 		final List<String> entitiesWithTypes = new ArrayList<>(size);
-		for (final TextEntity textEntity : entities)
-			entitiesWithTypes.addAll(textEntity.entityWithTypes());
+		for (final TextEntityCount textEntity : entities)
+			entitiesWithTypes.addAll(textEntity.getEntity().entityWithTypes());
 		return entitiesWithTypes.toArray(new String[size]);
 	}
 
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TextEntity.java b/vipra-util/src/main/java/de/vipra/util/model/TextEntity.java
index 275e349b..4c5b88b5 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/TextEntity.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/TextEntity.java
@@ -4,36 +4,36 @@ import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 
-import org.mongodb.morphia.annotations.Embedded;
+import org.mongodb.morphia.annotations.Entity;
+import org.mongodb.morphia.annotations.Id;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
-@Embedded
-public class TextEntity implements Comparable<TextEntity>, Serializable {
+@Entity(value = "textentities", noClassnameStored = true)
+public class TextEntity implements Model<String>, Serializable {
 
-	private String entity;
+	@Id
+	private String id;
 
 	private String url;
 
-	private Integer count;
-
 	private List<String> types;
 
 	public TextEntity() {}
 
-	public TextEntity(final String entity, final String url) {
-		this.entity = entity;
+	public TextEntity(final String id, final String url) {
+		this.id = id;
 		this.url = url;
 	}
 
-	public String getEntity() {
-		return entity;
+	public String getId() {
+		return id;
 	}
 
-	public void setEntity(final String entity) {
-		this.entity = entity;
+	public void setId(final String id) {
+		this.id = id;
 	}
 
 	public String getUrl() {
@@ -44,14 +44,6 @@ public class TextEntity implements Comparable<TextEntity>, Serializable {
 		this.url = url;
 	}
 
-	public Integer getCount() {
-		return count;
-	}
-
-	public void setCount(final Integer count) {
-		this.count = count;
-	}
-
 	public List<String> getTypes() {
 		return types;
 	}
@@ -61,12 +53,12 @@ public class TextEntity implements Comparable<TextEntity>, Serializable {
 	}
 
 	public String aTag() {
-		return "<a href=\"" + url + "\">" + entity + "</a>";
+		return "<a href=\"" + url + "\">" + id + "</a>";
 	}
 
 	public List<String> entityWithTypes() {
 		final List<String> entityWithTypes = new ArrayList<>(types.size() + 1);
-		entityWithTypes.add(entity.toLowerCase());
+		entityWithTypes.add(id.toLowerCase());
 		for (final String type : types)
 			entityWithTypes.add(type.toLowerCase());
 		return entityWithTypes;
@@ -76,7 +68,7 @@ public class TextEntity implements Comparable<TextEntity>, Serializable {
 	public int hashCode() {
 		final int prime = 31;
 		int result = 1;
-		result = prime * result + ((entity == null) ? 0 : entity.hashCode());
+		result = prime * result + ((id == null) ? 0 : id.hashCode());
 		return result;
 	}
 
@@ -89,22 +81,17 @@ public class TextEntity implements Comparable<TextEntity>, Serializable {
 		if (getClass() != obj.getClass())
 			return false;
 		final TextEntity other = (TextEntity) obj;
-		if (entity == null) {
-			if (other.entity != null)
+		if (id == null) {
+			if (other.id != null)
 				return false;
-		} else if (!entity.equals(other.entity))
+		} else if (!id.equals(other.id))
 			return false;
 		return true;
 	}
 
-	@Override
-	public int compareTo(final TextEntity o) {
-		return count.compareTo(o.getCount());
-	}
-
 	@Override
 	public String toString() {
-		return "TextEntity [entity=" + entity + ", url=" + url + ", count=" + count + ", types=" + types + "]";
+		return "TextEntity [id=" + id + ", url=" + url + ", types=" + types + "]";
 	}
 
 }
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TextEntityCount.java b/vipra-util/src/main/java/de/vipra/util/model/TextEntityCount.java
new file mode 100644
index 00000000..7f16790e
--- /dev/null
+++ b/vipra-util/src/main/java/de/vipra/util/model/TextEntityCount.java
@@ -0,0 +1,37 @@
+package de.vipra.util.model;
+
+import java.io.Serializable;
+
+import org.mongodb.morphia.annotations.Embedded;
+
+@SuppressWarnings("serial")
+@Embedded
+public class TextEntityCount implements Comparable<TextEntityCount>, Serializable {
+
+	@Embedded
+	private TextEntity entity;
+
+	private Integer count;
+
+	public TextEntity getEntity() {
+		return entity;
+	}
+
+	public void setEntity(TextEntity entity) {
+		this.entity = entity;
+	}
+
+	public Integer getCount() {
+		return count;
+	}
+
+	public void setCount(Integer count) {
+		this.count = count;
+	}
+
+	@Override
+	public int compareTo(TextEntityCount o) {
+		return count.compareTo(o.getCount());
+	}
+
+}
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java b/vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java
new file mode 100644
index 00000000..8d20134f
--- /dev/null
+++ b/vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java
@@ -0,0 +1,98 @@
+package de.vipra.util.model;
+
+import java.io.Serializable;
+import java.util.List;
+
+import org.mongodb.morphia.annotations.Entity;
+import org.mongodb.morphia.annotations.Id;
+import org.mongodb.morphia.annotations.Reference;
+
+import de.vipra.util.an.QueryIgnore;
+
+@SuppressWarnings("serial")
+@Entity(value = "textentities", noClassnameStored = true)
+public class TextEntityFull implements Model<String>, Serializable {
+
+	@Id
+	private String id;
+
+	@Reference
+	@QueryIgnore(multi = true)
+	private TopicModel topicModel;
+
+	@QueryIgnore(multi = true)
+	private String url;
+
+	@QueryIgnore(multi = true)
+	private List<String> types;
+
+	public TextEntityFull() {}
+
+	public TextEntityFull(final TextEntity textEntity) {
+		this.id = textEntity.getId();
+		this.url = textEntity.getUrl();
+		this.types = textEntity.getTypes();
+	}
+
+	public String getId() {
+		return id;
+	}
+
+	public void setId(final String id) {
+		this.id = id;
+	}
+
+	public TopicModel getTopicModel() {
+		return topicModel;
+	}
+
+	public void setTopicModel(TopicModel topicModel) {
+		this.topicModel = topicModel;
+	}
+
+	public String getUrl() {
+		return url;
+	}
+
+	public void setUrl(final String url) {
+		this.url = url;
+	}
+
+	public List<String> getTypes() {
+		return types;
+	}
+
+	public void setTypes(final List<String> types) {
+		this.types = types;
+	}
+
+	public String aTag() {
+		return "<a href=\"" + url + "\">" + id + "</a>";
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((id == null) ? 0 : id.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(final Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		final TextEntityFull other = (TextEntityFull) obj;
+		if (id == null) {
+			if (other.id != null)
+				return false;
+		} else if (!id.equals(other.id))
+			return false;
+		return true;
+	}
+
+}
-- 
GitLab