diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json index cc60035a..c49a27f6 100644 --- a/internal/locale/translations/de_DE.json +++ b/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.", diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json index 82282163..94d74149 100644 --- a/internal/locale/translations/el_EL.json +++ b/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": "Δεν υπάρχει συνδρομή για αυτήν την κατηγορία.", diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index 97e58fe5..77b73778 100644 --- a/internal/locale/translations/en_US.json +++ b/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.", diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json index 914d8994..d4669b39 100644 --- a/internal/locale/translations/es_ES.json +++ b/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.", diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json index 41bd2e50..6b2f2fca 100644 --- a/internal/locale/translations/fi_FI.json +++ b/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.", diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json index d616eb3e..fb2982fa 100644 --- a/internal/locale/translations/fr_FR.json +++ b/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.", diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json index 199c66af..ef7c0c04 100644 --- a/internal/locale/translations/hi_IN.json +++ b/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": "इस श्रेणी के लिए कोई सदस्यता नहीं है।", diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json index ee06ac61..3f1e3cd3 100644 --- a/internal/locale/translations/id_ID.json +++ b/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.", diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json index 6e808a02..e9385c7c 100644 --- a/internal/locale/translations/it_IT.json +++ b/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.", diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json index 8c767b55..efd2d37d 100644 --- a/internal/locale/translations/ja_JP.json +++ b/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": "このカテゴリには購読中のフィードがありません。", diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json index d47510e9..d9141819 100644 --- a/internal/locale/translations/nl_NL.json +++ b/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.", diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json index 32f3e4f8..3b39301d 100644 --- a/internal/locale/translations/pl_PL.json +++ b/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.", diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json index 56861a3a..7c639f5b 100644 --- a/internal/locale/translations/pt_BR.json +++ b/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.", diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json index 69c139f0..6327c09f 100644 --- a/internal/locale/translations/ru_RU.json +++ b/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": "Для этой категории нет подписки.", diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json index 15fe4c8c..4fc999a2 100644 --- a/internal/locale/translations/tr_TR.json +++ b/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.", diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json index eeb305d7..832a1471 100644 --- a/internal/locale/translations/uk_UA.json +++ b/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": "У цій категорії немає підписок.", diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json index dc1079c4..b32a270a 100644 --- a/internal/locale/translations/zh_CN.json +++ b/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": "目前没有历史", diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json index 4b4316f3..39504b73 100644 --- a/internal/locale/translations/zh_TW.json +++ b/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": "目前沒有歷史", diff --git a/internal/storage/entry_pagination_builder.go b/internal/storage/entry_pagination_builder.go index bab478d3..9779f245 100644 --- a/internal/storage/entry_pagination_builder.go +++ b/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") diff --git a/internal/storage/entry_query_builder.go b/internal/storage/entry_query_builder.go index 680fbedb..9ab26738 100644 --- a/internal/storage/entry_query_builder.go +++ b/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) } } diff --git a/internal/template/functions.go b/internal/template/functions.go index 54e787cf..cfbfc53d 100644 --- a/internal/template/functions.go +++ b/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 { diff --git a/internal/template/templates/views/entry.html b/internal/template/templates/views/entry.html index 4284f097..48f3c5fe 100644 --- a/internal/template/templates/views/entry.html +++ b/internal/template/templates/views/entry.html @@ -135,7 +135,7 @@ {{ if .entry.Tags }}
{{ t "entry.tags.label" }} - {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}{{ $e }}{{end}} + {{range $i, $e := .entry.Tags}}{{if $i}}, {{end}}{{ $e }}{{end}}
{{ end }}
diff --git a/internal/template/templates/views/tag_entries.html b/internal/template/templates/views/tag_entries.html new file mode 100644 index 00000000..86d1c203 --- /dev/null +++ b/internal/template/templates/views/tag_entries.html @@ -0,0 +1,52 @@ +{{ define "title"}}{{ .tagName }} ({{ .total }}){{ end }} + +{{ define "page_header"}} + +{{ end }} + +{{ define "content"}} +{{ if not .entries }} + +{{ else }} +
+ {{ template "pagination" .pagination }} +
+
+ {{ range .entries }} + + {{ end }} +
+
+ {{ template "pagination" .pagination }} +
+{{ end }} + +{{ end }} diff --git a/internal/ui/entry_tag.go b/internal/ui/entry_tag.go new file mode 100644 index 00000000..cf153a8b --- /dev/null +++ b/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")) +} diff --git a/internal/ui/tag_entries_all.go b/internal/ui/tag_entries_all.go new file mode 100644 index 00000000..a7f6fb02 --- /dev/null +++ b/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")) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6d8e729c..d6641c01 100644 --- a/internal/ui/ui.go +++ b/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)