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 e8af0b52083..53170f5e149 100644 --- a/client/model.go +++ b/client/model.go @@ -268,8 +268,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 01f2cd5a419..4d7e398e08b 100644 --- a/internal/api/entry_handlers.go +++ b/internal/api/entry_handlers.go @@ -314,12 +314,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/locale/translations/ar_SA.json b/internal/locale/translations/ar_SA.json index 85593adcd99..f63794affae 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": "تم حفظ التفضيلات!", @@ -477,6 +478,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 4673987a800..1b08c55999f 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!", @@ -461,6 +462,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 5e31500b61d..0034478bd16 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": "Οι προτιμήσεις αποθηκεύτηκαν!", @@ -461,6 +462,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 6748bfc8934..01cd2bd0c3a 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!", @@ -461,6 +462,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 076d7238964..71d519257e4 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!", @@ -461,6 +462,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 7f3def6ee7b..5da53e48131 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!", @@ -461,6 +462,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 b0e809210d3..163c37427dc 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 !", @@ -461,6 +462,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 df2fdc5ee91..489a7495d3d 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!", @@ -461,6 +462,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 79cbbc89b18..925caccab2a 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": "प्राथमिकताएं सहेजी गईं!", @@ -461,6 +462,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 e233a1ed87a..cf90d73c647 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!", @@ -457,6 +458,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 b89d9fb9ac4..8966b289fbc 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!", @@ -461,6 +462,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 8ef30ab7e0e..daa25727f23 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": "設定情報は保存されました!", @@ -457,6 +458,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 5c2bc145769..e840e5e7dcc 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!", @@ -457,6 +458,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 da6e751a892..21e0464a1de 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!", @@ -461,6 +462,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 2fba440c1e1..10492548c4d 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!", @@ -465,6 +466,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 bf92962a238..1ec74dad756 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!", @@ -461,6 +462,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 a8fd61df67f..a74c0d1fc66 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!", @@ -465,6 +466,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 b2c60babe8e..4929dfa0a91 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": "Предпочтения сохранены!", @@ -465,6 +466,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 1e50ec0960f..cdeda37d463 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!", @@ -461,6 +462,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 7680aa4ceeb..86759daf76b 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": "Уподобання збережено!", @@ -465,6 +466,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 7aa03a78ebc..cb1fe6a84fd 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": "偏好设置已保存!", @@ -457,6 +458,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 87b7462c13d..291ab2ca35c 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": "設定已儲存!", @@ -457,6 +458,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/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/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/entry.go b/internal/storage/entry.go index b021e902a54..9e1da3224c0 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -77,6 +77,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/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 b51f2298c6c..466576c2038 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 }} -