Add localized texts to Benki post model.

Change-Id: I123cfe2ff06f85dc14c705b21d723d1c68fd2e00
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
index a659049..256c988 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
@@ -1,7 +1,6 @@
 package eu.mulk.mulkcms2.benki.bookmarks;
 
 import eu.mulk.mulkcms2.benki.posts.Post;
-import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import java.util.Set;
 import javax.annotation.CheckForNull;
 import javax.persistence.CollectionTable;
@@ -11,23 +10,14 @@
 import javax.persistence.FetchType;
 import javax.persistence.JoinColumn;
 import javax.persistence.Table;
-import javax.persistence.Transient;
 
 @Entity
 @Table(name = "bookmarks", schema = "benki")
-public class Bookmark extends Post {
+public class Bookmark extends Post<BookmarkText> {
 
   @Column(name = "uri", nullable = false, length = -1)
   public String uri;
 
-  @Column(name = "title", nullable = true, length = -1)
-  @CheckForNull
-  public String title;
-
-  @Column(name = "description", nullable = true, length = -1)
-  @CheckForNull
-  public String description;
-
   @ElementCollection(fetch = FetchType.LAZY)
   @CollectionTable(
       name = "bookmark_tags",
@@ -36,13 +26,10 @@
   @Column(name = "tag")
   public Set<String> tags;
 
-  @Transient
   @CheckForNull
-  protected String computeDescriptionHtml() {
-    if (description == null) {
-      return null;
-    }
-    return new MarkdownConverter().htmlify(description);
+  private String getDescription() {
+    var text = getText();
+    return text == null ? null : text.description;
   }
 
   @CheckForNull
@@ -54,7 +41,8 @@
   @CheckForNull
   @Override
   public String getTitle() {
-    return title;
+    var text = getText();
+    return text == null ? null : text.title;
   }
 
   @Override
@@ -66,4 +54,29 @@
   public boolean isLazychatMessage() {
     return false;
   }
+
+  public void setTitle(String x) {
+    var text = getText();
+    if (text == null) {
+      text = new BookmarkText();
+      text.post = this;
+      text.language = "";
+      texts.put(text.language, text);
+    }
+
+    text.title = x;
+  }
+
+  public void setDescription(String x) {
+    var text = getText();
+    if (text == null) {
+      text = new BookmarkText();
+      text.post = this;
+      text.language = "";
+      texts.put(text.language, text);
+    }
+
+    text.description = x;
+    text.cachedDescriptionHtml = null;
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
index f81ed04..bb39be9 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -66,9 +66,9 @@
 
     var bookmark = new Bookmark();
     bookmark.uri = uri.toString();
-    bookmark.title = title;
     bookmark.tags = Set.of();
-    bookmark.description = description;
+    bookmark.setTitle(title);
+    bookmark.setDescription(description);
     bookmark.owner = user;
     bookmark.date = OffsetDateTime.now();
 
@@ -106,10 +106,9 @@
     }
 
     bookmark.uri = uri.toString();
-    bookmark.title = title;
     bookmark.tags = Set.of();
-    bookmark.description = description;
-    bookmark.cachedDescriptionHtml = null;
+    bookmark.setTitle(title);
+    bookmark.setDescription(description);
     bookmark.owner = user;
 
     assignPostTargets(visibility, user, bookmark);
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java
new file mode 100644
index 0000000..c30f3df
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkText.java
@@ -0,0 +1,31 @@
+package eu.mulk.mulkcms2.benki.bookmarks;
+
+import eu.mulk.mulkcms2.benki.posts.PostText;
+import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
+import javax.annotation.CheckForNull;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import javax.persistence.Transient;
+
+@Entity
+@Table(name = "bookmark_texts", schema = "benki")
+public class BookmarkText extends PostText<Bookmark> {
+
+  @Column(name = "title", nullable = true, length = -1)
+  @CheckForNull
+  public String title;
+
+  @Column(name = "description", nullable = true, length = -1)
+  @CheckForNull
+  public String description;
+
+  @Transient
+  @CheckForNull
+  protected String computeDescriptionHtml() {
+    if (description == null) {
+      return null;
+    }
+    return new MarkdownConverter().htmlify(description);
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkTextPK.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkTextPK.java
new file mode 100644
index 0000000..92bda99
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkTextPK.java
@@ -0,0 +1,54 @@
+package eu.mulk.mulkcms2.benki.bookmarks;
+
+import java.io.Serializable;
+import java.util.Objects;
+import javax.persistence.Column;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+
+public class BookmarkTextPK implements Serializable {
+
+  @Id
+  @Column(name = "language", nullable = false, length = -1)
+  private String language;
+
+  @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "bookmark", referencedColumnName = "id", nullable = false)
+  private Bookmark bookmark;
+
+  public String getLanguage() {
+    return language;
+  }
+
+  public void setLanguage(String language) {
+    this.language = language;
+  }
+
+  public Bookmark getBookmark() {
+    return bookmark;
+  }
+
+  public void setBookmark(Bookmark bookmark) {
+    this.bookmark = bookmark;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof BookmarkTextPK)) {
+      return false;
+    }
+    BookmarkTextPK that = (BookmarkTextPK) o;
+    return Objects.equals(getBookmark(), that.getBookmark())
+        && getLanguage().equals(that.getLanguage());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(getBookmark(), getLanguage());
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
index 5cec6aa..aa5b6eb 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
@@ -1,11 +1,9 @@
 package eu.mulk.mulkcms2.benki.lazychat;
 
 import eu.mulk.mulkcms2.benki.posts.Post;
-import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import java.util.Collection;
 import javax.annotation.CheckForNull;
 import javax.json.bind.annotation.JsonbTransient;
-import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
 import javax.persistence.OneToMany;
@@ -14,14 +12,7 @@
 
 @Entity
 @Table(name = "lazychat_messages", schema = "benki")
-public class LazychatMessage extends Post {
-
-  @Column(name = "content", nullable = true, length = -1)
-  @CheckForNull
-  public String content;
-
-  @Column(name = "format", nullable = false, length = -1)
-  public String format;
+public class LazychatMessage extends Post<LazychatMessageText> {
 
   @OneToMany(mappedBy = "referrer", fetch = FetchType.LAZY)
   @JsonbTransient
@@ -46,16 +37,6 @@
     return null;
   }
 
-  @CheckForNull
-  @Override
-  @JsonbTransient
-  protected String computeDescriptionHtml() {
-    if (content == null) {
-      return null;
-    }
-    return new MarkdownConverter().htmlify(content);
-  }
-
   @Override
   public boolean isBookmark() {
     return false;
@@ -65,4 +46,17 @@
   public boolean isLazychatMessage() {
     return true;
   }
+
+  public void setContent(String x) {
+    var text = getText();
+    if (text == null) {
+      text = new LazychatMessageText();
+      text.post = this;
+      text.language = "";
+      texts.put(text.language, text);
+    }
+
+    text.cachedDescriptionHtml = null;
+    text.content = x;
+  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java
new file mode 100644
index 0000000..1a60877
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageText.java
@@ -0,0 +1,28 @@
+package eu.mulk.mulkcms2.benki.lazychat;
+
+import eu.mulk.mulkcms2.benki.posts.PostText;
+import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
+import javax.annotation.CheckForNull;
+import javax.json.bind.annotation.JsonbTransient;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+@Entity
+@Table(name = "lazychat_message_texts", schema = "benki")
+public class LazychatMessageText extends PostText<LazychatMessage> {
+
+  @Column(name = "content", nullable = true, length = -1)
+  @CheckForNull
+  public String content;
+
+  @CheckForNull
+  @Override
+  @JsonbTransient
+  protected String computeDescriptionHtml() {
+    if (content == null) {
+      return null;
+    }
+    return new MarkdownConverter().htmlify(content);
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageTextPK.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageTextPK.java
new file mode 100644
index 0000000..33063b1
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessageTextPK.java
@@ -0,0 +1,54 @@
+package eu.mulk.mulkcms2.benki.lazychat;
+
+import java.io.Serializable;
+import java.util.Objects;
+import javax.persistence.Column;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+
+public class LazychatMessageTextPK implements Serializable {
+
+  @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "lazychat_message", referencedColumnName = "id", nullable = false)
+  public LazychatMessage lazychatMessage;
+
+  @Id
+  @Column(name = "language", nullable = false, length = -1)
+  private String language;
+
+  public LazychatMessage getLazychatMessage() {
+    return lazychatMessage;
+  }
+
+  public void setLazychatMessageId(LazychatMessage lazychatMessage) {
+    this.lazychatMessage = lazychatMessage;
+  }
+
+  public String getLanguage() {
+    return language;
+  }
+
+  public void setLanguage(String language) {
+    this.language = language;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof LazychatMessageTextPK)) {
+      return false;
+    }
+    LazychatMessageTextPK that = (LazychatMessageTextPK) o;
+    return Objects.equals(getLazychatMessage(), that.getLazychatMessage())
+        && getLanguage().equals(that.getLanguage());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(getLazychatMessage(), getLanguage());
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
index 156b638..270a3d0 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
@@ -45,8 +45,7 @@
     var user = Objects.requireNonNull(getCurrentUser());
 
     var message = new LazychatMessage();
-    message.content = text;
-    message.format = "markdown";
+    message.setContent(text);
     message.owner = user;
     message.date = OffsetDateTime.now();
 
@@ -81,9 +80,7 @@
       throw new ForbiddenException();
     }
 
-    message.content = text;
-    message.cachedDescriptionHtml = null;
-    message.format = "markdown";
+    message.setContent(text);
 
     assignPostTargets(visibility, user, message);
 
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index 2bd9ade..8f2166c 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -1,5 +1,7 @@
 package eu.mulk.mulkcms2.benki.posts;
 
+import static java.util.stream.Collectors.toList;
+
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
 import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
@@ -10,7 +12,9 @@
 import java.time.OffsetDateTime;
 import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.TimeZone;
@@ -18,6 +22,7 @@
 import javax.annotation.CheckForNull;
 import javax.annotation.Nullable;
 import javax.json.bind.annotation.JsonbTransient;
+import javax.persistence.CascadeType;
 import javax.persistence.Column;
 import javax.persistence.Entity;
 import javax.persistence.FetchType;
@@ -30,6 +35,8 @@
 import javax.persistence.JoinTable;
 import javax.persistence.ManyToMany;
 import javax.persistence.ManyToOne;
+import javax.persistence.MapKey;
+import javax.persistence.OneToMany;
 import javax.persistence.SequenceGenerator;
 import javax.persistence.Table;
 import javax.persistence.criteria.CriteriaBuilder;
@@ -43,12 +50,10 @@
 @Entity
 @Table(name = "posts", schema = "benki")
 @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
-public abstract class Post extends PanacheEntityBase {
+public abstract class Post<Text extends PostText<?>> extends PanacheEntityBase {
 
   private static final Logger log = Logger.getLogger(Post.class);
 
-  private static final int DESCRIPTION_CACHE_VERSION = 1;
-
   @Id
   @SequenceGenerator(
       allocationSize = 1,
@@ -63,14 +68,6 @@
   @CheckForNull
   public OffsetDateTime date;
 
-  @Column(name = "cached_description_version", nullable = true)
-  @CheckForNull
-  public Integer cachedDescriptionVersion;
-
-  @Column(name = "cached_description_html", nullable = true)
-  @CheckForNull
-  public String cachedDescriptionHtml;
-
   @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "owner", referencedColumnName = "id")
   @CheckForNull
@@ -95,6 +92,18 @@
   @JsonbTransient
   public Set<Role> targets;
 
+  @OneToMany(
+      mappedBy = "post",
+      fetch = FetchType.LAZY,
+      cascade = CascadeType.ALL,
+      targetEntity = PostText.class)
+  @MapKey(name = "language")
+  public Map<String, Text> texts = new HashMap<>();
+
+  public Map<String, Text> getTexts() {
+    return texts;
+  }
+
   public abstract boolean isBookmark();
 
   public abstract boolean isLazychatMessage();
@@ -103,23 +112,6 @@
   public abstract String getTitle();
 
   @CheckForNull
-  public final String getDescriptionHtml() {
-    if (cachedDescriptionHtml != null
-        && cachedDescriptionVersion != null
-        && cachedDescriptionVersion >= DESCRIPTION_CACHE_VERSION) {
-      return cachedDescriptionHtml;
-    } else {
-      @CheckForNull var descriptionHtml = computeDescriptionHtml();
-      cachedDescriptionHtml = descriptionHtml;
-      cachedDescriptionVersion = DESCRIPTION_CACHE_VERSION;
-      return descriptionHtml;
-    }
-  }
-
-  @CheckForNull
-  protected abstract String computeDescriptionHtml();
-
-  @CheckForNull
   public abstract String getUri();
 
   public Visibility getVisibility() {
@@ -195,7 +187,16 @@
     return getVisibility() == Visibility.PUBLIC || (user != null && visibleTo.contains(user));
   }
 
-  public static class PostPage<T extends Post> {
+  @CheckForNull
+  public final String getDescriptionHtml() {
+    var text = getText();
+    if (text == null) {
+      return null;
+    }
+    return text.getDescriptionHtml();
+  }
+
+  public static class PostPage<T extends Post<? extends PostText>> {
     public @CheckForNull final Integer prevCursor;
     public @CheckForNull final Integer cursor;
     public @CheckForNull final Integer nextCursor;
@@ -229,7 +230,7 @@
 
       public void cacheDescriptions() {
         for (var post : posts) {
-          post.getDescriptionHtml();
+          post.getTexts().values().forEach(PostText::getDescriptionHtml);
         }
       }
     }
@@ -245,12 +246,12 @@
     }
   }
 
-  public static PostPage<Post> findViewable(
+  public static PostPage<Post<? extends PostText>> findViewable(
       PostFilter postFilter, Session session, @CheckForNull User viewer, @CheckForNull User owner) {
     return findViewable(postFilter, session, viewer, owner, null, null);
   }
 
-  public static PostPage<Post> findViewable(
+  public static PostPage<Post<? extends PostText>> findViewable(
       PostFilter postFilter,
       Session session,
       @CheckForNull User viewer,
@@ -271,7 +272,7 @@
     return findViewable(entityClass, session, viewer, owner, cursor, count);
   }
 
-  protected static <T extends Post> PostPage<T> findViewable(
+  protected static <T extends Post<? extends PostText>> PostPage<T> findViewable(
       Class<? extends T> entityClass,
       Session session,
       @CheckForNull User viewer,
@@ -316,9 +317,31 @@
       }
     }
 
+    // Fetch texts (to avoid n+1 selects).
+    var postIds = forwardResults.stream().map(x -> x.id).collect(toList());
+
+    if (!postIds.isEmpty()) {
+      find("SELECT p FROM Post p LEFT JOIN FETCH p.texts WHERE p.id IN (?1)", postIds).stream()
+          .count();
+    }
+
     return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
   }
 
+  @CheckForNull
+  protected Text getText() {
+    var texts = getTexts();
+    if (texts.isEmpty()) {
+      return null;
+    } else if (texts.containsKey("")) {
+      return texts.get("");
+    } else if (texts.containsKey("en")) {
+      return texts.get("en");
+    } else {
+      return texts.values().stream().findAny().get();
+    }
+  }
+
   public enum Visibility {
     PUBLIC,
     SEMIPRIVATE,
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java
new file mode 100644
index 0000000..01753dc
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostText.java
@@ -0,0 +1,61 @@
+package eu.mulk.mulkcms2.benki.posts;
+
+import javax.annotation.CheckForNull;
+import javax.json.bind.annotation.JsonbTransient;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.Inheritance;
+import javax.persistence.InheritanceType;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+import javax.persistence.Table;
+
+@Entity
+@Table(name = "post_texts", schema = "benki")
+@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
+@IdClass(PostTextPK.class)
+public abstract class PostText<OwningPost extends Post<?>> {
+
+  private static final int DESCRIPTION_CACHE_VERSION = 1;
+
+  @Id
+  @Column(name = "post", nullable = false, insertable = false, updatable = false)
+  public int postId;
+
+  @Id
+  @Column(name = "language", nullable = false, length = -1)
+  public String language;
+
+  @Column(name = "cached_description_version", nullable = true)
+  @CheckForNull
+  public Integer cachedDescriptionVersion;
+
+  @Column(name = "cached_description_html", nullable = true)
+  @CheckForNull
+  public String cachedDescriptionHtml;
+
+  @ManyToOne(fetch = FetchType.LAZY, targetEntity = Post.class)
+  @JoinColumn(name = "post", referencedColumnName = "id", nullable = false)
+  @JsonbTransient
+  public OwningPost post;
+
+  @CheckForNull
+  public final String getDescriptionHtml() {
+    if (cachedDescriptionHtml != null
+        && cachedDescriptionVersion != null
+        && cachedDescriptionVersion >= DESCRIPTION_CACHE_VERSION) {
+      return cachedDescriptionHtml;
+    } else {
+      @CheckForNull var descriptionHtml = computeDescriptionHtml();
+      cachedDescriptionHtml = descriptionHtml;
+      cachedDescriptionVersion = DESCRIPTION_CACHE_VERSION;
+      return descriptionHtml;
+    }
+  }
+
+  @CheckForNull
+  protected abstract String computeDescriptionHtml();
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTextPK.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTextPK.java
new file mode 100644
index 0000000..0a945dd
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTextPK.java
@@ -0,0 +1,55 @@
+package eu.mulk.mulkcms2.benki.posts;
+
+import java.io.Serializable;
+import java.util.Objects;
+import javax.persistence.Column;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+
+@IdClass(PostTextPK.class)
+public class PostTextPK implements Serializable {
+
+  @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "post", referencedColumnName = "id", nullable = false)
+  public Post<?> post;
+
+  @Id
+  @Column(name = "language", nullable = false, length = -1)
+  private String language;
+
+  public Post<?> getPost() {
+    return post;
+  }
+
+  public void setPost(Post post) {
+    this.post = post;
+  }
+
+  public String getLanguage() {
+    return language;
+  }
+
+  public void setLanguage(String language) {
+    this.language = language;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof PostTextPK)) {
+      return false;
+    }
+    PostTextPK that = (PostTextPK) o;
+    return Objects.equals(getPost(), that.getPost()) && getLanguage().equals(that.getLanguage());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(getPost(), getLanguage());
+  }
+}