Prechádzať zdrojové kódy

ui: add tag entries page

Romain de Laage 2 rokov pred
rodič
commit
647c66e70a

+ 1 - 0
internal/locale/translations/de_DE.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
     "alert.no_category": "Es ist keine Kategorie vorhanden.",
     "alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
+    "alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.",
     "alert.no_feed_entry": "Es existiert kein Artikel für dieses Abonnement.",
     "alert.no_feed": "Es sind keine Abonnements vorhanden.",
     "alert.no_feed_in_category": "Für diese Kategorie gibt es kein Abonnement.",

+ 1 - 0
internal/locale/translations/el_EL.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Δεν υπάρχει σελιδοδείκτης αυτή τη στιγμή.",
     "alert.no_category": "Δεν υπάρχει κατηγορία.",
     "alert.no_category_entry": "Δεν υπάρχουν άρθρα σε αυτήν την κατηγορία.",
+    "alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.",
     "alert.no_feed_entry": "Δεν υπάρχουν άρθρα για αυτήν τη ροή.",
     "alert.no_feed": "Δεν έχετε συνδρομές.",
     "alert.no_feed_in_category": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.",

+ 1 - 0
internal/locale/translations/en_US.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "There are no starred entries.",
     "alert.no_category": "There is no category.",
     "alert.no_category_entry": "There are no entries in this category.",
+    "alert.no_tag_entry": "There are no entries matching this tag.",
     "alert.no_feed_entry": "There are no entries for this feed.",
     "alert.no_feed": "You don’t have any feeds.",
     "alert.no_feed_in_category": "There is no feed for this category.",

+ 1 - 0
internal/locale/translations/es_ES.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "No hay marcador en este momento.",
     "alert.no_category": "No hay categoría.",
     "alert.no_category_entry": "No hay artículos en esta categoría.",
+    "alert.no_tag_entry": "No hay artículos con esta etiqueta.",
     "alert.no_feed_entry": "No hay artículos para esta fuente.",
     "alert.no_feed": "No tienes fuentes.",
     "alert.no_feed_in_category": "No hay fuentes para esta categoría.",

+ 1 - 0
internal/locale/translations/fi_FI.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Tällä hetkellä ei ole kirjanmerkkiä.",
     "alert.no_category": "Ei ole kategoriaa.",
     "alert.no_category_entry": "Tässä kategoriassa ei ole artikkeleita.",
+    "alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.",
     "alert.no_feed_entry": "Tässä syötteessä ei ole artikkeleita.",
     "alert.no_feed": "Sinulla ei ole tilauksia.",
     "alert.no_feed_in_category": "Tälle kategorialle ei ole tilausta.",

+ 1 - 0
internal/locale/translations/fr_FR.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
     "alert.no_category": "Il n'y a aucune catégorie.",
     "alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
+    "alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.",
     "alert.no_feed_entry": "Il n'y a aucun article pour cet abonnement.",
     "alert.no_feed": "Vous n'avez aucun abonnement.",
     "alert.no_feed_in_category": "Il n'y a pas d'abonnement pour cette catégorie.",

+ 1 - 0
internal/locale/translations/hi_IN.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "इस समय कोई बुकमार्क नहीं है",
     "alert.no_category": "कोई श्रेणी नहीं है।",
     "alert.no_category_entry": "इस श्रेणी में कोई विषय-वस्तु नहीं है।",
+    "alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।",
     "alert.no_feed_entry": "इस फ़ीड के लिए कोई विषय-वस्तु नहीं है।",
     "alert.no_feed": "आपके पास कोई सदस्यता नहीं है।",
     "alert.no_feed_in_category": "इस श्रेणी के लिए कोई सदस्यता नहीं है।",

+ 1 - 0
internal/locale/translations/id_ID.json

@@ -248,6 +248,7 @@
     "alert.no_bookmark": "Tidak ada markah.",
     "alert.no_category": "Tidak ada kategori.",
     "alert.no_category_entry": "Tidak ada artikel di kategori ini.",
+    "alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.",
     "alert.no_feed_entry": "Tidak ada artikel di umpan ini.",
     "alert.no_feed": "Anda tidak memiliki langganan.",
     "alert.no_feed_in_category": "Tidak ada langganan untuk kategori ini.",

+ 1 - 0
internal/locale/translations/it_IT.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Nessun preferito disponibile.",
     "alert.no_category": "Nessuna categoria disponibile.",
     "alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
+    "alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.",
     "alert.no_feed_entry": "Questo feed non contiene alcun articolo.",
     "alert.no_feed": "Nessun feed disponibile.",
     "alert.no_feed_in_category": "Non esiste un abbonamento per questa categoria.",

+ 1 - 0
internal/locale/translations/ja_JP.json

@@ -248,6 +248,7 @@
     "alert.no_bookmark": "現在星付きはありません。",
     "alert.no_category": "カテゴリが存在しません。",
     "alert.no_category_entry": "このカテゴリには記事がありません。",
+    "alert.no_tag_entry": "このタグに一致するエントリーはありません。",
     "alert.no_feed_entry": "このフィードには記事がありません。",
     "alert.no_feed": "何も購読していません。",
     "alert.no_feed_in_category": "このカテゴリには購読中のフィードがありません。",

+ 1 - 0
internal/locale/translations/nl_NL.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
     "alert.no_category": "Er zijn geen categorieën.",
     "alert.no_category_entry": "Deze categorie bevat geen feeds.",
+    "alert.no_tag_entry": "Er zijn geen items die overeenkomen met deze tag.",
     "alert.no_feed_entry": "Er zijn geen artikelen in deze feed.",
     "alert.no_feed": "Je hebt nog geen feeds geabboneerd staan.",
     "alert.no_feed_in_category": "Er is geen abonnement voor deze categorie.",

+ 1 - 0
internal/locale/translations/pl_PL.json

@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
     "alert.no_category": "Nie ma żadnej kategorii!",
     "alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
+    "alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
     "alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
     "alert.no_feed": "Nie masz żadnej subskrypcji.",
     "alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",

+ 1 - 0
internal/locale/translations/pt_BR.json

@@ -258,6 +258,7 @@
     "alert.no_bookmark": "Não há favorito neste momento.",
     "alert.no_category": "Não há categoria.",
     "alert.no_category_entry": "Não há itens nesta categoria.",
+    "alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.",
     "alert.no_feed_entry": "Não há itens nessa fonte.",
     "alert.no_feed": "Não há inscrições.",
     "alert.no_feed_in_category": "Não há inscrições nessa categoria.",

+ 1 - 0
internal/locale/translations/ru_RU.json

@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Избранное отсутствует.",
     "alert.no_category": "Категории отсутствуют.",
     "alert.no_category_entry": "В этой категории нет статей.",
+    "alert.no_tag_entry": "Нет записей, соответствующих этому тегу.",
     "alert.no_feed_entry": "В этой подписке отсутствуют статьи.",
     "alert.no_feed": "У вас нет ни одной подписки.",
     "alert.no_feed_in_category": "Для этой категории нет подписки.",

+ 1 - 0
internal/locale/translations/tr_TR.json

@@ -18,6 +18,7 @@
   "alert.no_bookmark": "Yıldızlanmış makale yok.",
   "alert.no_category": "Hiç kategori yok.",
   "alert.no_category_entry": "Bu kategoride hiç makele yok.",
+  "alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.",
   "alert.no_feed": "Hiç beslemeniz yok.",
   "alert.no_feed_entry": "Bu besleme için makele yok.",
   "alert.no_feed_in_category": "Bu kategori için besleme yok.",

+ 1 - 0
internal/locale/translations/uk_UA.json

@@ -268,6 +268,7 @@
     "alert.no_bookmark": "Наразі закладки відсутні.",
     "alert.no_category": "Немає категорії.",
     "alert.no_category_entry": "У цій категорії немає записів.",
+    "alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.",
     "alert.no_feed_entry": "У цій стрічці немає записів.",
     "alert.no_feed": "У вас немає підписок.",
     "alert.no_feed_in_category": "У цій категорії немає підписок.",

+ 1 - 0
internal/locale/translations/zh_CN.json

@@ -248,6 +248,7 @@
     "alert.no_bookmark": "目前没有收藏",
     "alert.no_category": "目前没有分类",
     "alert.no_category_entry": "该分类下没有文章",
+    "alert.no_tag_entry": "没有与此标签匹配的条目。",
     "alert.no_feed_entry": "该源中没有文章",
     "alert.no_feed": "目前没有源",
     "alert.no_history": "目前没有历史",

+ 1 - 0
internal/locale/translations/zh_TW.json

@@ -248,6 +248,7 @@
     "alert.no_bookmark": "目前沒有收藏",
     "alert.no_category": "目前沒有分類",
     "alert.no_category_entry": "該分類下沒有文章",
+    "alert.no_tag_entry": "沒有與此標籤相符的條目。",
     "alert.no_feed_entry": "該Feed中沒有文章",
     "alert.no_feed": "目前沒有Feed",
     "alert.no_history": "目前沒有歷史",

+ 9 - 0
internal/storage/entry_pagination_builder.go

@@ -58,6 +58,15 @@ func (e *EntryPaginationBuilder) WithStatus(status string) {
 	}
 }
 
+func (e *EntryPaginationBuilder) WithTags(tags []string) {
+	if len(tags) > 0 {
+		for _, tag := range tags {
+			e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
+			e.args = append(e.args, tag)
+		}
+	}
+}
+
 // WithGloballyVisible adds global visibility to the condition.
 func (e *EntryPaginationBuilder) WithGloballyVisible() {
 	e.conditions = append(e.conditions, "not c.hide_globally")

+ 1 - 1
internal/storage/entry_query_builder.go

@@ -160,7 +160,7 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder {
 func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder {
 	if len(tags) > 0 {
 		for _, cat := range tags {
-			e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1))
+			e.conditions = append(e.conditions, fmt.Sprintf("LOWER($%d) = ANY(LOWER(e.tags::text)::text[])", len(e.args)+1))
 			e.args = append(e.args, cat)
 		}
 	}

+ 4 - 2
internal/template/functions.go

@@ -8,6 +8,7 @@ import (
 	"html/template"
 	"math"
 	"net/mail"
+	"net/url"
 	"slices"
 	"strings"
 	"time"
@@ -91,8 +92,9 @@ func (f *funcMap) Map() template.FuncMap {
 		"nonce": func() string {
 			return crypto.GenerateRandomStringHex(16)
 		},
-		"deRef":    func(i *int) int { return *i },
-		"duration": duration,
+		"deRef":     func(i *int) int { return *i },
+		"duration":  duration,
+		"urlEncode": url.PathEscape,
 
 		// These functions are overrode at runtime after the parsing.
 		"elapsed": func(timezone string, t time.Time) string {

+ 1 - 1
internal/template/templates/views/entry.html

@@ -135,7 +135,7 @@
         {{ if .entry.Tags }}
         <div class="entry-tags">
             {{ t "entry.tags.label" }}
-            {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<strong>{{ $e }}</strong>{{end}}
+	    {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}<a href="{{ route "tagEntriesAll" "tagName" (urlEncode $e) }}"><strong>{{ $e }}</strong></a>{{end}}
         </div>
         {{ end }}
         <div class="entry-date">

+ 52 - 0
internal/template/templates/views/tag_entries.html

@@ -0,0 +1,52 @@
+{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }}
+
+{{ define "page_header"}}
+<section class="page-header" aria-labelledby="page-header-title page-header-title-count">
+    <h1 id="page-header-title" dir="auto">
+        {{ .tagName }}
+        <span aria-hidden="true"> ({{ .total }})</span>
+    </h1>
+    <span id="page-header-title-count" class="sr-only">{{ plural "page.tag_entry_count" .total .total }}</span>
+</section>
+{{ end }}
+
+{{ define "content"}}
+{{ if not .entries }}
+    <p role="alert" class="alert alert-info">{{ t "alert.no_tag_entry" }}</p>
+{{ else }}
+    <div class="pagination-top">
+        {{ template "pagination" .pagination }}
+    </div>
+    <div class="items">
+        {{ range .entries }}
+        <article
+            class="item entry-item {{ if $.user.EntrySwipe }}entry-swipe{{ end }} item-status-{{ .Status }}"
+            data-id="{{ .ID }}"
+            aria-labelledby="entry-title-{{ .ID }}"
+            tabindex="-1"
+        >
+            <header class="item-header" dir="auto">
+                <h2 id="entry-title-{{ .ID }}" class="item-title">
+                    <a href="{{ route "tagEntry" "entryID" .ID "tagName" (urlEncode $.tagName) }}">
+                        {{ if ne .Feed.Icon.IconID 0 }}
+                        <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16" loading="lazy" alt="">
+                        {{ end }}
+                        {{ .Title }}
+                    </a>
+                </h2>
+                <span class="category">
+                    <a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">
+                        {{ .Feed.Category.Title }}
+                    </a>
+                </span>
+            </header>
+            {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
+        </article>
+        {{ end }}
+    </div>
+    <div class="pagination-bottom">
+        {{ template "pagination" .pagination }}
+    </div>
+{{ end }}
+
+{{ end }}

+ 90 - 0
internal/ui/entry_tag.go

@@ -0,0 +1,90 @@
+// 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"
+	"net/url"
+
+	"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) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+	entryID := request.RouteInt64Param(r, "entryID")
+
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithTags([]string{tagName})
+	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.WithTags([]string{tagName})
+	prevEntry, nextEntry, err := entryPaginationBuilder.Entries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	nextEntryRoute := ""
+	if nextEntry != nil {
+		nextEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", nextEntry.ID)
+	}
+
+	prevEntryRoute := ""
+	if prevEntry != nil {
+		prevEntryRoute = route.Path(h.router, "tagEntry", "tagName", url.PathEscape(tagName), "entryID", prevEntry.ID)
+	}
+
+	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("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"))
+}

+ 65 - 0
internal/ui/tag_entries_all.go

@@ -0,0 +1,65 @@
+// 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"
+	"net/url"
+
+	"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/ui/session"
+	"miniflux.app/v2/internal/ui/view"
+)
+
+func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	tagName, err := url.PathUnescape(request.RouteStringParam(r, "tagName"))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	offset := request.QueryIntParam(r, "offset", 0)
+	builder := h.store.NewEntryQueryBuilder(user.ID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+	builder.WithTags([]string{tagName})
+	builder.WithSorting("status", "asc")
+	builder.WithSorting(user.EntryOrder, user.EntryDirection)
+	builder.WithOffset(offset)
+	builder.WithLimit(user.EntriesPerPage)
+
+	entries, err := builder.GetEntries()
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	count, err := builder.CountEntries()
+	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("tagName", tagName)
+	view.Set("total", count)
+	view.Set("entries", entries)
+	view.Set("pagination", getPagination(route.Path(h.router, "tagEntriesAll", "tagName", url.PathEscape(tagName)), count, offset, user.EntriesPerPage))
+	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))
+	view.Set("showOnlyUnreadEntries", false)
+
+	html.OK(w, r, view.Render("tag_entries"))
+}

+ 4 - 0
internal/ui/ui.go

@@ -93,6 +93,10 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
 	uiRouter.HandleFunc("/category/{categoryID}/remove", handler.removeCategory).Name("removeCategory").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/category/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Name("markCategoryAsRead").Methods(http.MethodPost)
 
+	// Tag pages.
+	uiRouter.HandleFunc("/tags/{tagName}/entries/all", handler.showTagEntriesAllPage).Name("tagEntriesAll").Methods(http.MethodGet)
+	uiRouter.HandleFunc("/tags/{tagName}/entry/{entryID}", handler.showTagEntryPage).Name("tagEntry").Methods(http.MethodGet)
+
 	// Entry pages.
 	uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)