diff --git a/database/migrations.go b/database/migrations.go index 4bad07a4..b2e901d6 100644 --- a/database/migrations.go +++ b/database/migrations.go @@ -534,4 +534,10 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(sql) return err }, + func(tx *sql.Tx) (err error) { + _, err = tx.Exec(` + ALTER TABLE categories ADD COLUMN hide_globally boolean not null default false + `) + return err + }, } diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json index fd19b982..257a333e 100644 --- a/locale/translations/de_DE.json +++ b/locale/translations/de_DE.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Über Proxy abrufen", "form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren", "form.category.label.title": "Titel", + "form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden", "form.user.label.username": "Benutzername", "form.user.label.password": "Passwort", "form.user.label.confirmation": "Passwort Bestätigung", diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json index 42cfc070..52d29f0b 100644 --- a/locale/translations/en_US.json +++ b/locale/translations/en_US.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Fetch via proxy", "form.feed.label.disabled": "Do not refresh this feed", "form.category.label.title": "Title", + "form.category.hide_globally": "Hide entries in global unread list", "form.user.label.username": "Username", "form.user.label.password": "Password", "form.user.label.confirmation": "Password Confirmation", diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json index fe7a1583..ee5b9a27 100644 --- a/locale/translations/es_ES.json +++ b/locale/translations/es_ES.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Buscar a través de proxy", "form.feed.label.disabled": "No actualice este feed", "form.category.label.title": "Título", + "form.category.hide_globally": "Ocultar entradas en la lista global de no leídos", "form.user.label.username": "Nombre de usuario", "form.user.label.password": "Contraseña", "form.user.label.confirmation": "Confirmación de contraseña", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index 7f90c103..c1843219 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Récupérer via proxy", "form.feed.label.disabled": "Ne pas actualiser ce flux", "form.category.label.title": "Titre", + "form.category.hide_globally": "Masquer les entrées dans la liste globale non lue", "form.user.label.username": "Nom d'utilisateur", "form.user.label.password": "Mot de passe", "form.user.label.confirmation": "Confirmation du mot de passe", diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json index 2e215a09..bdfc3a0f 100644 --- a/locale/translations/it_IT.json +++ b/locale/translations/it_IT.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Recuperare tramite proxy", "form.feed.label.disabled": "Non aggiornare questo feed", "form.category.label.title": "Titolo", + "form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti", "form.user.label.username": "Nome utente", "form.user.label.password": "Password", "form.user.label.confirmation": "Conferma password", diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json index f327ac7f..e30705bc 100644 --- a/locale/translations/ja_JP.json +++ b/locale/translations/ja_JP.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "プロキシ経由でフェッチ", "form.feed.label.disabled": "このフィードを更新しない", "form.category.label.title": "タイトル", + "form.category.hide_globally": "グローバル未読リストのエントリーを隠す", "form.user.label.username": "ユーザー名", "form.user.label.password": "パスワード", "form.user.label.confirmation": "パスワード確認", diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json index 2b9dd4bf..91766eb6 100644 --- a/locale/translations/nl_NL.json +++ b/locale/translations/nl_NL.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Ophalen via proxy", "form.feed.label.disabled": "Vernieuw deze feed niet", "form.category.label.title": "Naam", + "form.category.hide_globally": "Verberg items in de globale ongelezen lijst", "form.user.label.username": "Gebruikersnaam", "form.user.label.password": "Wachtwoord", "form.user.label.confirmation": "Bevestig wachtwoord", diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json index a0e95b9c..2f92b4cf 100644 --- a/locale/translations/pl_PL.json +++ b/locale/translations/pl_PL.json @@ -279,6 +279,7 @@ "form.feed.label.fetch_via_proxy": "Pobierz przez proxy", "form.feed.label.disabled": "Не обновлять этот канал", "form.category.label.title": "Tytuł", + "form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych", "form.user.label.username": "Nazwa użytkownika", "form.user.label.password": "Hasło", "form.user.label.confirmation": "Potwierdzenie hasła", diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json index e99110a6..ae27470f 100644 --- a/locale/translations/pt_BR.json +++ b/locale/translations/pt_BR.json @@ -277,6 +277,7 @@ "form.feed.label.disabled": "Não atualizar esta fonte", "form.feed.label.fetch_via_proxy": "Buscar via proxy", "form.category.label.title": "Título", + "form.category.hide_globally": "Ocultar entradas na lista global não lida", "form.user.label.username": "Nome de usuário", "form.user.label.password": "Senha", "form.user.label.confirmation": "Confirmação de senha", diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json index 18a02cc3..eb76b441 100644 --- a/locale/translations/ru_RU.json +++ b/locale/translations/ru_RU.json @@ -279,6 +279,7 @@ "form.feed.label.fetch_via_proxy": "Получить через прокси", "form.feed.label.disabled": "Не обновлять этот канал", "form.category.label.title": "Название", + "form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных", "form.user.label.username": "Имя пользователя", "form.user.label.password": "Пароль", "form.user.label.confirmation": "Подтверждение пароля", diff --git a/locale/translations/tr_TR.json b/locale/translations/tr_TR.json index 8081bb8f..06a39eac 100644 --- a/locale/translations/tr_TR.json +++ b/locale/translations/tr_TR.json @@ -277,6 +277,7 @@ "form.feed.label.fetch_via_proxy": "Proxy ile çek", "form.feed.label.disabled": "Bu beslemeyi yenileme", "form.category.label.title": "Başlık", + "form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle", "form.user.label.username": "Kullanıcı Adı", "form.user.label.password": "Parola", "form.user.label.confirmation": "Parola Doğrulama", diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json index eff92ced..8893a487 100644 --- a/locale/translations/zh_CN.json +++ b/locale/translations/zh_CN.json @@ -275,6 +275,7 @@ "form.feed.label.fetch_via_proxy": "通过代理获取", "form.feed.label.disabled": "请勿刷新此Feed", "form.category.label.title": "标题", + "form.category.hide_globally": "隐藏全局未读列表中的条目", "form.user.label.username": "用户名", "form.user.label.password": "密码", "form.user.label.confirmation": "确认", diff --git a/model/category.go b/model/category.go index 35f1b8d1..6bb415d4 100644 --- a/model/category.go +++ b/model/category.go @@ -8,11 +8,12 @@ import "fmt" // Category represents a feed category. type Category struct { - ID int64 `json:"id"` - Title string `json:"title"` - UserID int64 `json:"user_id"` - FeedCount int `json:"-"` - TotalUnread int `json:"-"` + ID int64 `json:"id"` + Title string `json:"title"` + UserID int64 `json:"user_id"` + HideGlobally bool `json:"hide_globally"` + FeedCount int `json:"-"` + TotalUnread int `json:"-"` } func (c *Category) String() string { @@ -21,12 +22,14 @@ func (c *Category) String() string { // CategoryRequest represents the request to create or update a category. type CategoryRequest struct { - Title string `json:"title"` + Title string `json:"title"` + HideGlobally string `json:"hide_globally"` } // Patch updates category fields. func (cr *CategoryRequest) Patch(category *Category) { category.Title = cr.Title + category.HideGlobally = cr.HideGlobally != "" } // Categories represents a list of categories. diff --git a/storage/category.go b/storage/category.go index 37220a2f..62b8b49a 100644 --- a/storage/category.go +++ b/storage/category.go @@ -40,8 +40,8 @@ func (s *Storage) CategoryIDExists(userID, categoryID int64) bool { func (s *Storage) Category(userID, categoryID int64) (*model.Category, error) { var category model.Category - query := `SELECT id, user_id, title FROM categories WHERE user_id=$1 AND id=$2` - err := s.db.QueryRow(query, userID, categoryID).Scan(&category.ID, &category.UserID, &category.Title) + query := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 AND id=$2` + err := s.db.QueryRow(query, userID, categoryID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally) switch { case err == sql.ErrNoRows: @@ -55,10 +55,10 @@ func (s *Storage) Category(userID, categoryID int64) (*model.Category, error) { // FirstCategory returns the first category for the given user. func (s *Storage) FirstCategory(userID int64) (*model.Category, error) { - query := `SELECT id, user_id, title FROM categories WHERE user_id=$1 ORDER BY title ASC LIMIT 1` + query := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 ORDER BY title ASC LIMIT 1` var category model.Category - err := s.db.QueryRow(query, userID).Scan(&category.ID, &category.UserID, &category.Title) + err := s.db.QueryRow(query, userID).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally) switch { case err == sql.ErrNoRows: @@ -74,8 +74,8 @@ func (s *Storage) FirstCategory(userID int64) (*model.Category, error) { func (s *Storage) CategoryByTitle(userID int64, title string) (*model.Category, error) { var category model.Category - query := `SELECT id, user_id, title FROM categories WHERE user_id=$1 AND title=$2` - err := s.db.QueryRow(query, userID, title).Scan(&category.ID, &category.UserID, &category.Title) + query := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 AND title=$2` + err := s.db.QueryRow(query, userID, title).Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally) switch { case err == sql.ErrNoRows: @@ -89,7 +89,7 @@ func (s *Storage) CategoryByTitle(userID int64, title string) (*model.Category, // Categories returns all categories that belongs to the given user. func (s *Storage) Categories(userID int64) (model.Categories, error) { - query := `SELECT id, user_id, title FROM categories WHERE user_id=$1 ORDER BY title ASC` + query := `SELECT id, user_id, title, hide_globally FROM categories WHERE user_id=$1 ORDER BY title ASC` rows, err := s.db.Query(query, userID) if err != nil { return nil, fmt.Errorf(`store: unable to fetch categories: %v`, err) @@ -99,7 +99,7 @@ func (s *Storage) Categories(userID int64) (model.Categories, error) { categories := make(model.Categories, 0) for rows.Next() { var category model.Category - if err := rows.Scan(&category.ID, &category.UserID, &category.Title); err != nil { + if err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally); err != nil { return nil, fmt.Errorf(`store: unable to fetch category row: %v`, err) } @@ -116,6 +116,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error c.id, c.user_id, c.title, + c.hide_globally, (SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count, (SELECT count(*) FROM feeds @@ -136,7 +137,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error categories := make(model.Categories, 0) for rows.Next() { var category model.Category - if err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.FeedCount, &category.TotalUnread); err != nil { + if err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.HideGlobally, &category.FeedCount, &category.TotalUnread); err != nil { return nil, fmt.Errorf(`store: unable to fetch category row: %v`, err) } @@ -179,10 +180,11 @@ func (s *Storage) CreateCategory(userID int64, request *model.CategoryRequest) ( // UpdateCategory updates an existing category. func (s *Storage) UpdateCategory(category *model.Category) error { - query := `UPDATE categories SET title=$1 WHERE id=$2 AND user_id=$3` + query := `UPDATE categories SET title=$1, hide_globally = $2 WHERE id=$3 AND user_id=$4` _, err := s.db.Exec( query, category.Title, + category.HideGlobally, category.ID, category.UserID, ) diff --git a/storage/entry.go b/storage/entry.go index 968877f2..03243467 100644 --- a/storage/entry.go +++ b/storage/entry.go @@ -49,6 +49,7 @@ func (s *Storage) CountAllEntries() map[string]int64 { func (s *Storage) CountUnreadEntries(userID int64) int { builder := s.NewEntryQueryBuilder(userID) builder.WithStatus(model.EntryStatusUnread) + builder.WithGloballyVisible() n, err := builder.CountEntries() if err != nil { @@ -346,6 +347,27 @@ func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string return nil } +func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status string) (int, error) { + if err := s.SetEntriesStatus(userID, entryIDs, status); err != nil { + return 0, err + } + + query := ` + SELECT count(*) + FROM entries e + JOIN feeds f ON (f.id = e.feed_id) + JOIN categories c ON (c.id = f.category_id) + WHERE e.user_id = $1 AND e.id = ANY($2) AND NOT c.hide_globally + ` + row := s.db.QueryRow(query, userID, pq.Array(entryIDs)) + visible := 0 + if err := row.Scan(&visible); err != nil { + return 0, fmt.Errorf(`store: unable to query entries visibility %v: %v`, entryIDs, err) + } + + return visible, nil +} + // ToggleBookmark toggles entry bookmark value. func (s *Storage) ToggleBookmark(userID int64, entryID int64) error { query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2` diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go index dcf6a779..62def7f8 100644 --- a/storage/entry_query_builder.go +++ b/storage/entry_query_builder.go @@ -181,9 +181,20 @@ func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder { return e } +func (e *EntryQueryBuilder) WithGloballyVisible() *EntryQueryBuilder { + e.conditions = append(e.conditions, "not c.hide_globally") + return e +} + // CountEntries count the number of entries that match the condition. func (e *EntryQueryBuilder) CountEntries() (count int, err error) { - query := `SELECT count(*) FROM entries e LEFT JOIN feeds f ON f.id=e.feed_id WHERE %s` + query := ` + SELECT count(*) + FROM entries e + JOIN feeds f ON f.id = e.feed_id + JOIN categories c ON c.id = f.category_id + WHERE %s + ` condition := e.buildCondition() err = e.store.db.QueryRow(fmt.Sprintf(query, condition), e.args...).Scan(&count) diff --git a/template/templates/views/edit_category.html b/template/templates/views/edit_category.html index 3506e455..5fbc91e1 100644 --- a/template/templates/views/edit_category.html +++ b/template/templates/views/edit_category.html @@ -26,6 +26,11 @@ + +
diff --git a/ui/category_edit.go b/ui/category_edit.go index f21a63a7..41673cc3 100644 --- a/ui/category_edit.go +++ b/ui/category_edit.go @@ -37,7 +37,11 @@ func (h *handler) showEditCategoryPage(w http.ResponseWriter, r *http.Request) { } categoryForm := form.CategoryForm{ - Title: category.Title, + Title: category.Title, + HideGlobally: "", + } + if category.HideGlobally { + categoryForm.HideGlobally = "checked" } view.Set("form", categoryForm) diff --git a/ui/category_update.go b/ui/category_update.go index 80a053fa..4475508b 100644 --- a/ui/category_update.go +++ b/ui/category_update.go @@ -48,7 +48,10 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) { view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID)) view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(loggedUser.ID)) - categoryRequest := &model.CategoryRequest{Title: categoryForm.Title} + categoryRequest := &model.CategoryRequest{ + Title: categoryForm.Title, + HideGlobally: categoryForm.HideGlobally, + } if validationErr := validator.ValidateCategoryModification(h.store, loggedUser.ID, category.ID, categoryRequest); validationErr != nil { view.Set("errorMessage", validationErr.TranslationKey) diff --git a/ui/entry_update_status.go b/ui/entry_update_status.go index 2e2dccf0..d01ef678 100644 --- a/ui/entry_update_status.go +++ b/ui/entry_update_status.go @@ -26,10 +26,11 @@ func (h *handler) updateEntriesStatus(w http.ResponseWriter, r *http.Request) { return } - if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil { + count, err := h.store.SetEntriesStatusCount(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status) + if err != nil { json.ServerError(w, r, err) return } - json.OK(w, r, "OK") + json.OK(w, r, count) } diff --git a/ui/form/category.go b/ui/form/category.go index 1a50e3a2..0c013f4b 100644 --- a/ui/form/category.go +++ b/ui/form/category.go @@ -10,12 +10,14 @@ import ( // CategoryForm represents a feed form in the UI type CategoryForm struct { - Title string + Title string + HideGlobally string } // NewCategoryForm returns a new CategoryForm. func NewCategoryForm(r *http.Request) *CategoryForm { return &CategoryForm{ - Title: r.FormValue("title"), + Title: r.FormValue("title"), + HideGlobally: r.FormValue("hide_globally"), } } diff --git a/ui/static/js/app.js b/ui/static/js/app.js index 9063f933..2864103e 100644 --- a/ui/static/js/app.js +++ b/ui/static/js/app.js @@ -206,14 +206,20 @@ function updateEntriesStatus(entryIDs, status, callback) { let url = document.body.dataset.entriesStatusUrl; let request = new RequestBuilder(url); request.withBody({entry_ids: entryIDs, status: status}); - request.withCallback(callback); - request.execute(); + request.withCallback((resp) => { + resp.json().then(count => { + if (callback) { + callback(resp); + } - if (status === "read") { - decrementUnreadCounter(1); - } else { - incrementUnreadCounter(1); - } + if (status === "read") { + decrementUnreadCounter(count); + } else { + incrementUnreadCounter(count); + } + }); + }); + request.execute(); } // Handle save entry from list view and entry view. diff --git a/ui/unread_entries.go b/ui/unread_entries.go index b04f13f2..43495095 100644 --- a/ui/unread_entries.go +++ b/ui/unread_entries.go @@ -34,6 +34,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) { offset := request.QueryIntParam(r, "offset", 0) builder := h.store.NewEntryQueryBuilder(user.ID) builder.WithStatus(model.EntryStatusUnread) + builder.WithGloballyVisible() countUnread, err := builder.CountEntries() if err != nil { html.ServerError(w, r, err) @@ -52,6 +53,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) { builder.WithDirection(user.EntryDirection) builder.WithOffset(offset) builder.WithLimit(user.EntriesPerPage) + builder.WithGloballyVisible() entries, err := builder.GetEntries() if err != nil { html.ServerError(w, r, err)