Repository: miniflux/v2 (language: go)

Issue:
Pagination in unread category also shows read articles
I've been using the "hide category" feature to sort out my local news feeds & it's been working really well. I have however found a possible bug.

I read my local news articles by navigating to the hidden category, opening the newest article and using the "Next" button (well, the right arrow key) to navigate to the next article. I have noticed that when I have read an article that's older than the newest one, the read article still pops up when navigating the feed. For example, if my feed shows posts A, B & C (in this order) and I first read B before clicking on A and pressing "Next", it shows me B again instead of skipping to C.

Does this count as a bug? The pagination shows that the navigation buttons just go through the `/category/<category_id>/entry/<entry_id>` pages, which does indeed show unread articles as well.


Discussion / hints:
Duplicate of #1208 ?
@fguillot not quite: #1208 is about the global unread feed showing *unread* entries from hidden categories when using the navigation buttons. This issue says that, when navigating a category's list of unread entries (so, `/category/4/entries` for example) using the navigation buttons, the pagination also shows unread entries. I would expect this to work the same as the main unread list, namely that it only cycles through unread messages instead of all of them.

I agree that this one is quite similar to #1208. The reason I added them as two issues is because #1208 had already been fixed by @jlelse before I discovered this possible bug.
This also bothers me, maybe an additional query parameter will help. For example, `/category/<category_id>/entry/<entry_id>?unread=1`, or a new route like `/category/<category_id>/unread/entry/<entry_id>` or somthing like this and add unread conditional restrictions. Any willing to deal with this problem？  @fguillot 

I solved this problem in a previous pull request(might not good), but now it seems there is no way to continue the merge (due to conflicts), so I just deleted it for I don't know how to resolve it. 😅

Unexpected:

![unexpected](https://i.imgur.com/O8hkaYg.gif)

Expected(in my fork):

![expected](https://i.imgur.com/oD682iQ.gif)

Fix patch:
diff --git a/internal/template/templates/views/category_entries.html b/internal/template/templates/views/category_entries.html
index 4e45120d7be..4b66431bdf8 100644
--- a/internal/template/templates/views/category_entries.html
+++ b/internal/template/templates/views/category_entries.html
@@ -56,7 +56,11 @@ <h1 dir="auto">{{ .category.Title }} ({{ .total }})</h1>
                     {{ if ne .Feed.Icon.IconID 0 }}
                         <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="{{ .Feed.Title }}">
                     {{ end }}
-                    <a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ if $.showOnlyUnreadEntries }}
+                      <a href="{{ route "unreadCategoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ else }}
+                      <a href="{{ route "categoryEntry" "categoryID" .Feed.Category.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ end }}
                 </span>
                 <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
             </div>
diff --git a/internal/template/templates/views/feed_entries.html b/internal/template/templates/views/feed_entries.html
index 999e99c3bc2..1c2db382acd 100644
--- a/internal/template/templates/views/feed_entries.html
+++ b/internal/template/templates/views/feed_entries.html
@@ -88,7 +88,11 @@ <h3>{{ t "alert.feed_error" }}</h3>
                     {{ if ne .Feed.Icon.IconID 0 }}
                         <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="{{ .Feed.Title }}">
                     {{ end }}
-                    <a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ if $.showOnlyUnreadEntries }}
+                      <a href="{{ route "unreadFeedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ else }}
+                      <a href="{{ route "feedEntry" "feedID" .Feed.ID "entryID" .ID }}">{{ .Title }}</a>
+                    {{ end }}
                 </span>
                 <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
             </div>
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index f95d6ebf31a..0393d33bf7c 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -73,11 +73,13 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
 	uiRouter.HandleFunc("/feed/{feedID}/entries", handler.showFeedEntriesPage).Name("feedEntries").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/feed/{feedID}/entries/all", handler.showFeedEntriesAllPage).Name("feedEntriesAll").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", handler.showFeedEntryPage).Name("feedEntry").Methods(http.MethodGet)
+	uiRouter.HandleFunc("/unread/feed/{feedID}/entry/{entryID}", handler.showUnreadFeedEntryPage).Name("unreadFeedEntry").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/feed/icon/{iconID}", handler.showIcon).Name("icon").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/feed/{feedID}/mark-all-as-read", handler.markFeedAsRead).Name("markFeedAsRead").Methods(http.MethodPost)
 
 	// Category pages.
 	uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", handler.showCategoryEntryPage).Name("categoryEntry").Methods(http.MethodGet)
+	uiRouter.HandleFunc("/unread/category/{categoryID}/entry/{entryID}", handler.showUnreadCategoryEntryPage).Name("unreadCategoryEntry").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/categories", handler.showCategoryListPage).Name("categories").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/category/create", handler.showCreateCategoryPage).Name("createCategory").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/category/save", handler.saveCategory).Name("saveCategory").Methods(http.MethodPost)
diff --git a/internal/ui/unread_entry_category.go b/internal/ui/unread_entry_category.go
new file mode 100644
index 00000000000..b07f3a7af05
--- /dev/null
+++ b/internal/ui/unread_entry_category.go
@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response/html"
+	"miniflux.app/v2/internal/http/route"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/storage"
+	"miniflux.app/v2/internal/ui/session"
+	"miniflux.app/v2/internal/ui/view"
+)
+
+func (h *handler) showUnreadCategoryEntryPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	categoryID := request.RouteInt64Param(r, "categoryID")
+	entryID := request.RouteInt64Param(r, "entryID")
+
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithCategoryID(categoryID)
+	builder.WithEntryID(entryID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+
+	entry, err := builder.GetEntry()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	if entry == nil {
+		html.NotFound(w, r)
+		return
+	}
+
+	if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+
+		entry.Status = model.EntryStatusRead
+	}
+
+	entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
+	entryPaginationBuilder.WithCategoryID(categoryID)
+	entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
+
+	if entry.Status == model.EntryStatusRead {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+	}
+
+	prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	nextEntryRoute := ""
+	if nextEntry != nil {
+		nextEntryRoute = route.Path(h.router, "unreadCategoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
+	}
+
+	prevEntryRoute := ""
+	if prevEntry != nil {
+		prevEntryRoute = route.Path(h.router, "unreadCategoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
+	}
+
+	// Restore entry read status if needed after fetching the pagination.
+	if entry.Status == model.EntryStatusRead {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+	}
+
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	view.Set("entry", entry)
+	view.Set("prevEntry", prevEntry)
+	view.Set("nextEntry", nextEntry)
+	view.Set("nextEntryRoute", nextEntryRoute)
+	view.Set("prevEntryRoute", prevEntryRoute)
+	view.Set("menu", "categories")
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
+	view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
+
+	html.OK(w, r, view.Render("entry"))
+}
diff --git a/internal/ui/unread_entry_feed.go b/internal/ui/unread_entry_feed.go
new file mode 100644
index 00000000000..ad3bd068c1e
--- /dev/null
+++ b/internal/ui/unread_entry_feed.go
@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response/html"
+	"miniflux.app/v2/internal/http/route"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/storage"
+	"miniflux.app/v2/internal/ui/session"
+	"miniflux.app/v2/internal/ui/view"
+)
+
+func (h *handler) showUnreadFeedEntryPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	entryID := request.RouteInt64Param(r, "entryID")
+	feedID := request.RouteInt64Param(r, "feedID")
+
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithFeedID(feedID)
+	builder.WithEntryID(entryID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+
+	entry, err := builder.GetEntry()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	if entry == nil {
+		html.NotFound(w, r)
+		return
+	}
+
+	if user.MarkReadOnView && entry.Status == model.EntryStatusUnread {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+
+		entry.Status = model.EntryStatusRead
+	}
+
+	entryPaginationBuilder := storage.NewEntryPaginationBuilder(h.store, user.ID, entry.ID, user.EntryOrder, user.EntryDirection)
+	entryPaginationBuilder.WithFeedID(feedID)
+	entryPaginationBuilder.WithStatus(model.EntryStatusUnread)
+
+	if entry.Status == model.EntryStatusRead {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusUnread)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+	}
+
+	prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	nextEntryRoute := ""
+	if nextEntry != nil {
+		nextEntryRoute = route.Path(h.router, "unreadFeedEntry", "feedID", feedID, "entryID", nextEntry.ID)
+	}
+
+	prevEntryRoute := ""
+	if prevEntry != nil {
+		prevEntryRoute = route.Path(h.router, "unreadFeedEntry", "feedID", feedID, "entryID", prevEntry.ID)
+	}
+
+	// Restore entry read status if needed after fetching the pagination.
+	if entry.Status == model.EntryStatusRead {
+		err = h.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+	}
+
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	view.Set("entry", entry)
+	view.Set("prevEntry", prevEntry)
+	view.Set("nextEntry", nextEntry)
+	view.Set("nextEntryRoute", nextEntryRoute)
+	view.Set("prevEntryRoute", prevEntryRoute)
+	view.Set("menu", "feeds")
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
+	view.Set("hasSaveEntry", h.store.HasSaveEntry(user.ID))
+
+	html.OK(w, r, view.Render("entry"))
+}


Test patch: