From cde9eb1e5090bdb5ee89e6b9a32532aefe92be07 Mon Sep 17 00:00:00 2001
From: Eike Cochu <eike@cochu.com>
Date: Thu, 12 May 2016 02:57:07 +0200
Subject: [PATCH] fixed bugs

added proper model indexes to mongodb models
added missed indexing and capped collection call to mongodb datastore
fixed paginator go to page bug, where all pages would be shown
replaced ng-repeat alert schema with alerter, javascript library
clearing alerts on state change
reverted network settings to slower convergence
fixed item-link menu dropdowns unusable
fit index page to full hd
added google analytics + angular library
added feedback modal and google forms survey as iframe
---
 vipra-cmd/runcfg/CMD.launch                   |   2 +-
 .../de/vipra/cmd/option/BackupCommand.java    |  53 ++-
 .../de/vipra/cmd/option/RestoreCommand.java   |  44 ++-
 .../app/html/directives/article-link.html     |   4 +-
 vipra-ui/app/html/directives/entity-link.html |  16 +-
 vipra-ui/app/html/directives/topic-link.html  |   8 +-
 vipra-ui/app/html/directives/word-link.html   |   8 +-
 vipra-ui/app/index.html                       |  28 +-
 vipra-ui/app/js/alerter.js                    | 311 ++++++++++++++++++
 vipra-ui/app/js/app.js                        |  27 +-
 vipra-ui/app/js/controllers.js                |  17 +-
 vipra-ui/app/js/factories.js                  |  31 --
 vipra-ui/app/less/app.less                    |  32 +-
 vipra-ui/bower.json                           |   3 +-
 vipra-ui/gulpfile.js                          |   4 +-
 .../src/main/java/de/vipra/util/Mongo.java    |   5 +-
 .../java/de/vipra/util/model/Article.java     |   2 +-
 .../java/de/vipra/util/model/ArticleFull.java |   3 +-
 .../java/de/vipra/util/model/Sequence.java    |   3 +
 .../de/vipra/util/model/SequenceFull.java     |   4 +
 .../java/de/vipra/util/model/TextEntity.java  |   3 +
 .../de/vipra/util/model/TextEntityFull.java   |   3 +
 .../main/java/de/vipra/util/model/Topic.java  |   2 +-
 .../java/de/vipra/util/model/TopicFull.java   |   3 +-
 24 files changed, 514 insertions(+), 102 deletions(-)
 create mode 100644 vipra-ui/app/js/alerter.js

diff --git a/vipra-cmd/runcfg/CMD.launch b/vipra-cmd/runcfg/CMD.launch
index d44936f9..b36db2e1 100644
--- a/vipra-cmd/runcfg/CMD.launch
+++ b/vipra-cmd/runcfg/CMD.launch
@@ -11,7 +11,7 @@
 </listAttribute>
 <stringAttribute key="org.eclipse.jdt.launching.CLASSPATH_PROVIDER" value="org.eclipse.m2e.launchconfig.classpathProvider"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="de.vipra.cmd.Main"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-R /home/eike/Downloads/vipra-1462648299922.zip"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-t"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="vipra-cmd"/>
 <stringAttribute key="org.eclipse.jdt.launching.SOURCE_PATH_PROVIDER" value="org.eclipse.m2e.launchconfig.sourcepathProvider"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-ea"/>
diff --git a/vipra-cmd/src/main/java/de/vipra/cmd/option/BackupCommand.java b/vipra-cmd/src/main/java/de/vipra/cmd/option/BackupCommand.java
index d656552d..57e68758 100644
--- a/vipra-cmd/src/main/java/de/vipra/cmd/option/BackupCommand.java
+++ b/vipra-cmd/src/main/java/de/vipra/cmd/option/BackupCommand.java
@@ -3,9 +3,12 @@ package de.vipra.cmd.option;
 import java.io.File;
 import java.util.Date;
 
+import org.fusesource.jansi.Ansi;
+import org.fusesource.jansi.Ansi.Color;
 import org.zeroturnaround.zip.ZipUtil;
 
 import de.vipra.util.Config;
+import de.vipra.util.ConsoleUtils;
 import de.vipra.util.FileUtils;
 
 public class BackupCommand implements Command {
@@ -18,19 +21,43 @@ public class BackupCommand implements Command {
 
 	@Override
 	public void run() throws Exception {
-		final Config config = Config.getConfig();
-		final File tmpTarget = FileUtils.getTempFile("vipra-dump");
-		org.apache.commons.io.FileUtils.deleteDirectory(tmpTarget);
-		final Process p = Runtime.getRuntime().exec("mongodump -d " + config.getDatabaseName() + " -h " + config.getDatabaseHost() + " --port "
-				+ config.getDatabasePort() + " -o " + new File(tmpTarget, "db"));
-		p.waitFor();
-		org.apache.commons.io.FileUtils.copyDirectory(config.getDataDirectory(), new File(tmpTarget, "fb"));
-		org.apache.commons.io.FileUtils.copyDirectory(Config.getGenericConfigDir(), new File(tmpTarget, "config"));
-		File target = new File(path);
-		if (target.exists() && target.isDirectory())
-			target = new File(target, "vipra-" + new Date().getTime() + ".zip");
-		ZipUtil.pack(tmpTarget, target);
-		org.apache.commons.io.FileUtils.deleteDirectory(tmpTarget);
+		try {
+			final Config config = Config.getConfig();
+			final File tmpTarget = FileUtils.getTempFile("vipra-dump");
+			org.apache.commons.io.FileUtils.deleteDirectory(tmpTarget);
+
+			ConsoleUtils.infoNOLF(" backup database...");
+			final Process p = Runtime.getRuntime().exec("mongodump -d " + config.getDatabaseName() + " -h " + config.getDatabaseHost() + " --port "
+					+ config.getDatabasePort() + " -o " + new File(tmpTarget, "db"));
+			p.waitFor();
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.infoNOLF(" backup filebase...");
+			org.apache.commons.io.FileUtils.copyDirectory(config.getDataDirectory(), new File(tmpTarget, "fb"));
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.infoNOLF(" backup configuration...");
+			org.apache.commons.io.FileUtils.copyDirectory(Config.getGenericConfigDir(), new File(tmpTarget, "config"));
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.infoNOLF(" compressing...");
+			File target = new File(path);
+			if (target.exists() && target.isDirectory())
+				target = new File(target, "vipra-" + new Date().getTime() + ".zip");
+			ZipUtil.pack(tmpTarget, target);
+			org.apache.commons.io.FileUtils.deleteDirectory(tmpTarget);
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.info("completed: " + target.getAbsolutePath());
+		} catch (final Exception e) {
+			ConsoleUtils.print(Ansi.ansi().fg(Color.RED).a("FAILED").reset().toString());
+			if (e.getMessage().contains("mongodump")) {
+				ConsoleUtils.error("mongodump not installed");
+				return;
+			} else {
+				throw e;
+			}
+		}
 	}
 
 	@Override
diff --git a/vipra-cmd/src/main/java/de/vipra/cmd/option/RestoreCommand.java b/vipra-cmd/src/main/java/de/vipra/cmd/option/RestoreCommand.java
index 857a35cd..c1bc3024 100644
--- a/vipra-cmd/src/main/java/de/vipra/cmd/option/RestoreCommand.java
+++ b/vipra-cmd/src/main/java/de/vipra/cmd/option/RestoreCommand.java
@@ -3,9 +3,12 @@ package de.vipra.cmd.option;
 import java.io.File;
 import java.io.FileNotFoundException;
 
+import org.fusesource.jansi.Ansi;
+import org.fusesource.jansi.Ansi.Color;
 import org.zeroturnaround.zip.ZipUtil;
 
 import de.vipra.util.Config;
+import de.vipra.util.ConsoleUtils;
 import de.vipra.util.FileUtils;
 
 public class RestoreCommand implements Command {
@@ -18,17 +21,36 @@ public class RestoreCommand implements Command {
 
 	@Override
 	public void run() throws Exception {
-		final File zip = new File(path);
-		if (!zip.isFile())
-			throw new FileNotFoundException(path);
-		final File tmpTarget = FileUtils.getTempFile("vipra-dump");
-		ZipUtil.unpack(zip, tmpTarget);
-		final Config config = Config.getConfig();
-		final Process p = Runtime.getRuntime()
-				.exec("mongorestore --drop -h " + config.getDatabaseHost() + " --port " + config.getDatabasePort() + " " + new File(tmpTarget, "db"));
-		p.waitFor();
-		org.apache.commons.io.FileUtils.copyDirectory(new File(tmpTarget, "fb"), config.getDataDirectory());
-		org.apache.commons.io.FileUtils.copyDirectory(new File(tmpTarget, "config"), Config.getGenericConfigDir());
+		try {
+			final File zip = new File(path);
+			if (!zip.isFile())
+				throw new FileNotFoundException(path);
+			final File tmpTarget = FileUtils.getTempFile("vipra-dump");
+			ZipUtil.unpack(zip, tmpTarget);
+			final Config config = Config.getConfig();
+
+			ConsoleUtils.infoNOLF(" restore database...");
+			final Process p = Runtime.getRuntime().exec(
+					"mongorestore --drop -h " + config.getDatabaseHost() + " --port " + config.getDatabasePort() + " " + new File(tmpTarget, "db"));
+			p.waitFor();
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.infoNOLF(" restore filebase...");
+			org.apache.commons.io.FileUtils.copyDirectory(new File(tmpTarget, "fb"), config.getDataDirectory());
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+
+			ConsoleUtils.infoNOLF(" restore configuration...");
+			org.apache.commons.io.FileUtils.copyDirectory(new File(tmpTarget, "config"), Config.getGenericConfigDir());
+			ConsoleUtils.print(Ansi.ansi().fg(Color.GREEN).a("OK").reset().toString());
+		} catch (final Exception e) {
+			ConsoleUtils.print(Ansi.ansi().fg(Color.RED).a("FAILED").reset().toString());
+			if (e.getMessage().contains("mongorestore")) {
+				ConsoleUtils.error("mongorestore not installed");
+				return;
+			} else {
+				throw e;
+			}
+		}
 	}
 
 	@Override
diff --git a/vipra-ui/app/html/directives/article-link.html b/vipra-ui/app/html/directives/article-link.html
index 6573ef9f..13791ba6 100644
--- a/vipra-ui/app/html/directives/article-link.html
+++ b/vipra-ui/app/html/directives/article-link.html
@@ -1,4 +1,4 @@
-<span>
+<div class="link-wrapper">
 	<a class="article-link" ui-sref="articles.show({id:article.id})">
 		<span ng-bind="article.title"></span>
 		<ng-transclude/>
@@ -8,4 +8,4 @@
   	<span class="badge" ng-bind="::article.topicsCount" ng-attr-title="{{::article.topicsCount}} topic(s)" ng-if="::showBadge"></span>
   </div>
   <div ng-bind="excerpt" ng-if="excerptShown" class="excerpt"></div>
-</span>
+</div>
diff --git a/vipra-ui/app/html/directives/entity-link.html b/vipra-ui/app/html/directives/entity-link.html
index 569cdb43..b0090b88 100644
--- a/vipra-ui/app/html/directives/entity-link.html
+++ b/vipra-ui/app/html/directives/entity-link.html
@@ -1,7 +1,9 @@
-<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
+<div class="link-wrapper">
+  <span class="ellipsis menu-padding">
+    <a class="entity-link" ui-sref="entities.show({id:entity.id})">
+      <span ng-bind="entity.id"></span>
+      <ng-transclude/>
+    </a>
+  </span>
+  <entity-menu class="menu-button" entity="entity" ng-if="::showMenu" />
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/html/directives/topic-link.html b/vipra-ui/app/html/directives/topic-link.html
index 411e1e14..f4595e81 100644
--- a/vipra-ui/app/html/directives/topic-link.html
+++ b/vipra-ui/app/html/directives/topic-link.html
@@ -1,10 +1,10 @@
-<span>
-  <topic-menu topic="topic" class="menu-button" ng-if="::showMenu" />
-  <span class="ellipsis">
+<div class="link-wrapper">
+  <span class="ellipsis menu-padding">
 	  <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)" ng-if="::showBadge"></span>
 	</span>
-</span>
+  <topic-menu topic="topic" class="menu-button" ng-if="::showMenu" />
+</div>
diff --git a/vipra-ui/app/html/directives/word-link.html b/vipra-ui/app/html/directives/word-link.html
index 268cec13..9e852ae3 100644
--- a/vipra-ui/app/html/directives/word-link.html
+++ b/vipra-ui/app/html/directives/word-link.html
@@ -1,6 +1,6 @@
-<span>
-  <word-menu class="menu-button" word="word" ng-if="::showMenu" />
-  <span class="ellipsis">
+<div class="link-wrapper">
+  <span class="ellipsis menu-padding">
   	<a ui-sref="words.show({id: word.id})" ng-bind="word.id"></a>
   </span>
-</span>
\ No newline at end of file
+  <word-menu class="menu-button" word="word" ng-if="::showMenu" />
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/index.html b/vipra-ui/app/index.html
index 175de291..12e07f0b 100644
--- a/vipra-ui/app/index.html
+++ b/vipra-ui/app/index.html
@@ -66,6 +66,11 @@
               <i class="fa fa-question-circle"></i>
             </a>
           </li>
+          <li title="Feedback">
+            <a tabindex="0" ng-click="showFeedbackModal()">
+              <i class="fa fa-comment-o"></i>
+            </a>
+          </li>
         </ul>
       </div>
     </div>
@@ -116,9 +121,20 @@
       </div>
     </div>
   </div>
-  <div class="alerts">
-    <bs-alert ng-model="alert" type="alert.type" ng-repeat="alert in alerts"/>
+  <div id="feedbackModal" class="modal" tabindex="-1" role="dialog" data-backdrop="static">
+    <div class="modal-dialog modal-lg">
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-label="Close" ng-show="rootModels.topicModel" ng-cloak><span aria-hidden="true">&times;</span></button>
+          <h4 class="modal-title">Evaluation</h4>
+        </div>
+        <div class="modal-body">
+          <iframe id="feedbackIframe" frameborder="0" marginheight="0" marginwidth="0">Wird geladen...</iframe>
+        </div>
+      </div>
+    </div>
   </div>
+  <div class="alerts"></div>
   <div class="overlay-message" ng-show="fatal">
     <div class="message" ng-bind="fatal"></div>
   </div>
@@ -135,5 +151,13 @@
   <script src="js/config.js"></script>
   <script src="js/app.js"></script>
   <script src="js/templates.js"></script>
+  <script>
+    /* jshint ignore:start */
+    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+    (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+    })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+    ga('create', 'UA-77619702-1', 'auto');
+  </script>
 </body>
 </html>
\ No newline at end of file
diff --git a/vipra-ui/app/js/alerter.js b/vipra-ui/app/js/alerter.js
new file mode 100644
index 00000000..6e9c1e58
--- /dev/null
+++ b/vipra-ui/app/js/alerter.js
@@ -0,0 +1,311 @@
+/******************************************************************************
+ * Vipra Application
+ * Alerter library
+ ******************************************************************************/
+/* globals $, Alerter */
+;(function(g) {
+  'use strict';
+
+  if(g.Alerter) return;
+
+  g.Alerter = function(area, create, classes, style) {
+    this.area = area || 'alerts';
+    this.alerts = [];
+    this.queue = [];
+    this.settings = $.extend({}, Alerter.defaultSettings);
+    this.alertDefaults = $.extend({}, Alerter.alertDefaults);
+    if(create)
+      this.createAlertArea(classes, style);
+    Alerter.instances.push(this);
+  };
+
+  Alerter.instances = [];
+
+  Alerter.defaultSettings = {
+    prepend: true,
+    maxAlerts: 0,
+    queue: true,
+    merge: false
+  };
+
+  Alerter.alertDefaults = {
+    dismissible: true,
+    fade: true,
+    fadeTime: 300,
+    fadeAfter: 5000
+  };
+
+  Alerter.prototype.setSettings = function(s, opts) {
+    if(s)
+      this.settings = $.extend({}, this.settings, s);
+    if(opts)
+      this.setAlertDefaults(opts);
+    return this;
+  };
+
+  Alerter.prototype.getSettings = function() {
+    return this.settings;
+  };
+
+  Alerter.prototype.setAlertDefaults = function(opts) {
+    this.alertDefaults = $.extend({}, this.alertDefaults, opts);
+    return this;
+  };
+
+  Alerter.prototype.showAlert = function(type, title, msg, opts) {
+    return this.showAlerts(type, [{title: title, msg: msg}], opts);
+  };
+
+  Alerter.prototype.showAlerts = function(type, body, opts) {
+    opts = $.extend({}, this.alertDefaults, opts);
+
+    if(this.settings.merge && this.mergeAlerts(type, body, opts)) {
+      return;
+    }
+
+    var id = '_' + Math.random().toString(36).substr(2, 9),
+      c = {
+        instance: this,
+        id: id,
+        type: type,
+        opts: opts,
+        time: new Date().getTime(),
+        body: body
+      };
+
+    if(this.settings.maxAlerts > 0 && Object.keys(this.alerts).length >= this.settings.maxAlerts) {
+      if(this.settings.queue) {
+        this.queue.push(c);
+        return;
+      } else {
+        this.removeOldestAlert();
+      }
+    }
+
+    var html = this.createAlert(c),
+      el = c.el = this.settings.prepend ? $(html).prependTo('.' + this.area) : $(html).appendTo('.' + this.area);
+
+    this.alerts[id] = c;
+
+    if(opts.fade) {
+      el.hide().fadeIn(opts.fadeTime, function() {
+        this.restartAlert(id);
+      }.bind(this));
+    } else {
+      this.restartAlert(id);
+    }
+
+    return c;
+  };
+
+  Alerter.prototype.showSuccess = function(title, msg, opts) {
+    return this.showAlert('success', title, msg, opts);
+  };
+
+  Alerter.prototype.showInfo = function(title, msg, opts) {
+    return this.showAlert('info', title, msg, opts);
+  };
+
+  Alerter.prototype.showWarning = function(title, msg, opts) {
+    return this.showAlert('warning', title, msg, opts);
+  };
+
+  Alerter.prototype.showDanger = function(title, msg, opts) {
+    return this.showAlert('danger', title, msg, opts);
+  };
+
+  Alerter.prototype.showPrimary = function(title, msg, opts) {
+    return this.showAlert('primary', title, msg, opts);
+  };
+
+  Alerter.prototype.unqueueAlert = function() {
+    if(this.queue.length) {
+      var c = this.queue.shift();
+      this.showAlerts(c.type, c.body, c.opts);
+    }
+    return this;
+  };
+
+  Alerter.prototype.mergeAlert = function(type, title, msg, opts) {
+    this.mergeAlerts(type, [{title: title, msg: msg}], opts);
+    return this;
+  };
+
+  Alerter.prototype.mergeAlerts = function(type, body, opts) {
+    for(var id in this.alerts) {
+      if(this.alerts[id].type === type) {
+        return this.editAlerts(id, body, opts, true);
+      }
+    }
+    if(this.settings.queue) {
+      for(var i = 0; i < this.queue.length; i++) {
+        if(this.queue[i].type === type) {
+          return this.editAlerts(this.queue[i].id, body, opts, false);
+        }
+      }
+    }
+    return false;
+  };
+
+  Alerter.prototype.getAlert = function(id) {
+    var c = this.alerts[id];
+    if(c) return c;
+    if(this.settings.queue) {
+      for(var i = 0; i < this.queue.length; i++) {
+        if(this.queue[i].id === id) {
+          return this.queue[i];
+        }
+      }
+    }
+    return null;
+  };
+
+  Alerter.prototype.editAlert = function(id, title, msg, opts, restart) {
+    this.editAlerts(id, [{title: title, msg: msg}], restart);
+    return this;
+  };
+
+  Alerter.prototype.editAlerts = function(id, body, opts, restart) {
+    var c = this.getAlert(id);
+    if(c) {
+      c.body.push.apply(c.body, body);
+      if(opts) {
+        for(var key in opts) {
+          if(key !== 'body') {
+            c[key] = opts[key];
+          }
+        }
+      }
+      if(c.el) {
+        var elBody = c.el.find('.alert-body');
+        for(var i = 0; i < body.length; i++) {
+          elBody.append(this.createAlertBody(body[i].title, body[i].msg));
+        }
+      }
+      if(restart) {
+        this.restartAlert(id);
+      }
+      return true;
+    }
+    return false;
+  };
+
+  Alerter.prototype.restartAlert = function(id) {
+    var c = this.getAlert(id);
+    if(c) {
+      clearTimeout(c.timeout);
+      if(c.opts.fadeAfter && c.opts.fadeAfter > 0) {
+        c.timeout = setTimeout(function() {
+          this.removeAlert(id);
+        }.bind(this), c.opts.fadeAfter);
+      }
+    }
+    return this;
+  };
+
+  Alerter.prototype.removeAlert = function(id) {
+    var c = this.getAlert(id);
+    if(c) {
+      if(c.opts.fade) {
+        c.el.fadeOut(c.opts.fadeTime, function() {
+          $(this).remove();
+          delete this.alerts[id];
+          this.unqueueAlert();
+        }.bind(this));
+      } else {
+        c.el.remove();
+        delete this.alerts[id];
+        this.unqueueAlert();
+      }
+    }
+    return this;
+  };
+
+  Alerter.prototype.removeAlerts = function() {
+    this.queue = [];
+    for(var id in this.alerts)
+      this.removeAlert(id);
+    return this;
+  };
+
+  Alerter.prototype.getOldestAlert = function() {
+    var oldest = null;
+    for(var id in this.alerts) {
+      if(!oldest || this.alerts[id].time < oldest.time)
+        oldest = this.alerts[id];
+    }
+    return oldest;
+  };
+
+  Alerter.prototype.removeOldestAlert = function() {
+    var oldest = this.getOldestAlert();
+    if(oldest) {
+      this.removeAlert(oldest.id);
+    }
+    return this;
+  };
+
+  Alerter.prototype.createAlert = function(c) {
+    var msgs = "";
+    for(var i = 0; i < c.body.length; i++)
+      msgs += this.createAlertBody(c.body[i].title, c.body[i].msg);
+    return '<div id="' + c.id + '" class="alert alert-' + c.type + ' ' + (c.opts.dismissible ? 'alert-dismissible' : '') +
+      '" role="alert">' + (c.opts.dismissible ? '<button onclick="Alerter.removeAlertById(\'' + 
+      c.id + '\')" type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>' : '') +
+      '<div class="alert-body">' + msgs + '</div></div>';
+  };
+
+  Alerter.prototype.createAlertBody = function(title, msg) {
+    return '<div><strong>' + (title ? title : '') + '</strong> ' + msg + '</div>';
+  };
+
+  Alerter.prototype.createAlertArea = function(classes, style) {
+    $('<div class="' + this.area + ' ' + classes + '" style="' + style + '"></div>').appendTo('body');
+    return this;
+  };
+
+  /**
+   * Singleton
+   */
+
+  var _A = new Alerter('alerts');
+  Alerter.instances.push(_A);
+
+  Alerter.setSettings = _A.setSettings.bind(_A);
+  Alerter.getSettings = _A.getSettings.bind(_A);
+  Alerter.setAlertDefaults = _A.setAlertDefaults.bind(_A);
+  Alerter.showAlert = _A.showAlert.bind(_A);
+  Alerter.showAlerts = _A.showAlerts.bind(_A);
+  Alerter.showSuccess = _A.showSuccess.bind(_A);
+  Alerter.showInfo = _A.showInfo.bind(_A);
+  Alerter.showWarning = _A.showWarning.bind(_A);
+  Alerter.showDanger = _A.showDanger.bind(_A);
+  Alerter.showPrimary = _A.showPrimary.bind(_A);
+  Alerter.unqueueAlert = _A.unqueueAlert.bind(_A);
+  Alerter.mergeAlert = _A.mergeAlert.bind(_A);
+  Alerter.mergeAlerts = _A.mergeAlerts.bind(_A);
+  Alerter.getAlert = _A.getAlert.bind(_A);
+  Alerter.editAlert = _A.editAlert.bind(_A);
+  Alerter.editAlerts = _A.editAlerts.bind(_A);
+  Alerter.restartAlert = _A.restartAlert.bind(_A);
+  Alerter.removeAlert = _A.removeAlert.bind(_A);
+  Alerter.removeAlerts = _A.removeAlerts.bind(_A);
+  Alerter.getOldestAlert = _A.getOldestAlert.bind(_A);
+  Alerter.removeOldestAlert = _A.removeOldestAlert.bind(_A);
+  Alerter.createAlert = _A.createAlert.bind(_A);
+  Alerter.createAlertBody = _A.createAlertBody.bind(_A);
+  Alerter.createAlertArea = _A.createAlertArea.bind(_A);
+
+  Alerter.removeAlertById = function(id) {
+    for(var i = 0; i < Alerter.instances.length; i++) {
+      Alerter.instances[i].removeAlert(id);
+    }
+  };
+
+  Alerter.removeAllAlerts = function() {
+    for(var i = 0; i < Alerter.instances.length; i++) {
+      Alerter.instances[i].removeAlerts();
+    }
+  };
+
+})(this);
\ No newline at end of file
diff --git a/vipra-ui/app/js/app.js b/vipra-ui/app/js/app.js
index 6754e3f7..55301b93 100644
--- a/vipra-ui/app/js/app.js
+++ b/vipra-ui/app/js/app.js
@@ -2,7 +2,7 @@
  * Vipra Application
  * Main application file
  ******************************************************************************/
-/* globals angular, $, Vipra, ReconnectingWebSocket */
+/* globals angular, $, Vipra, ReconnectingWebSocket, Alerter */
 (function() {
 
   "use strict";
@@ -14,6 +14,8 @@
     'ui.router',
     'cfp.hotkeys',
     'nya.bootstrap.select',
+    'angulartics',
+    'angulartics.google.analytics',
     'vipra.controllers',
     'vipra.directives',
     'vipra.factories',
@@ -182,14 +184,11 @@
             } else if (rejection.data) {
               if (angular.isArray(rejection.data)) {
                 for (var i = 0; i < rejection.data.length; i++) {
-                  $rootScope.alerts.push(angular.extend({
-                    type: 'danger'
-                  }, rejection.data[i]));
+                  var rd = rejection.data[i];
+                  Alerter.showDanger(rd.title, rd.detail);
                 }
               } else {
-                $rootScope.alerts.push(angular.extend({
-                  type: 'danger'
-                }, rejection.data));
+                Alerter.showDanger(rejection.data.title, rejection.data.detail);
               }
             }
             return $q.reject(rejection);
@@ -197,10 +196,17 @@
         };
       });
 
+      Alerter.setSettings({
+        prepend: false,
+        merge: true
+      }, {
+        fadeAfter: 0
+      });
+
     }
   ]);
 
-  app.run(['$rootScope', '$state', 'AlertFactory', function($rootScope, $state, AlertFactory) {
+  app.run(['$rootScope', '$state', function($rootScope, $state) {
 
     $rootScope.loading = {};
 
@@ -217,6 +223,7 @@
     $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState) {
       $rootScope.oldState = fromState;
       $rootScope.state = toState;
+      Alerter.removeAlerts();
     });
 
     $rootScope.$on('$stateChangeStart', function() {
@@ -224,11 +231,11 @@
     });
 
     $rootScope.handleWSMessage = function(data) {
-      AlertFactory.showAlert(data.message, {time:7000});
+      Alerter.showInfo(null, data.message, {fadeAfter: 7000});
     };
 
     $rootScope.showMessage = function(msg) {
-      AlertFactory.showAlert(msg, {time:7000});
+      Alerter.showInfo(null, msg, {fadeAfter: 7000});
     };
     
     var socket = new ReconnectingWebSocket(Vipra.getWSURL(), null, {
diff --git a/vipra-ui/app/js/controllers.js b/vipra-ui/app/js/controllers.js
index 0ea47e94..ea0a1d64 100644
--- a/vipra-ui/app/js/controllers.js
+++ b/vipra-ui/app/js/controllers.js
@@ -62,6 +62,11 @@
         localStorage.tm = topicModel.id;
       };
 
+      $scope.showFeedbackModal = function() {
+        $('#feedbackModal').modal();
+        $('#feedbackIframe').attr('src', 'https://docs.google.com/forms/d/1RjXyGgw8F3v7QsfgOQKz0Koa2Dkr1wDNx3veRZr9I3o/viewform?embedded=true');
+      };
+
       $scope.menubarSearch = function(query) {
         $state.transitionTo('index', {
           q: query
@@ -281,15 +286,9 @@
           randomSeed: 1
         },
         physics: {
-          maxVelocity: 70,
           barnesHut: {
-            centralGravity: 0.5,
-            springConstant: 0.01,
-            gravitationalConstant: -7000,
-            avoidOverlap: 0.2
-          },
-          stabilization: {
-            fit: false
+            springConstant: 0.008,
+            gravitationalConstant: -3500
           }
         },
         interaction: {
@@ -1585,7 +1584,7 @@
       $scope.calculatePages();
 
       $scope.changePage = function(page) {
-        $scope.page = page;
+        $scope.page = parseInt(page, 10);
       };
 
       $scope.toPage = function() {
diff --git a/vipra-ui/app/js/factories.js b/vipra-ui/app/js/factories.js
index 430f6af6..49c0546d 100644
--- a/vipra-ui/app/js/factories.js
+++ b/vipra-ui/app/js/factories.js
@@ -88,35 +88,4 @@
     return $myResource(Vipra.config.restUrl + '/windows/:id');
   }]);
 
-  app.factory('AlertFactory', [function() {
-    var alerts = $("#alerts");
-    if(alerts.length === 0) {
-      alerts = $('<div id="alerts" class="alerts"></div>').appendTo('body');
-    }
-
-    function showAlert(msg, config) {
-      config = angular.merge({}, {
-        type: 'info',
-        time: 0,
-        dismissible: true
-      }, config);
-      var classes = 'alert alert-' + config.type;
-      if(config.dismissible) {
-        classes += ' alert-dismissible';
-      }
-      var alert = $('<div class="' + classes + '" role="alert" style="display:none">' +  (config.dismissible ? 
-        '<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>' : '') + 
-        msg + '</div>').appendTo(alerts).fadeIn();
-      if(config.time > 0) {
-        setTimeout(function() {
-          alert.fadeOut(300, function() { $(this).remove(); });
-        }, config.time);
-      }
-    }
-
-    return {
-      showAlert: showAlert
-    };
-  }]);
-
 })();
\ No newline at end of file
diff --git a/vipra-ui/app/less/app.less b/vipra-ui/app/less/app.less
index 525ddd12..7927ebaf 100644
--- a/vipra-ui/app/less/app.less
+++ b/vipra-ui/app/less/app.less
@@ -631,11 +631,16 @@ entity-menu {
   cursor: text !important;
 }
 
+.link-wrapper {
+  position: relative;
+}
+
 .menu-button {
   position: absolute;
+  top: 0;
 }
 
-.menu-button + * {
+.menu-padding {
   padding-left: 15px;
 }
 
@@ -862,6 +867,31 @@ entity-menu {
   margin: 10px auto;
 }
 
+#feedbackModal {
+  .modal-dialog {
+    height: 100%;
+    margin-top: 0;
+    margin-bottom: 0;
+    padding-top: 30px;
+    padding-bottom: 30px;
+  }
+  .modal-content {
+    height: 100%;
+  }
+  .modal-body {
+    position: absolute;
+    top: 56px;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    padding: 0;
+  }
+  iframe {
+    width: 100%;
+    height: 100%;
+  }
+}
+
 @keyframes spin {
   100% {
     -webkit-transform: rotateY(360deg);
diff --git a/vipra-ui/bower.json b/vipra-ui/bower.json
index 8c9f6794..42042c28 100644
--- a/vipra-ui/bower.json
+++ b/vipra-ui/bower.json
@@ -34,6 +34,7 @@
     "bootbox.js": "bootbox#^4.x",
     "angular-hotkeys": "chieffancypants/angular-hotkeys#^1.x",
     "eonasdan-bootstrap-datetimepicker": "^4.x",
-    "reconnectingWebsocket": "joewalnes/reconnecting-websocket#^1.0.0"
+    "reconnectingWebsocket": "joewalnes/reconnecting-websocket#^1.0.0",
+    "angulartics-google-analytics": "^0.1.4"
   }
 }
diff --git a/vipra-ui/gulpfile.js b/vipra-ui/gulpfile.js
index 01da5326..28223ec7 100644
--- a/vipra-ui/gulpfile.js
+++ b/vipra-ui/gulpfile.js
@@ -28,7 +28,9 @@ var assets = {
     'bower_components/randomcolor/randomColor.js',
     'bower_components/bootbox.js/bootbox.js',
     'bower_components/eonasdan-bootstrap-datetimepicker/build/js/bootstrap-datetimepicker.min.js',
-    'bower_components/reconnectingWebsocket/reconnecting-websocket.min.js'
+    'bower_components/reconnectingWebsocket/reconnecting-websocket.min.js',
+    'bower_components/angulartics/dist/angulartics.min.js',
+    'bower_components/angulartics-google-analytics/dist/angulartics-google-analytics.min.js'
   ],
   css: [
     'bower_components/bootstrap/dist/css/bootstrap.min.css',
diff --git a/vipra-util/src/main/java/de/vipra/util/Mongo.java b/vipra-util/src/main/java/de/vipra/util/Mongo.java
index cc0c7864..93ee0441 100644
--- a/vipra-util/src/main/java/de/vipra/util/Mongo.java
+++ b/vipra-util/src/main/java/de/vipra/util/Mongo.java
@@ -41,8 +41,11 @@ public class Mongo {
 		client = new MongoClient(host + ":" + port, options);
 
 		morphia = new Morphia();
-		morphia.mapPackage("de.vipra.util.model");
 		datastore = morphia.createDatastore(client, databaseName);
+
+		morphia.mapPackage("de.vipra.util.model");
+		datastore.ensureIndexes();
+		datastore.ensureCaps();
 	}
 
 	public MongoClient getClient() {
diff --git a/vipra-util/src/main/java/de/vipra/util/model/Article.java b/vipra-util/src/main/java/de/vipra/util/model/Article.java
index b2c16747..3f47d2fb 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/Article.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/Article.java
@@ -13,7 +13,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "articles", noClassnameStored = true)
-@Indexes({ @Index("title"), @Index("date"), @Index("-created") })
+@Indexes({ @Index("title"), @Index("-title"), @Index("topicsCount"), @Index("-topicsCount") })
 public class Article implements Model<ObjectId>, Serializable {
 
 	@Id
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 a4bf05c0..38d202de 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
@@ -32,7 +32,8 @@ import de.vipra.util.an.QueryIgnore;
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "articles", noClassnameStored = true)
-@Indexes({ @Index("title"), @Index("date"), @Index("-created") })
+@Indexes({ @Index("title"), @Index("-title"), @Index("date"), @Index("-date"), @Index("topicsCount"), @Index("-topicsCount"), @Index("created"),
+		@Index("-created"), @Index("modified"), @Index("-modified") })
 public class ArticleFull implements Model<ObjectId>, Serializable {
 
 	public static final Logger log = LoggerFactory.getLogger(ArticleFull.class);
diff --git a/vipra-util/src/main/java/de/vipra/util/model/Sequence.java b/vipra-util/src/main/java/de/vipra/util/model/Sequence.java
index f83d0c66..46f61d39 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/Sequence.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/Sequence.java
@@ -6,12 +6,15 @@ import org.bson.types.ObjectId;
 import org.mongodb.morphia.annotations.Embedded;
 import org.mongodb.morphia.annotations.Entity;
 import org.mongodb.morphia.annotations.Id;
+import org.mongodb.morphia.annotations.Index;
+import org.mongodb.morphia.annotations.Indexes;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "sequences", noClassnameStored = true)
+@Indexes({ @Index("relevance"), @Index("-relevance"), @Index("relevanceChange"), @Index("-relevanceChange") })
 public class Sequence implements Model<ObjectId>, Comparable<Sequence>, Serializable {
 
 	@Id
diff --git a/vipra-util/src/main/java/de/vipra/util/model/SequenceFull.java b/vipra-util/src/main/java/de/vipra/util/model/SequenceFull.java
index dbdfd71a..9af2291b 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/SequenceFull.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/SequenceFull.java
@@ -8,6 +8,8 @@ import org.bson.types.ObjectId;
 import org.mongodb.morphia.annotations.Embedded;
 import org.mongodb.morphia.annotations.Entity;
 import org.mongodb.morphia.annotations.Id;
+import org.mongodb.morphia.annotations.Index;
+import org.mongodb.morphia.annotations.Indexes;
 import org.mongodb.morphia.annotations.PrePersist;
 import org.mongodb.morphia.annotations.Reference;
 
@@ -18,6 +20,8 @@ import de.vipra.util.an.QueryIgnore;
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "sequences", noClassnameStored = true)
+@Indexes({ @Index("relevance"), @Index("-relevance"), @Index("relevanceChange"), @Index("-relevanceChange"), @Index("created"), @Index("-created"),
+		@Index("modified"), @Index("-modified") })
 public class SequenceFull implements Model<ObjectId>, Comparable<SequenceFull>, Serializable {
 
 	@Id
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 e5423b29..e8313bc2 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
@@ -6,12 +6,15 @@ import java.util.List;
 
 import org.mongodb.morphia.annotations.Entity;
 import org.mongodb.morphia.annotations.Id;
+import org.mongodb.morphia.annotations.Index;
+import org.mongodb.morphia.annotations.Indexes;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "textentities", noClassnameStored = true)
+@Indexes({ @Index("url"), @Index("-url") })
 public class TextEntity implements Model<String>, Serializable {
 
 	@Id
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
index 2058565e..44e6484f 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/TextEntityFull.java
@@ -6,6 +6,8 @@ import java.util.List;
 
 import org.mongodb.morphia.annotations.Entity;
 import org.mongodb.morphia.annotations.Id;
+import org.mongodb.morphia.annotations.Index;
+import org.mongodb.morphia.annotations.Indexes;
 import org.mongodb.morphia.annotations.PrePersist;
 import org.mongodb.morphia.annotations.Reference;
 
@@ -13,6 +15,7 @@ import de.vipra.util.an.QueryIgnore;
 
 @SuppressWarnings("serial")
 @Entity(value = "textentities", noClassnameStored = true)
+@Indexes({ @Index("url"), @Index("-url"), @Index("created"), @Index("-created"), @Index("modified"), @Index("-modified") })
 public class TextEntityFull implements Model<String>, Serializable {
 
 	@Id
diff --git a/vipra-util/src/main/java/de/vipra/util/model/Topic.java b/vipra-util/src/main/java/de/vipra/util/model/Topic.java
index a52d6ca6..ef9696e1 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/Topic.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/Topic.java
@@ -15,7 +15,7 @@ import de.vipra.util.MongoUtils;
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "topics", noClassnameStored = true)
-@Indexes({ @Index("name"), @Index("-created") })
+@Indexes({ @Index("name"), @Index("-name"), @Index("articlesCount"), @Index("-articlesCount") })
 public class Topic implements Model<ObjectId>, Serializable {
 
 	@Id
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java b/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
index 5947d680..77fec9e1 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
@@ -23,7 +23,8 @@ import de.vipra.util.an.QueryIgnore;
 @JsonIgnoreProperties(ignoreUnknown = true)
 @SuppressWarnings("serial")
 @Entity(value = "topics", noClassnameStored = true)
-@Indexes({ @Index("name"), @Index("-created") })
+@Indexes({ @Index("name"), @Index("-name"), @Index("articlesCount"), @Index("-articlesCount"), @Index("created"), @Index("-created"),
+		@Index("modified"), @Index("-modified") })
 public class TopicFull implements Model<ObjectId>, Serializable {
 
 	@Id
-- 
GitLab