Factor common parts of BookmarkResource and LazychatResource into PostResource.

Change-Id: I6e5e123c67340e564c47448cf43b803f7d0cc809
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
index f35fc6c..6298245 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java
@@ -1,6 +1,6 @@
 package eu.mulk.mulkcms2.benki.accesscontrol;
 
-import eu.mulk.mulkcms2.benki.generic.PostTarget;
+import eu.mulk.mulkcms2.benki.posts.PostTarget;
 import eu.mulk.mulkcms2.benki.users.User;
 import eu.mulk.mulkcms2.benki.users.UserRole;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
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 ea62af3..736740a 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java
@@ -1,6 +1,6 @@
 package eu.mulk.mulkcms2.benki.bookmarks;
 
-import eu.mulk.mulkcms2.benki.generic.Post;
+import eu.mulk.mulkcms2.benki.posts.Post;
 import eu.mulk.mulkcms2.benki.users.User;
 import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import io.quarkus.security.identity.SecurityIdentity;
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 485a96e..f2e3067 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -1,45 +1,24 @@
 package eu.mulk.mulkcms2.benki.bookmarks;
 
-import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML;
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 import static javax.ws.rs.core.MediaType.TEXT_HTML;
 
-import com.rometools.rome.feed.atom.Content;
-import com.rometools.rome.feed.atom.Entry;
-import com.rometools.rome.feed.atom.Feed;
-import com.rometools.rome.feed.atom.Link;
-import com.rometools.rome.feed.synd.SyndPersonImpl;
-import com.rometools.rome.io.FeedException;
-import com.rometools.rome.io.WireFeedOutput;
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
+import eu.mulk.mulkcms2.benki.posts.PostFilter;
+import eu.mulk.mulkcms2.benki.posts.PostResource;
 import eu.mulk.mulkcms2.benki.users.User;
 import io.quarkus.qute.Template;
-import io.quarkus.qute.TemplateExtension;
 import io.quarkus.qute.TemplateInstance;
 import io.quarkus.qute.api.ResourcePath;
 import io.quarkus.security.Authenticated;
-import io.quarkus.security.identity.SecurityIdentity;
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.time.Instant;
 import java.time.OffsetDateTime;
-import java.time.ZoneOffset;
-import java.time.format.DateTimeFormatter;
-import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.List;
 import java.util.Set;
-import java.util.stream.Collectors;
 import javax.annotation.CheckForNull;
-import javax.annotation.Nullable;
 import javax.inject.Inject;
 import javax.json.JsonObject;
-import javax.json.spi.JsonProvider;
-import javax.persistence.EntityManager;
-import javax.persistence.PersistenceContext;
 import javax.transaction.Transactional;
 import javax.validation.constraints.NotEmpty;
 import javax.validation.constraints.NotNull;
@@ -49,195 +28,20 @@
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
 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.Response;
-import javax.ws.rs.core.UriInfo;
-import org.eclipse.microprofile.config.inject.ConfigProperty;
-import org.hibernate.Session;
-import org.jboss.logging.Logger;
 import org.jsoup.Jsoup;
 
 @Path("/bookmarks")
-public class BookmarkResource {
-
-  private static final Logger log = Logger.getLogger(BookmarkResource.class);
-
-  private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-
-  private static final DateTimeFormatter humanDateFormatter =
-      DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
-
-  private static final JsonProvider jsonProvider = JsonProvider.provider();
-
-  @ConfigProperty(name = "mulkcms.bookmarks.default-max-results")
-  int defaultMaxResults;
-
-  @ResourcePath("benki/posts/postList.html")
-  @Inject
-  Template postList;
+public class BookmarkResource extends PostResource {
 
   @ResourcePath("benki/bookmarks/newBookmark.html")
   @Inject
   Template newBookmark;
 
-  @Inject SecurityIdentity identity;
-
-  @Context UriInfo uri;
-
-  @Inject
-  @ConfigProperty(name = "mulkcms.tag-base")
-  String tagBase;
-
-  @PersistenceContext EntityManager entityManager;
-
-  @GET
-  @Produces(TEXT_HTML)
-  public TemplateInstance getIndex(
-      @QueryParam("i") @CheckForNull Integer cursor,
-      @QueryParam("n") @CheckForNull Integer maxResults) {
-
-    maxResults = maxResults == null ? defaultMaxResults : maxResults;
-
-    var session = entityManager.unwrap(Session.class);
-    var q = Bookmark.findViewable(session, identity, null, cursor, maxResults);
-
-    return postList
-        .data("posts", q.posts)
-        .data("feedUri", "/bookmarks/feed")
-        .data("pageTitle", "Bookmarks")
-        .data("showBookmarkForm", !identity.isAnonymous())
-        .data("showLazychatForm", false)
-        .data("hasPreviousPage", q.prevCursor != null)
-        .data("hasNextPage", q.nextCursor != null)
-        .data("previousCursor", q.prevCursor)
-        .data("nextCursor", q.nextCursor)
-        .data("pageSize", maxResults);
-  }
-
-  @GET
-  @Path("~{ownerName}")
-  @Produces(TEXT_HTML)
-  public TemplateInstance getUserIndex(
-      @PathParam("ownerName") String ownerName,
-      @QueryParam("i") @CheckForNull Integer cursor,
-      @QueryParam("n") @CheckForNull Integer maxResults) {
-
-    maxResults = maxResults == null ? defaultMaxResults : maxResults;
-
-    var owner = User.findByNickname(ownerName);
-    var session = entityManager.unwrap(Session.class);
-    var q = Bookmark.findViewable(session, identity, owner, cursor, maxResults);
-
-    return postList
-        .data("posts", q.posts)
-        .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName))
-        .data("pageTitle", "Bookmarks")
-        .data("showBookmarkForm", !identity.isAnonymous())
-        .data("showLazychatForm", false)
-        .data("hasPreviousPage", q.prevCursor != null)
-        .data("hasNextPage", q.nextCursor != null)
-        .data("previousCursor", q.prevCursor)
-        .data("nextCursor", q.nextCursor)
-        .data("pageSize", maxResults);
-  }
-
-  @GET
-  @Path("feed")
-  @Produces(APPLICATION_ATOM_XML)
-  public String getFeed() throws FeedException {
-    return makeFeed(null, null);
-  }
-
-  @GET
-  @Path("~{ownerName}/feed")
-  @Produces(APPLICATION_ATOM_XML)
-  public String getUserFeed(@PathParam("ownerName") String ownerName) throws FeedException {
-    var owner = User.findByNickname(ownerName);
-    return makeFeed(owner, ownerName);
-  }
-
-  private String makeFeed(@Nullable User owner, @Nullable String ownerName) throws FeedException {
-    var bookmarks = Bookmark.findViewable(entityManager.unwrap(Session.class), identity, owner);
-    var feed = new Feed("atom_1.0");
-
-    var feedSubId = owner == null ? "" : String.format("/%d", owner.id);
-
-    feed.setTitle("Book Marx");
-    feed.setId(
-        String.format(
-            "tag:%s,2019:marx%s:%s",
-            tagBase,
-            feedSubId,
-            identity.isAnonymous() ? "world" : identity.getPrincipal().getName()));
-    feed.setUpdated(
-        Date.from(
-            bookmarks.stream()
-                .map(x -> x.date)
-                .max(Comparator.comparing(x -> x))
-                .orElse(OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC))
-                .toInstant()));
-
-    var selfLink = new Link();
-    selfLink.setHref(uri.getRequestUri().toString());
-    selfLink.setRel("self");
-    feed.setOtherLinks(List.of(selfLink));
-
-    var htmlAltLink = new Link();
-    var htmlAltPath = owner == null ? "/bookmarks" : String.format("~%s/bookmarks", ownerName);
-    htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString());
-    htmlAltLink.setRel("alternate");
-    htmlAltLink.setType("text/html");
-    feed.setAlternateLinks(List.of(htmlAltLink));
-
-    feed.setEntries(
-        bookmarks.stream()
-            .map(
-                bookmark -> {
-                  var entry = new Entry();
-
-                  entry.setId(String.format("tag:%s,2012:/marx/%d", tagBase, bookmark.id));
-                  entry.setPublished(Date.from(bookmark.date.toInstant()));
-                  entry.setUpdated(Date.from(bookmark.date.toInstant()));
-
-                  var author = new SyndPersonImpl();
-                  author.setName(bookmark.owner.getFirstAndLastName());
-                  entry.setAuthors(List.of(author));
-
-                  var title = new Content();
-                  title.setType("text");
-                  title.setValue(bookmark.title);
-                  entry.setTitleEx(title);
-
-                  var summary = new Content();
-                  summary.setType("html");
-                  summary.setValue(bookmark.getDescriptionHtml());
-                  entry.setSummary(summary);
-
-                  var link = new Link();
-                  link.setHref(bookmark.uri);
-                  link.setRel("alternate");
-                  entry.setAlternateLinks(List.of(link));
-
-                  return entry;
-                })
-            .collect(Collectors.toUnmodifiableList()));
-
-    var wireFeedOutput = new WireFeedOutput();
-    return wireFeedOutput.outputString(feed);
-  }
-
-  @GET
-  @Authenticated
-  @Path("new")
-  @Produces(TEXT_HTML)
-  public TemplateInstance getNewBookmarkForm(
-      @QueryParam("uri") @CheckForNull String uri,
-      @QueryParam("title") @CheckForNull String title,
-      @QueryParam("description") @CheckForNull String description) {
-    return newBookmark.data("uri", uri).data("title", title).data("description", description);
+  public BookmarkResource() {
+    super(PostFilter.BOOKMARKS_ONLY, "Bookmarks");
   }
 
   @POST
@@ -277,6 +81,17 @@
   }
 
   @GET
+  @Authenticated
+  @Path("new")
+  @Produces(TEXT_HTML)
+  public TemplateInstance getNewBookmarkForm(
+      @QueryParam("uri") @CheckForNull String uri,
+      @QueryParam("title") @CheckForNull String title,
+      @QueryParam("description") @CheckForNull String description) {
+    return newBookmark.data("uri", uri).data("title", title).data("description", description);
+  }
+
+  @GET
   @Path("page-info")
   @Authenticated
   @Produces(APPLICATION_JSON)
@@ -284,14 +99,4 @@
     var document = Jsoup.connect(uri.toString()).get();
     return jsonProvider.createObjectBuilder().add("title", document.title()).build();
   }
-
-  @TemplateExtension
-  static String humanDateTime(TemporalAccessor x) {
-    return humanDateFormatter.format(x);
-  }
-
-  @TemplateExtension
-  static String htmlDateTime(TemporalAccessor x) {
-    return htmlDateFormatter.format(x);
-  }
 }
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 4c7f6a0..5e00c60 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java
@@ -1,6 +1,6 @@
 package eu.mulk.mulkcms2.benki.lazychat;
 
-import eu.mulk.mulkcms2.benki.generic.Post;
+import eu.mulk.mulkcms2.benki.posts.Post;
 import eu.mulk.mulkcms2.benki.users.User;
 import eu.mulk.mulkcms2.common.markdown.MarkdownConverter;
 import io.quarkus.security.identity.SecurityIdentity;
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 a74692b..8a4d2a3 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
@@ -1,113 +1,28 @@
 package eu.mulk.mulkcms2.benki.lazychat;
 
-import static javax.ws.rs.core.MediaType.TEXT_HTML;
-
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
+import eu.mulk.mulkcms2.benki.posts.PostFilter;
+import eu.mulk.mulkcms2.benki.posts.PostResource;
 import eu.mulk.mulkcms2.benki.users.User;
-import io.quarkus.qute.Template;
-import io.quarkus.qute.TemplateExtension;
-import io.quarkus.qute.TemplateInstance;
-import io.quarkus.qute.api.ResourcePath;
 import io.quarkus.security.Authenticated;
-import io.quarkus.security.identity.SecurityIdentity;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.time.OffsetDateTime;
-import java.time.format.DateTimeFormatter;
-import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
 import java.util.Set;
-import javax.annotation.CheckForNull;
-import javax.inject.Inject;
-import javax.json.spi.JsonProvider;
-import javax.persistence.EntityManager;
-import javax.persistence.PersistenceContext;
 import javax.transaction.Transactional;
 import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Pattern;
 import javax.ws.rs.BadRequestException;
 import javax.ws.rs.FormParam;
-import javax.ws.rs.GET;
 import javax.ws.rs.POST;
 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.Response;
-import org.eclipse.microprofile.config.inject.ConfigProperty;
-import org.hibernate.Session;
-import org.jboss.logging.Logger;
 
 @Path("/lazychat")
-public class LazychatResource {
+public class LazychatResource extends PostResource {
 
-  private static final Logger log = Logger.getLogger(LazychatResource.class);
-
-  private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-
-  private static final DateTimeFormatter humanDateFormatter =
-      DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
-
-  private static final JsonProvider jsonProvider = JsonProvider.provider();
-
-  @ConfigProperty(name = "mulkcms.lazychat.default-max-results")
-  int defaultMaxResults;
-
-  @ResourcePath("benki/posts/postList.html")
-  @Inject
-  Template postList;
-
-  @Inject SecurityIdentity identity;
-
-  @PersistenceContext EntityManager entityManager;
-
-  @GET
-  @Produces(TEXT_HTML)
-  public TemplateInstance getIndex(
-      @QueryParam("i") @CheckForNull Integer cursor,
-      @QueryParam("n") @CheckForNull Integer maxResults) {
-
-    maxResults = maxResults == null ? defaultMaxResults : maxResults;
-
-    var session = entityManager.unwrap(Session.class);
-    var q = LazychatMessage.findViewable(session, identity, null, cursor, maxResults);
-
-    return postList
-        .data("posts", q.posts)
-        .data("pageTitle", "Lazy Chat")
-        .data("showBookmarkForm", false)
-        .data("showLazychatForm", !identity.isAnonymous())
-        .data("hasPreviousPage", q.prevCursor != null)
-        .data("hasNextPage", q.nextCursor != null)
-        .data("previousCursor", q.prevCursor)
-        .data("nextCursor", q.nextCursor)
-        .data("pageSize", maxResults);
-  }
-
-  @GET
-  @Path("~{ownerName}")
-  @Produces(TEXT_HTML)
-  public TemplateInstance getUserIndex(
-      @PathParam("ownerName") String ownerName,
-      @QueryParam("i") @CheckForNull Integer cursor,
-      @QueryParam("n") @CheckForNull Integer maxResults) {
-
-    maxResults = maxResults == null ? defaultMaxResults : maxResults;
-
-    var owner = User.findByNickname(ownerName);
-    var session = entityManager.unwrap(Session.class);
-    var q = LazychatMessage.findViewable(session, identity, owner, cursor, maxResults);
-
-    return postList
-        .data("posts", q.posts)
-        .data("pageTitle", "Lazy Chat")
-        .data("showBookmarkForm", false)
-        .data("showLazychatForm", !identity.isAnonymous())
-        .data("hasPreviousPage", q.prevCursor != null)
-        .data("hasNextPage", q.nextCursor != null)
-        .data("previousCursor", q.prevCursor)
-        .data("nextCursor", q.nextCursor)
-        .data("pageSize", maxResults);
+  public LazychatResource() {
+    super(PostFilter.LAZYCHAT_MESSAGES_ONLY, "Lazy Chat");
   }
 
   @POST
@@ -141,14 +56,4 @@
 
     return Response.seeOther(new URI("/lazychat")).build();
   }
-
-  @TemplateExtension
-  static String humanDateTime(TemporalAccessor x) {
-    return humanDateFormatter.format(x);
-  }
-
-  @TemplateExtension
-  static String htmlDateTime(TemporalAccessor x) {
-    return htmlDateFormatter.format(x);
-  }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
similarity index 83%
rename from src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java
rename to src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index 7d75bb4..fc9ba78 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -1,4 +1,4 @@
-package eu.mulk.mulkcms2.benki.generic;
+package eu.mulk.mulkcms2.benki.posts;
 
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
@@ -103,9 +103,10 @@
       conditions.add(cb.equal(root, user));
       if (entityClass.isAssignableFrom(Bookmark.class)) {
         post = (From<?, T>) root.join(User_.visibleBookmarks);
-      } else {
-        assert entityClass.isAssignableFrom(LazychatMessage.class) : entityClass;
+      } else if (entityClass.isAssignableFrom(LazychatMessage.class)) {
         post = (From<?, T>) root.join(User_.visibleLazychatMessages);
+      } else {
+        post = (From<?, T>) root.join(User_.visiblePosts);
       }
     }
 
@@ -153,13 +154,29 @@
     }
   }
 
-  protected static <T extends Post> List<T> findViewable(
-      Class<T> entityClass, Session session, SecurityIdentity viewer, @CheckForNull User owner) {
-    return findViewable(entityClass, session, viewer, owner, null, null).posts;
+  public static PostPage<Post> findViewable(
+      PostFilter postFilter,
+      Session session,
+      SecurityIdentity viewer,
+      @CheckForNull User owner,
+      @CheckForNull Integer cursor,
+      @CheckForNull Integer count) {
+    Class<? extends Post> entityClass;
+    switch (postFilter) {
+      case BOOKMARKS_ONLY:
+        entityClass = Bookmark.class;
+        break;
+      case LAZYCHAT_MESSAGES_ONLY:
+        entityClass = LazychatMessage.class;
+        break;
+      default:
+        entityClass = Post.class;
+    }
+    return findViewable(entityClass, session, viewer, owner, cursor, count);
   }
 
   protected static <T extends Post> PostPage<T> findViewable(
-      Class<T> entityClass,
+      Class<? extends T> entityClass,
       Session session,
       SecurityIdentity viewer,
       @CheckForNull User owner,
@@ -172,7 +189,7 @@
 
     var cb = session.getCriteriaBuilder();
 
-    var forwardCriteria = Bookmark.queryViewable(entityClass, viewer, owner, cursor, cb, true);
+    var forwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, true);
     var forwardQuery = session.createQuery(forwardCriteria);
 
     if (count != null) {
@@ -186,7 +203,7 @@
 
     if (cursor != null) {
       // Look backwards as well so we can find the prevCursor.
-      var backwardCriteria = Bookmark.queryViewable(entityClass, viewer, owner, cursor, cb, false);
+      var backwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, false);
       var backwardQuery = session.createQuery(backwardCriteria);
       backwardQuery.setMaxResults(count);
       var backwardResults = backwardQuery.getResultList();
@@ -195,7 +212,7 @@
       }
     }
 
-    var forwardResults = forwardQuery.getResultList();
+    var forwardResults = (List<T>) forwardQuery.getResultList();
     if (count != null) {
       if (forwardResults.size() == count + 1) {
         nextCursor = forwardResults.get(count).id;
@@ -203,6 +220,6 @@
       }
     }
 
-    return new PostPage(prevCursor, cursor, nextCursor, forwardResults);
+    return new PostPage<T>(prevCursor, cursor, nextCursor, forwardResults);
   }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java
new file mode 100644
index 0000000..94069e3
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java
@@ -0,0 +1,7 @@
+package eu.mulk.mulkcms2.benki.posts;
+
+public enum PostFilter {
+  BOOKMARKS_ONLY,
+  LAZYCHAT_MESSAGES_ONLY,
+  ALL,
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
new file mode 100644
index 0000000..e08aaf1
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -0,0 +1,253 @@
+package eu.mulk.mulkcms2.benki.posts;
+
+import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML;
+import static javax.ws.rs.core.MediaType.TEXT_HTML;
+
+import com.rometools.rome.feed.atom.Content;
+import com.rometools.rome.feed.atom.Entry;
+import com.rometools.rome.feed.atom.Feed;
+import com.rometools.rome.feed.atom.Link;
+import com.rometools.rome.feed.synd.SyndPersonImpl;
+import com.rometools.rome.io.FeedException;
+import com.rometools.rome.io.WireFeedOutput;
+import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
+import eu.mulk.mulkcms2.benki.users.User;
+import io.quarkus.qute.Template;
+import io.quarkus.qute.TemplateExtension;
+import io.quarkus.qute.TemplateInstance;
+import io.quarkus.qute.api.ResourcePath;
+import io.quarkus.security.identity.SecurityIdentity;
+import java.net.URI;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.time.temporal.TemporalAccessor;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import javax.json.spi.JsonProvider;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+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.UriInfo;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.hibernate.Session;
+import org.jboss.logging.Logger;
+
+public abstract class PostResource {
+
+  private static final Logger log = Logger.getLogger(PostResource.class);
+
+  private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+  private static final DateTimeFormatter humanDateFormatter =
+      DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
+
+  protected static final JsonProvider jsonProvider = JsonProvider.provider();
+
+  @ConfigProperty(name = "mulkcms.posts.default-max-results")
+  int defaultMaxResults;
+
+  @ResourcePath("benki/posts/postList.html")
+  @Inject
+  Template postList;
+
+  @Inject protected SecurityIdentity identity;
+
+  @Context protected UriInfo uri;
+
+  @Inject
+  @ConfigProperty(name = "mulkcms.tag-base")
+  String tagBase;
+
+  @PersistenceContext EntityManager entityManager;
+
+  private final PostFilter postFilter;
+  private final String pageTitle;
+
+  public PostResource(PostFilter postFilter, String pageTitle) {
+    this.postFilter = postFilter;
+    this.pageTitle = pageTitle;
+  }
+
+  @GET
+  @Produces(TEXT_HTML)
+  public TemplateInstance getIndex(
+      @QueryParam("i") @CheckForNull Integer cursor,
+      @QueryParam("n") @CheckForNull Integer maxResults) {
+
+    maxResults = maxResults == null ? defaultMaxResults : maxResults;
+
+    var session = entityManager.unwrap(Session.class);
+    var q = Post.findViewable(postFilter, session, identity, null, cursor, maxResults);
+
+    return postList
+        .data("posts", q.posts)
+        .data("feedUri", "/bookmarks/feed")
+        .data("pageTitle", pageTitle)
+        .data("showBookmarkForm", showBookmarkForm())
+        .data("showLazychatForm", showLazychatForm())
+        .data("hasPreviousPage", q.prevCursor != null)
+        .data("hasNextPage", q.nextCursor != null)
+        .data("previousCursor", q.prevCursor)
+        .data("nextCursor", q.nextCursor)
+        .data("pageSize", maxResults);
+  }
+
+  @GET
+  @Path("~{ownerName}")
+  @Produces(TEXT_HTML)
+  public TemplateInstance getUserIndex(
+      @PathParam("ownerName") String ownerName,
+      @QueryParam("i") @CheckForNull Integer cursor,
+      @QueryParam("n") @CheckForNull Integer maxResults) {
+
+    maxResults = maxResults == null ? defaultMaxResults : maxResults;
+
+    var owner = User.findByNickname(ownerName);
+    var session = entityManager.unwrap(Session.class);
+    var q = Post.findViewable(postFilter, session, identity, owner, cursor, maxResults);
+
+    return postList
+        .data("posts", q.posts)
+        .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName))
+        .data("pageTitle", pageTitle)
+        .data("showBookmarkForm", showBookmarkForm())
+        .data("showLazychatForm", showLazychatForm())
+        .data("hasPreviousPage", q.prevCursor != null)
+        .data("hasNextPage", q.nextCursor != null)
+        .data("previousCursor", q.prevCursor)
+        .data("nextCursor", q.nextCursor)
+        .data("pageSize", maxResults);
+  }
+
+  @GET
+  @Path("feed")
+  @Produces(APPLICATION_ATOM_XML)
+  public String getFeed() throws FeedException {
+    return makeFeed(null, null);
+  }
+
+  @GET
+  @Path("~{ownerName}/feed")
+  @Produces(APPLICATION_ATOM_XML)
+  public String getUserFeed(@PathParam("ownerName") String ownerName) throws FeedException {
+    var owner = User.findByNickname(ownerName);
+    return makeFeed(owner, ownerName);
+  }
+
+  private String makeFeed(@Nullable User owner, @Nullable String ownerName) throws FeedException {
+    var bookmarks = Bookmark.findViewable(entityManager.unwrap(Session.class), identity, owner);
+    var feed = new Feed("atom_1.0");
+
+    var feedSubId = owner == null ? "" : String.format("/%d", owner.id);
+
+    feed.setTitle("Book Marx");
+    feed.setId(
+        String.format(
+            "tag:%s,2019:marx%s:%s",
+            tagBase,
+            feedSubId,
+            identity.isAnonymous() ? "world" : identity.getPrincipal().getName()));
+    feed.setUpdated(
+        Date.from(
+            bookmarks.stream()
+                .map(x -> x.date)
+                .max(Comparator.comparing(x -> x))
+                .orElse(OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC))
+                .toInstant()));
+
+    var selfLink = new Link();
+    selfLink.setHref(uri.getRequestUri().toString());
+    selfLink.setRel("self");
+    feed.setOtherLinks(List.of(selfLink));
+
+    var htmlAltLink = new Link();
+    var htmlAltPath = owner == null ? "/bookmarks" : String.format("~%s/bookmarks", ownerName);
+    htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString());
+    htmlAltLink.setRel("alternate");
+    htmlAltLink.setType("text/html");
+    feed.setAlternateLinks(List.of(htmlAltLink));
+
+    feed.setEntries(
+        bookmarks.stream()
+            .map(
+                bookmark -> {
+                  var entry = new Entry();
+
+                  entry.setId(String.format("tag:%s,2012:/marx/%d", tagBase, bookmark.id));
+                  entry.setPublished(Date.from(bookmark.date.toInstant()));
+                  entry.setUpdated(Date.from(bookmark.date.toInstant()));
+
+                  var author = new SyndPersonImpl();
+                  author.setName(bookmark.owner.getFirstAndLastName());
+                  entry.setAuthors(List.of(author));
+
+                  var title = new Content();
+                  title.setType("text");
+                  title.setValue(bookmark.title);
+                  entry.setTitleEx(title);
+
+                  var summary = new Content();
+                  summary.setType("html");
+                  summary.setValue(bookmark.getDescriptionHtml());
+                  entry.setSummary(summary);
+
+                  var link = new Link();
+                  link.setHref(bookmark.uri);
+                  link.setRel("alternate");
+                  entry.setAlternateLinks(List.of(link));
+
+                  return entry;
+                })
+            .collect(Collectors.toUnmodifiableList()));
+
+    var wireFeedOutput = new WireFeedOutput();
+    return wireFeedOutput.outputString(feed);
+  }
+
+  @TemplateExtension
+  static String humanDateTime(TemporalAccessor x) {
+    return humanDateFormatter.format(x);
+  }
+
+  @TemplateExtension
+  static String htmlDateTime(TemporalAccessor x) {
+    return htmlDateFormatter.format(x);
+  }
+
+  private boolean showBookmarkForm() {
+    switch (postFilter) {
+      case ALL:
+      case BOOKMARKS_ONLY:
+        return !identity.isAnonymous();
+      case LAZYCHAT_MESSAGES_ONLY:
+        return false;
+      default:
+        throw new IllegalStateException();
+    }
+  }
+
+  private boolean showLazychatForm() {
+    switch (postFilter) {
+      case ALL:
+      case LAZYCHAT_MESSAGES_ONLY:
+        return !identity.isAnonymous();
+      case BOOKMARKS_ONLY:
+        return false;
+      default:
+        throw new IllegalStateException();
+    }
+  }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java
similarity index 95%
rename from src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java
rename to src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java
index 7073874..112ca3e 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java
@@ -1,4 +1,4 @@
-package eu.mulk.mulkcms2.benki.generic;
+package eu.mulk.mulkcms2.benki.posts;
 
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java
similarity index 96%
rename from src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java
rename to src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java
index 13c660d..ecd5861 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java
@@ -1,4 +1,4 @@
-package eu.mulk.mulkcms2.benki.generic;
+package eu.mulk.mulkcms2.benki.posts;
 
 import java.io.Serializable;
 import javax.persistence.Column;
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
index 6587ec4..5879046 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java
@@ -3,8 +3,8 @@
 import eu.mulk.mulkcms2.benki.accesscontrol.PageKey;
 import eu.mulk.mulkcms2.benki.accesscontrol.Role;
 import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
-import eu.mulk.mulkcms2.benki.generic.Post;
 import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
+import eu.mulk.mulkcms2.benki.posts.Post;
 import eu.mulk.mulkcms2.benki.wiki.WikiPageRevision;
 import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
 import java.util.Collection;