From 6ca766c4caf92a9f3390538fda7282fb5505936f Mon Sep 17 00:00:00 2001 From: pettijohn <632844+pettijohn@users.noreply.github.com> Date: Mon, 4 May 2026 21:50:37 +0000 Subject: [PATCH 1/2] feat(api): allow updating entry tags Extend PUT /v1/entries/{entryID} to accept an optional tags field that replaces the entry tag list. Preserve existing title/content update behavior and allow an empty tag list to clear tags. Update the Go client request type and API examples. --- client/client_test.go | 3 ++ client/model.go | 5 ++- contrib/bruno/miniflux/Update entry.bru | 3 +- internal/api/api_integration_test.go | 47 +++++++++++++++++++++++ internal/api/entry_handlers.go | 16 +++++++- internal/model/entry.go | 9 ++++- internal/model/entry_test.go | 29 ++++++++++++++ internal/storage/entry.go | 50 +++++++++++++++++++++++++ internal/validator/entry.go | 11 ++++++ internal/validator/entry_test.go | 18 +++++++++ 10 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 internal/model/entry_test.go diff --git a/client/client_test.go b/client/client_test.go index d43dcc05f95..26662f5d006 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1113,6 +1113,7 @@ func TestUpdateEntry(t *testing.T) { ID: 1, Title: "Example", } + tags := []string{"digested", "digested-advertisement-false"} client := NewClientWithOptions( "http://mf", WithHTTPClient( @@ -1120,11 +1121,13 @@ func TestUpdateEntry(t *testing.T) { expectRequest(t, http.MethodPut, "http://mf/v1/entries/1", nil, req) expectFromJSON(t, req.Body, &EntryModificationRequest{ Title: &expected.Title, + Tags: &tags, }) return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected) }))) res, err := client.UpdateEntryContext(t.Context(), 1, &EntryModificationRequest{ Title: &expected.Title, + Tags: &tags, }) if err != nil { t.Fatalf("Expected no error, got %v", err) diff --git a/client/model.go b/client/model.go index c28168d0157..a6d7d5226ca 100644 --- a/client/model.go +++ b/client/model.go @@ -270,8 +270,9 @@ type Entry struct { // EntryModificationRequest represents a request to modify an entry. type EntryModificationRequest struct { - Title *string `json:"title"` - Content *string `json:"content"` + Title *string `json:"title"` + Content *string `json:"content"` + Tags *[]string `json:"tags,omitempty"` } // Entries represents a list of entries. diff --git a/contrib/bruno/miniflux/Update entry.bru b/contrib/bruno/miniflux/Update entry.bru index a349653985f..c0de9284d0f 100644 --- a/contrib/bruno/miniflux/Update entry.bru +++ b/contrib/bruno/miniflux/Update entry.bru @@ -18,7 +18,8 @@ auth:basic { body:json { { "title": "New title", - "content": "Some text" + "content": "Some text", + "tags": ["digested", "digested-advertisement-false"] } } diff --git a/internal/api/api_integration_test.go b/internal/api/api_integration_test.go index 01c261aed38..9afb4027943 100644 --- a/internal/api/api_integration_test.go +++ b/internal/api/api_integration_test.go @@ -10,6 +10,7 @@ import ( "io" "math/rand/v2" "os" + "reflect" "strings" "testing" @@ -2689,6 +2690,52 @@ func TestUpdateEntryEndpoint(t *testing.T) { if entry.Content != "New content" { t.Errorf(`Invalid content, got %q`, entry.Content) } + + originalTitle := entry.Title + originalContent := entry.Content + tags := []string{" digested ", "digested-advertisement-false"} + updatedEntry, err = regularUserClient.UpdateEntry(result.Entries[0].ID, &miniflux.EntryModificationRequest{ + Tags: &tags, + }) + if err != nil { + t.Fatal(err) + } + + expectedTags := []string{"digested", "digested-advertisement-false"} + if !reflect.DeepEqual(updatedEntry.Tags, expectedTags) { + t.Errorf(`Invalid tags, got %v`, updatedEntry.Tags) + } + if updatedEntry.Title != originalTitle { + t.Errorf(`Title should not change when only tags are updated, got %q`, updatedEntry.Title) + } + if updatedEntry.Content != originalContent { + t.Errorf(`Content should not change when only tags are updated, got %q`, updatedEntry.Content) + } + + entry, err = regularUserClient.Entry(result.Entries[0].ID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(entry.Tags, expectedTags) { + t.Errorf(`Invalid persisted tags, got %v`, entry.Tags) + } + if entry.Title != originalTitle { + t.Errorf(`Persisted title should not change when only tags are updated, got %q`, entry.Title) + } + if entry.Content != originalContent { + t.Errorf(`Persisted content should not change when only tags are updated, got %q`, entry.Content) + } + + tags = []string{} + updatedEntry, err = regularUserClient.UpdateEntry(result.Entries[0].ID, &miniflux.EntryModificationRequest{ + Tags: &tags, + }) + if err != nil { + t.Fatal(err) + } + if len(updatedEntry.Tags) != 0 { + t.Errorf(`Expected tags to be cleared, got %v`, updatedEntry.Tags) + } } func TestToggleStarredEndpoint(t *testing.T) { diff --git a/internal/api/entry_handlers.go b/internal/api/entry_handlers.go index 93ef8a6d547..337c606b9f0 100644 --- a/internal/api/entry_handlers.go +++ b/internal/api/entry_handlers.go @@ -315,12 +315,24 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) { entryUpdateRequest.Content = &sanitizedContent } + hasTitleOrContentUpdate := entryUpdateRequest.Title != nil || entryUpdateRequest.Content != nil + hasTagsUpdate := entryUpdateRequest.Tags != nil + entryUpdateRequest.Patch(entry) - if user.ShowReadingTime { + if hasTitleOrContentUpdate && user.ShowReadingTime { entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed) } - if err := h.store.UpdateEntryTitleAndContent(entry); err != nil { + if hasTagsUpdate { + if hasTitleOrContentUpdate { + err = h.store.UpdateEntryTitleContentAndTags(entry) + } else { + err = h.store.UpdateEntryTags(entry) + } + } else if hasTitleOrContentUpdate { + err = h.store.UpdateEntryTitleAndContent(entry) + } + if err != nil { response.JSONServerError(w, r, err) return } diff --git a/internal/model/entry.go b/internal/model/entry.go index 0e183029165..f9a71428d2a 100644 --- a/internal/model/entry.go +++ b/internal/model/entry.go @@ -80,8 +80,9 @@ type EntriesStatusUpdateRequest struct { // EntryUpdateRequest represents a request to update an entry. type EntryUpdateRequest struct { - Title *string `json:"title"` - Content *string `json:"content"` + Title *string `json:"title"` + Content *string `json:"content"` + Tags *[]string `json:"tags"` } func (e *EntryUpdateRequest) Patch(entry *Entry) { @@ -92,4 +93,8 @@ func (e *EntryUpdateRequest) Patch(entry *Entry) { if e.Content != nil && *e.Content != "" { entry.Content = *e.Content } + + if e.Tags != nil { + entry.Tags = append([]string(nil), (*e.Tags)...) + } } diff --git a/internal/model/entry_test.go b/internal/model/entry_test.go new file mode 100644 index 00000000000..191964ba252 --- /dev/null +++ b/internal/model/entry_test.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "reflect" + "testing" +) + +func TestEntryUpdateRequestPatchTags(t *testing.T) { + entry := &Entry{Tags: []string{"original"}} + (&EntryUpdateRequest{}).Patch(entry) + if !reflect.DeepEqual(entry.Tags, []string{"original"}) { + t.Fatalf(`Expected omitted tags to leave entry tags unchanged, got %v`, entry.Tags) + } + + emptyTags := []string{} + (&EntryUpdateRequest{Tags: &emptyTags}).Patch(entry) + if len(entry.Tags) != 0 { + t.Fatalf(`Expected empty tags to clear entry tags, got %v`, entry.Tags) + } + + newTags := []string{"foo", "bar"} + (&EntryUpdateRequest{Tags: &newTags}).Patch(entry) + if !reflect.DeepEqual(entry.Tags, newTags) { + t.Fatalf(`Expected tags to be replaced with %v, got %v`, newTags, entry.Tags) + } +} diff --git a/internal/storage/entry.go b/internal/storage/entry.go index c92a92fe511..e595af83978 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -100,6 +100,56 @@ func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error { return nil } +// UpdateEntryTags updates entry tags. +func (s *Storage) UpdateEntryTags(entry *model.Entry) error { + query := ` + UPDATE + entries + SET + tags=$1 + WHERE + id=$2 AND user_id=$3 + ` + + if _, err := s.db.Exec(query, pq.Array(entry.Tags), entry.ID, entry.UserID); err != nil { + return fmt.Errorf(`store: unable to update entry #%d tags: %v`, entry.ID, err) + } + + return nil +} + +// UpdateEntryTitleContentAndTags updates entry title, content and tags. +func (s *Storage) UpdateEntryTitleContentAndTags(entry *model.Entry) error { + truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content) + query := ` + UPDATE + entries + SET + title=$1, + content=$2, + reading_time=$3, + document_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B'), + tags=$6 + WHERE + id=$7 AND user_id=$8 + ` + + if _, err := s.db.Exec( + query, + entry.Title, + entry.Content, + entry.ReadingTime, + truncatedTitle, + truncatedContent, + pq.Array(entry.Tags), + entry.ID, + entry.UserID); err != nil { + return fmt.Errorf(`store: unable to update entry #%d: %v`, entry.ID, err) + } + + return nil +} + // createEntry add a new entry. func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error { truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content) diff --git a/internal/validator/entry.go b/internal/validator/entry.go index 74d4a555989..ab18461f906 100644 --- a/internal/validator/entry.go +++ b/internal/validator/entry.go @@ -6,6 +6,7 @@ package validator // import "miniflux.app/v2/internal/validator" import ( "errors" "fmt" + "strings" "miniflux.app/v2/internal/model" ) @@ -49,5 +50,15 @@ func ValidateEntryModification(request *model.EntryUpdateRequest) error { return errors.New(`the entry content cannot be empty`) } + if request.Tags != nil { + for i, tag := range *request.Tags { + tag = strings.TrimSpace(tag) + if tag == "" { + return errors.New(`entry tags cannot contain empty values`) + } + (*request.Tags)[i] = tag + } + } + return nil } diff --git a/internal/validator/entry_test.go b/internal/validator/entry_test.go index b35977a2462..a510334eab9 100644 --- a/internal/validator/entry_test.go +++ b/internal/validator/entry_test.go @@ -82,4 +82,22 @@ func TestValidateEntryModification(t *testing.T) { if err := ValidateEntryModification(&model.EntryUpdateRequest{Title: &title, Content: &content}); err != nil { t.Errorf(`A valid title and content should not generate any error: %v`, err) } + + tags := []string{" tag1 ", "tag2"} + if err := ValidateEntryModification(&model.EntryUpdateRequest{Tags: &tags}); err != nil { + t.Errorf(`A valid tag list should not generate any error: %v`, err) + } + if tags[0] != "tag1" || tags[1] != "tag2" { + t.Errorf(`Tags should be trimmed, got %v`, tags) + } + + tags = []string{} + if err := ValidateEntryModification(&model.EntryUpdateRequest{Tags: &tags}); err != nil { + t.Errorf(`An empty tag list should not generate any error: %v`, err) + } + + tags = []string{" "} + if err := ValidateEntryModification(&model.EntryUpdateRequest{Tags: &tags}); err == nil { + t.Error(`An empty tag should generate an error`) + } } From 721cb800619234ccc95fd513bd7bb6a07d354c56 Mon Sep 17 00:00:00 2001 From: pettijohn <632844+pettijohn@users.noreply.github.com> Date: Mon, 4 May 2026 22:18:43 +0000 Subject: [PATCH 2/2] feat(ui): add tags listing page Add a hidden /tags page that lists all tags with entry and unread counts. Reuse the category list item template for categories and tags, and add a tag-specific mark-all-as-read action. Add AI translations for new keys, alert.no_tag and page.tags.title. --- internal/locale/translations/ar_SA.json | 2 + internal/locale/translations/de_DE.json | 2 + internal/locale/translations/el_EL.json | 2 + internal/locale/translations/en_US.json | 2 + internal/locale/translations/es_ES.json | 2 + internal/locale/translations/fi_FI.json | 2 + internal/locale/translations/fr_FR.json | 2 + internal/locale/translations/gl_ES.json | 2 + internal/locale/translations/hi_IN.json | 2 + internal/locale/translations/id_ID.json | 2 + internal/locale/translations/it_IT.json | 2 + internal/locale/translations/ja_JP.json | 2 + .../locale/translations/nan_Latn_pehoeji.json | 2 + internal/locale/translations/nl_NL.json | 2 + internal/locale/translations/pl_PL.json | 2 + internal/locale/translations/pt_BR.json | 2 + internal/locale/translations/ro_RO.json | 2 + internal/locale/translations/ru_RU.json | 2 + internal/locale/translations/tr_TR.json | 2 + internal/locale/translations/uk_UA.json | 2 + internal/locale/translations/zh_CN.json | 2 + internal/locale/translations/zh_TW.json | 2 + internal/model/tag.go | 14 +++ internal/storage/tag.go | 106 ++++++++++++++++++ internal/template/engine.go | 3 +- internal/template/engine_test.go | 11 ++ .../template/templates/common/item_list.html | 55 +++++++++ .../template/templates/views/categories.html | 71 +----------- internal/template/templates/views/tags.html | 15 +++ internal/ui/category_list.go | 59 ++++++++++ internal/ui/item_list.go | 30 +++++ internal/ui/tag_list.go | 70 ++++++++++++ internal/ui/tag_mark_as_read.go | 35 ++++++ internal/ui/ui.go | 2 + 34 files changed, 444 insertions(+), 71 deletions(-) create mode 100644 internal/model/tag.go create mode 100644 internal/storage/tag.go create mode 100644 internal/template/engine_test.go create mode 100644 internal/template/templates/common/item_list.html create mode 100644 internal/template/templates/views/tags.html create mode 100644 internal/ui/item_list.go create mode 100644 internal/ui/tag_list.go create mode 100644 internal/ui/tag_mark_as_read.go diff --git a/internal/locale/translations/ar_SA.json b/internal/locale/translations/ar_SA.json index 0c8f4d19dec..650cd4d7942 100644 --- a/internal/locale/translations/ar_SA.json +++ b/internal/locale/translations/ar_SA.json @@ -25,6 +25,7 @@ "alert.no_search_result": "لا توجد نتائج لهذا البحث.", "alert.no_shared_entry": "لا توجد مشاركات.", "alert.no_tag_entry": "لا توجد مقالات تطابق هذا الوسم.", + "alert.no_tag": "لا يوجد وسم.", "alert.no_unread_entry": "لا توجد مقالات غير مقروءة.", "alert.no_user": "أنت المستخدم الوحيد.", "alert.prefs_saved": "تم حفظ التفضيلات!", @@ -475,6 +476,7 @@ "page.categories.feeds": "المصادر", "page.categories.no_feed": "لا يوجد مصدر.", "page.categories.title": "الفئات", + "page.tags.title": "الوسوم", "page.categories_count": [ "%d فئة", "فئة واحدة", diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json index bcbd59e2f7a..e7a5430a152 100644 --- a/internal/locale/translations/de_DE.json +++ b/internal/locale/translations/de_DE.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Es gibt kein Ergebnis für diese Suche.", "alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.", "alert.no_tag_entry": "Es gibt keine Artikel, die diesem Tag entsprechen.", + "alert.no_tag": "Es gibt kein Stichwort.", "alert.no_unread_entry": "Es existiert kein ungelesener Artikel.", "alert.no_user": "Sie sind der einzige Benutzer.", "alert.prefs_saved": "Einstellungen gespeichert!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Abonnements", "page.categories.no_feed": "Kein Abonnement.", "page.categories.title": "Kategorien", + "page.tags.title": "Stichworte", "page.categories_count": [ "%d Kategorie", "%d Kategorien" diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json index 792f57f69b4..f7b7df8adfc 100644 --- a/internal/locale/translations/el_EL.json +++ b/internal/locale/translations/el_EL.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Δεν υπάρχουν αποτελέσματα για αυτήν την αναζήτηση.", "alert.no_shared_entry": "Δεν υπάρχει κοινόχρηστη καταχώρηση.", "alert.no_tag_entry": "Δεν υπάρχουν αντικείμενα που να ταιριάζουν με αυτή την ετικέτα.", + "alert.no_tag": "Δεν υπάρχει ετικέτα.", "alert.no_unread_entry": "Δεν υπάρχουν μη αναγνωσμένα άρθρα.", "alert.no_user": "Είστε ο μόνος χρήστης.", "alert.prefs_saved": "Οι προτιμήσεις αποθηκεύτηκαν!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Συνδρομές", "page.categories.no_feed": "Καμία ροή.", "page.categories.title": "Κατηγορίες", + "page.tags.title": "Ετικέτες", "page.categories_count": [ "%d κατηγορία", "%d κατηγορίες" diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json index c9198d3718b..8595e8c0352 100644 --- a/internal/locale/translations/en_US.json +++ b/internal/locale/translations/en_US.json @@ -25,6 +25,7 @@ "alert.no_search_result": "There are no results for this search.", "alert.no_shared_entry": "There is no shared entry.", "alert.no_tag_entry": "There are no entries matching this tag.", + "alert.no_tag": "There is no tag.", "alert.no_unread_entry": "There are no unread entries.", "alert.no_user": "You are the only user.", "alert.prefs_saved": "Preferences saved!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Feeds", "page.categories.no_feed": "No feed.", "page.categories.title": "Categories", + "page.tags.title": "Tags", "page.categories_count": [ "%d category", "%d categories" diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json index 80691d3af30..76afa03c62e 100644 --- a/internal/locale/translations/es_ES.json +++ b/internal/locale/translations/es_ES.json @@ -25,6 +25,7 @@ "alert.no_search_result": "No hay resultados para esta búsqueda.", "alert.no_shared_entry": "No hay artículos compartidos.", "alert.no_tag_entry": "No hay artículos con esta etiqueta.", + "alert.no_tag": "No hay etiquetas.", "alert.no_unread_entry": "No hay artículos sin leer.", "alert.no_user": "Eres el único usuario.", "alert.prefs_saved": "¡Las preferencias se han guardado!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Fuentes", "page.categories.no_feed": "Sin fuente.", "page.categories.title": "Categorías", + "page.tags.title": "Etiquetas", "page.categories_count": [ "%d categoría", "%d categorías" diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json index 4784663f80e..71c614c4a59 100644 --- a/internal/locale/translations/fi_FI.json +++ b/internal/locale/translations/fi_FI.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Ei hakua vastaavia tuloksia.", "alert.no_shared_entry": "Jaettua artikkelia ei ole.", "alert.no_tag_entry": "Tätä tunnistetta vastaavia merkintöjä ei ole.", + "alert.no_tag": "Tunnisteita ei ole.", "alert.no_unread_entry": "Ei ole lukemattomia artikkeleita.", "alert.no_user": "Olet ainoa käyttäjä.", "alert.prefs_saved": "Asetukset tallennettu!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Tilaukset", "page.categories.no_feed": "Ei syötettä.", "page.categories.title": "Kategoriat", + "page.tags.title": "Tunnisteet", "page.categories_count": [ "%d kategoria", "%d kategoriaa" diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json index 7723d021c1a..9d30e004d58 100644 --- a/internal/locale/translations/fr_FR.json +++ b/internal/locale/translations/fr_FR.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Il n'y a aucun résultat pour cette recherche.", "alert.no_shared_entry": "Il n'y a pas d'article partagé.", "alert.no_tag_entry": "Il n'y a aucun article correspondant à ce tag.", + "alert.no_tag": "Il n'y a aucun libellé.", "alert.no_unread_entry": "Il n'y a rien de nouveau à lire.", "alert.no_user": "Vous êtes le seul utilisateur.", "alert.prefs_saved": "Préférences sauvegardées !", @@ -459,6 +460,7 @@ "page.categories.feeds": "Abonnements", "page.categories.no_feed": "Aucun abonnement.", "page.categories.title": "Catégories", + "page.tags.title": "Libellés", "page.categories_count": [ "%d catégorie", "%d catégories" diff --git a/internal/locale/translations/gl_ES.json b/internal/locale/translations/gl_ES.json index e16b07fb4f2..b40ae46df40 100644 --- a/internal/locale/translations/gl_ES.json +++ b/internal/locale/translations/gl_ES.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Non hai resultados para esta busca.", "alert.no_shared_entry": "Non hai artigos compartidos.", "alert.no_tag_entry": "Non hai artigos con esta etiqueta.", + "alert.no_tag": "Non hai etiquetas.", "alert.no_unread_entry": "Non hai artigos sen ler.", "alert.no_user": "Es a única conta usuaria.", "alert.prefs_saved": "Gardáronse as preferencias!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Canles", "page.categories.no_feed": "No hai canles.", "page.categories.title": "Categorías", + "page.tags.title": "Etiquetas", "page.categories_count": [ "%d categoría", "%d categorías" diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json index 89613e8b8d6..4f5315420cb 100644 --- a/internal/locale/translations/hi_IN.json +++ b/internal/locale/translations/hi_IN.json @@ -25,6 +25,7 @@ "alert.no_search_result": "इस खोज के लिए कोई परिणाम नहीं हैं।", "alert.no_shared_entry": "कोई साझा प्रविष्टि नहीं है", "alert.no_tag_entry": "इस टैग से मेल खाती कोई प्रविष्टियाँ नहीं हैं।", + "alert.no_tag": "कोई टैग नहीं है।", "alert.no_unread_entry": "कोई अपठित वस्तुत नहीं है।", "alert.no_user": "आप एकमात्र उपयोगकर्ता हैं।", "alert.prefs_saved": "प्राथमिकताएं सहेजी गईं!", @@ -459,6 +460,7 @@ "page.categories.feeds": "सदस्यता ले", "page.categories.no_feed": "कोई फ़ीड नहीं है।", "page.categories.title": "श्रेणियाँ", + "page.tags.title": "टैग", "page.categories_count": [ "%d श्रेणी", "%d श्रेणियाँ" diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json index b6e104224ff..8877685a89c 100644 --- a/internal/locale/translations/id_ID.json +++ b/internal/locale/translations/id_ID.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Tidak ada hasil untuk pencarian ini.", "alert.no_shared_entry": "Tidak ada entri yang dibagikan.", "alert.no_tag_entry": "Tidak ada entri yang cocok dengan tag ini.", + "alert.no_tag": "Tidak ada tanda.", "alert.no_unread_entry": "Belum ada artikel yang dibaca.", "alert.no_user": "Anda adalah satu-satunya pengguna.", "alert.prefs_saved": "Preferensi disimpan!", @@ -455,6 +456,7 @@ "page.categories.feeds": "Langganan", "page.categories.no_feed": "Tidak ada umpan.", "page.categories.title": "Kategori", + "page.tags.title": "Tanda", "page.categories_count": [ "%d kategori" ], diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json index 6dcc0f7ff56..f4c2c75e596 100644 --- a/internal/locale/translations/it_IT.json +++ b/internal/locale/translations/it_IT.json @@ -25,6 +25,7 @@ "alert.no_search_result": "La ricerca non ha prodotto risultati.", "alert.no_shared_entry": "Non ci sono voci condivise.", "alert.no_tag_entry": "Non ci sono voci corrispondenti a questo tag.", + "alert.no_tag": "Non ci sono tag.", "alert.no_unread_entry": "Nessun articolo da leggere.", "alert.no_user": "Tu sei l'unico utente.", "alert.prefs_saved": "Preferenze salvate!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Abbonamenti", "page.categories.no_feed": "Nessun feed.", "page.categories.title": "Categorie", + "page.tags.title": "Tag", "page.categories_count": [ "%d categoria", "%d categorie" diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json index f2d8f523a4a..38aae18daad 100644 --- a/internal/locale/translations/ja_JP.json +++ b/internal/locale/translations/ja_JP.json @@ -25,6 +25,7 @@ "alert.no_search_result": "検索で何も見つかりませんでした。", "alert.no_shared_entry": "共有エントリはありません。", "alert.no_tag_entry": "このタグに一致するエントリーはありません。", + "alert.no_tag": "タグはありません。", "alert.no_unread_entry": "未読の記事はありません。", "alert.no_user": "あなたが唯一のユーザーです。", "alert.prefs_saved": "設定情報は保存されました!", @@ -455,6 +456,7 @@ "page.categories.feeds": "フィード一覧", "page.categories.no_feed": "フィードはありません。", "page.categories.title": "カテゴリ", + "page.tags.title": "タグ", "page.categories_count": [ "%d 件のカテゴリ" ], diff --git a/internal/locale/translations/nan_Latn_pehoeji.json b/internal/locale/translations/nan_Latn_pehoeji.json index c307b67e8d8..9695c2e6ea4 100644 --- a/internal/locale/translations/nan_Latn_pehoeji.json +++ b/internal/locale/translations/nan_Latn_pehoeji.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Bô hû-ha̍p ê chhiau-chhē kiat-kó", "alert.no_shared_entry": "Chit-má ah bô hun-hióng ê siau-sit", "alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit", + "alert.no_tag": "Bô khan-á.", "alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit", "alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng", "alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!", @@ -455,6 +456,7 @@ "page.categories.feeds": "Siau-sit lâi-goân", "page.categories.no_feed": "Ah-bô siau-sit lâi-goân", "page.categories.title": "Lūi-pia̍t", + "page.tags.title": "Khan-á", "page.categories_count": [ "%d ê lūi-pia̍t" ], diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json index baa201904d9..712453a28c8 100644 --- a/internal/locale/translations/nl_NL.json +++ b/internal/locale/translations/nl_NL.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Er is geen resultaat voor deze zoekopdracht.", "alert.no_shared_entry": "Er is geen gedeeld artikel.", "alert.no_tag_entry": "Er zijn geen artikelen die overeenkomen met deze tag.", + "alert.no_tag": "Er zijn geen labels.", "alert.no_unread_entry": "Er zijn geen ongelezen artikelen.", "alert.no_user": "Je bent de enige gebruiker.", "alert.prefs_saved": "Instellingen opgeslagen!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Feeds", "page.categories.no_feed": "Geen feed.", "page.categories.title": "Categorieën", + "page.tags.title": "Labels", "page.categories_count": [ "%d categorie", "%d categorieën" diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json index fc997a717c9..a70fdf14919 100644 --- a/internal/locale/translations/pl_PL.json +++ b/internal/locale/translations/pl_PL.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Brak wyników tego wyszukiwania.", "alert.no_shared_entry": "Brak udostępnionego wpisu.", "alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.", + "alert.no_tag": "Brak znaczników.", "alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.", "alert.no_user": "Jesteś jedynym użytkownikiem.", "alert.prefs_saved": "Ustawienia zapisane!", @@ -463,6 +464,7 @@ "page.categories.feeds": "Kanały", "page.categories.no_feed": "Brak kanałów.", "page.categories.title": "Kategorie", + "page.tags.title": "Znaczniki", "page.categories_count": [ "%d kategoria", "%d kategorie", diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json index 448df76638a..4f7ea6d185d 100644 --- a/internal/locale/translations/pt_BR.json +++ b/internal/locale/translations/pt_BR.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Não há resultados para essa busca.", "alert.no_shared_entry": "Não há itens compartilhados.", "alert.no_tag_entry": "Não há itens que correspondam a esta etiqueta.", + "alert.no_tag": "Não há etiquetas.", "alert.no_unread_entry": "Não há itens não lidos.", "alert.no_user": "Você é o único usuário.", "alert.prefs_saved": "Suas preferências foram salvas!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Inscrições", "page.categories.no_feed": "Sem fonte.", "page.categories.title": "Categorias", + "page.tags.title": "Etiquetas", "page.categories_count": [ "%d categoria", "%d categorias" diff --git a/internal/locale/translations/ro_RO.json b/internal/locale/translations/ro_RO.json index bfdeb6e0dbb..6c2c4960718 100644 --- a/internal/locale/translations/ro_RO.json +++ b/internal/locale/translations/ro_RO.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Nu există înregistrări pentru această căutare.", "alert.no_shared_entry": "Nu sunt înregistrări partajate.", "alert.no_tag_entry": "Nu sunt înregistrări pentru această etichetă.", + "alert.no_tag": "Nu există etichete.", "alert.no_unread_entry": "Nu sunt intrări necitite.", "alert.no_user": "Sunteți singurul utilizator.", "alert.prefs_saved": "Preferințe salvate!", @@ -463,6 +464,7 @@ "page.categories.feeds": "Fluxuri", "page.categories.no_feed": "Nici un flux.", "page.categories.title": "Categorii", + "page.tags.title": "Etichete", "page.categories_count": [ "%d categorie", "%d categorii", diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json index 285e6ccb9a8..8a00ea5078a 100644 --- a/internal/locale/translations/ru_RU.json +++ b/internal/locale/translations/ru_RU.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Нет результатов для данного поискового запроса.", "alert.no_shared_entry": "Общедоступные статьи отсутствуют.", "alert.no_tag_entry": "Нет записей, соответствующих этому тегу.", + "alert.no_tag": "Нет тегов.", "alert.no_unread_entry": "Нет непрочитанных статей.", "alert.no_user": "Вы единственный пользователь.", "alert.prefs_saved": "Предпочтения сохранены!", @@ -463,6 +464,7 @@ "page.categories.feeds": "Подписки", "page.categories.no_feed": "Нет подписок.", "page.categories.title": "Категории", + "page.tags.title": "Теги", "page.categories_count": [ "%d категория", "%d категории", diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json index 9c07dec6ae2..26bda0d0d4a 100644 --- a/internal/locale/translations/tr_TR.json +++ b/internal/locale/translations/tr_TR.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Bu arama için sonuç yok", "alert.no_shared_entry": "Paylaşılan bir makele yok.", "alert.no_tag_entry": "Bu etiketle eşleşen hiçbir giriş yok.", + "alert.no_tag": "Etiket yok.", "alert.no_unread_entry": "Okunmamış makele yok", "alert.no_user": "Tek kullanıcı sizsiniz", "alert.prefs_saved": "Tercihler kaydedildi!", @@ -459,6 +460,7 @@ "page.categories.feeds": "Beslemeler", "page.categories.no_feed": "Besleme yok.", "page.categories.title": "Kategoriler", + "page.tags.title": "Etiketler", "page.categories_count": [ "%d kategori", "%d kategori" diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json index 47af81260d2..cc53b06076f 100644 --- a/internal/locale/translations/uk_UA.json +++ b/internal/locale/translations/uk_UA.json @@ -25,6 +25,7 @@ "alert.no_search_result": "Немає результатів для цього пошуку.", "alert.no_shared_entry": "Немає спільного запису.", "alert.no_tag_entry": "Немає записів, що відповідають цьому тегу.", + "alert.no_tag": "Немає тегів.", "alert.no_unread_entry": "Немає непрочитаних статей.", "alert.no_user": "Ви єдиний користувач.", "alert.prefs_saved": "Уподобання збережено!", @@ -463,6 +464,7 @@ "page.categories.feeds": "Підписки", "page.categories.no_feed": "Немає стрічки.", "page.categories.title": "Категорії", + "page.tags.title": "Теги", "page.categories_count": [ "%d категорія", "%d категорії", diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json index 23a71d2b543..8cc783725d3 100644 --- a/internal/locale/translations/zh_CN.json +++ b/internal/locale/translations/zh_CN.json @@ -25,6 +25,7 @@ "alert.no_search_result": "此搜索没有结果。", "alert.no_shared_entry": "没有已分享条目。", "alert.no_tag_entry": "没有匹配此标签的条目。", + "alert.no_tag": "没有标签。", "alert.no_unread_entry": "没有未读条目。", "alert.no_user": "您是唯一的用户。", "alert.prefs_saved": "偏好设置已保存!", @@ -455,6 +456,7 @@ "page.categories.feeds": "订阅源", "page.categories.no_feed": "无订阅源。", "page.categories.title": "分类", + "page.tags.title": "标签", "page.categories_count": [ "%d 个分类" ], diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json index 5cb5cca02f1..d1db5c6947f 100644 --- a/internal/locale/translations/zh_TW.json +++ b/internal/locale/translations/zh_TW.json @@ -25,6 +25,7 @@ "alert.no_search_result": "沒有符合搜尋的結果", "alert.no_shared_entry": "沒有分享文章。", "alert.no_tag_entry": "沒有與此標籤相符的文章。", + "alert.no_tag": "沒有標籤。", "alert.no_unread_entry": "目前沒有未讀文章", "alert.no_user": "您是唯一的使用者", "alert.prefs_saved": "設定已儲存!", @@ -455,6 +456,7 @@ "page.categories.feeds": "檢視 Feeds", "page.categories.no_feed": "沒有 Feed", "page.categories.title": "分類", + "page.tags.title": "標籤", "page.categories_count": [ "%d 個分類" ], diff --git a/internal/model/tag.go b/internal/model/tag.go new file mode 100644 index 00000000000..0320ff3aced --- /dev/null +++ b/internal/model/tag.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model // import "miniflux.app/v2/internal/model" + +// Tag represents an entry tag. +type Tag struct { + Title string + TotalEntries int + TotalUnread int +} + +// Tags represents a list of tags. +type Tags []Tag diff --git a/internal/storage/tag.go b/internal/storage/tag.go new file mode 100644 index 00000000000..e2ccde9d7d9 --- /dev/null +++ b/internal/storage/tag.go @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package storage // import "miniflux.app/v2/internal/storage" + +import ( + "fmt" + "log/slog" + "time" + + "miniflux.app/v2/internal/model" +) + +// TagExists checks if the given tag exists for the user. +func (s *Storage) TagExists(userID int64, tagName string) bool { + var result bool + query := `SELECT true FROM entries WHERE user_id=$1 AND LOWER($2) = ANY(LOWER(tags::text)::text[]) LIMIT 1` + s.db.QueryRow(query, userID, tagName).Scan(&result) + return result +} + +// TagsWithEntryCount returns all entry tags with entry counts, sorted according to sortOrder. +func (s *Storage) TagsWithEntryCount(userID int64, sortOrder string) (model.Tags, error) { + query := ` + WITH entry_tags AS ( + SELECT DISTINCT + e.id, + e.status, + tag.title + FROM entries e + CROSS JOIN LATERAL unnest(e.tags) AS tag(title) + WHERE + e.user_id = $1 AND tag.title <> '' + ) + SELECT + title, + count(*) AS count, + count(*) FILTER (WHERE status = $2) AS count_unread + FROM entry_tags + GROUP BY title + ` + + if sortOrder == "alphabetical" { + query += ` + ORDER BY + title ASC + ` + } else { + query += ` + ORDER BY + count_unread DESC, + title ASC + ` + } + + rows, err := s.db.Query(query, userID, model.EntryStatusUnread) + if err != nil { + return nil, fmt.Errorf(`store: unable to fetch tags: %v`, err) + } + defer rows.Close() + + tags := make(model.Tags, 0) + for rows.Next() { + var tag model.Tag + if err := rows.Scan(&tag.Title, &tag.TotalEntries, &tag.TotalUnread); err != nil { + return nil, fmt.Errorf(`store: unable to fetch tag row: %v`, err) + } + + tags = append(tags, tag) + } + + return tags, nil +} + +// MarkTagAsRead updates all tag entries to the read status. +func (s *Storage) MarkTagAsRead(userID int64, tagName string, before time.Time) error { + query := ` + UPDATE + entries + SET + status=$1, + changed_at=now() + WHERE + user_id=$2 + AND + status=$3 + AND + published_at < $4 + AND + LOWER($5) = ANY(LOWER(tags::text)::text[]) + ` + result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before, tagName) + if err != nil { + return fmt.Errorf(`store: unable to mark tag entries as read: %v`, err) + } + + count, _ := result.RowsAffected() + slog.Debug("Marked tag entries as read", + slog.Int64("user_id", userID), + slog.String("tag", tagName), + slog.Int64("nb_entries", count), + slog.String("before", before.Format(time.RFC3339)), + ) + + return nil +} diff --git a/internal/template/engine.go b/internal/template/engine.go index 1a3dc9ca68f..268e137a4ea 100644 --- a/internal/template/engine.go +++ b/internal/template/engine.go @@ -39,7 +39,7 @@ func (e *Engine) ParseTemplates() { "add_subscription.html": {"feed_menu.html", "layout.html", "settings_menu.html"}, "api_keys.html": {"layout.html", "settings_menu.html"}, "starred_entries.html": {"item_meta.html", "layout.html", "pagination.html"}, - "categories.html": {"layout.html"}, + "categories.html": {"item_list.html", "layout.html"}, "category_entries.html": {"item_meta.html", "layout.html", "pagination.html"}, "category_feeds.html": {"feed_list.html", "layout.html"}, "choose_subscription.html": {"feed_menu.html", "layout.html"}, @@ -62,6 +62,7 @@ func (e *Engine) ParseTemplates() { "settings.html": {"layout.html", "settings_menu.html"}, "shared_entries.html": {"layout.html", "pagination.html"}, "tag_entries.html": {"item_meta.html", "layout.html", "pagination.html"}, + "tags.html": {"item_list.html", "layout.html"}, "unread_entries.html": {"item_meta.html", "layout.html", "pagination.html"}, "users.html": {"layout.html", "settings_menu.html"}, "webauthn_rename.html": {"layout.html"}, diff --git a/internal/template/engine_test.go b/internal/template/engine_test.go new file mode 100644 index 00000000000..1bd243001cc --- /dev/null +++ b/internal/template/engine_test.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package template // import "miniflux.app/v2/internal/template" + +import "testing" + +func TestParseTemplates(t *testing.T) { + engine := NewEngine("") + engine.ParseTemplates() +} diff --git a/internal/template/templates/common/item_list.html b/internal/template/templates/common/item_list.html new file mode 100644 index 00000000000..2474e21d451 --- /dev/null +++ b/internal/template/templates/common/item_list.html @@ -0,0 +1,55 @@ +{{ define "item_list" }} +{{ if not .items }} +
{{ t .emptyMessageKey }}
+{{ else }} +{{ t "alert.no_category" }}
-{{ else }} -