Add flush history feature

This commit is contained in:
Frédéric Guillot 2017-11-21 15:46:59 -08:00
parent 238b9e4c85
commit 549a4277b0
15 changed files with 79 additions and 17 deletions

View file

@ -9,6 +9,7 @@ Miniflux is a minimalist and opinionated feed reader:
- Works only with Postgresql - Works only with Postgresql
- Doesn't use any ORM - Doesn't use any ORM
- Doesn't use any complicated framework - Doesn't use any complicated framework
- Use only modern vanilla Javascript (ES6 and fetch)
- The number of features is volountary limited - The number of features is volountary limited
It's simple, fast, lightweight and super easy to install. It's simple, fast, lightweight and super easy to install.
@ -29,7 +30,7 @@ TODO
- [ ] External integrations (Pinboard, Wallabag...) - [ ] External integrations (Pinboard, Wallabag...)
- [ ] Gzip compression - [ ] Gzip compression
- [ ] Integration tests - [ ] Integration tests
- [ ] Flush history - [X] Flush history
- [ ] OAuth2 - [ ] OAuth2
Credits Credits

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.456403496 -0800 PST m=+0.037949400 // 2017-11-21 15:41:59.495654213 -0800 PST m=+0.041889871
package locale package locale

View file

@ -9,6 +9,7 @@ import (
"time" "time"
) )
// Entry statuses
const ( const (
EntryStatusUnread = "unread" EntryStatusUnread = "unread"
EntryStatusRead = "read" EntryStatusRead = "read"
@ -17,6 +18,7 @@ const (
DefaultSortingDirection = "desc" DefaultSortingDirection = "desc"
) )
// Entry represents a feed item in the system.
type Entry struct { type Entry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@ -33,8 +35,10 @@ type Entry struct {
Category *Category `json:"category,omitempty"` Category *Category `json:"category,omitempty"`
} }
// Entries represents a list of entries.
type Entries []*Entry type Entries []*Entry
// ValidateEntryStatus makes sure the entry status is valid.
func ValidateEntryStatus(status string) error { func ValidateEntryStatus(status string) error {
switch status { switch status {
case EntryStatusRead, EntryStatusUnread, EntryStatusRemoved: case EntryStatusRead, EntryStatusUnread, EntryStatusRemoved:
@ -44,6 +48,7 @@ func ValidateEntryStatus(status string) error {
return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, EntryStatusRead, EntryStatusUnread, EntryStatusRemoved) return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, EntryStatusRead, EntryStatusUnread, EntryStatusRemoved)
} }
// ValidateEntryOrder makes sure the sorting order is valid.
func ValidateEntryOrder(order string) error { func ValidateEntryOrder(order string) error {
switch order { switch order {
case "id", "status", "published_at", "category_title", "category_id": case "id", "status", "published_at", "category_title", "category_id":
@ -53,6 +58,7 @@ func ValidateEntryOrder(order string) error {
return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "published_at", "category_title", "category_id"`) return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "published_at", "category_title", "category_id"`)
} }
// ValidateDirection makes sure the sorting direction is valid.
func ValidateDirection(direction string) error { func ValidateDirection(direction string) error {
switch direction { switch direction {
case "asc", "desc": case "asc", "desc":
@ -62,6 +68,7 @@ func ValidateDirection(direction string) error {
return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`) return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`)
} }
// GetOppositeDirection returns the opposite sorting direction.
func GetOppositeDirection(direction string) string { func GetOppositeDirection(direction string) string {
if direction == "asc" { if direction == "asc" {
return "desc" return "desc"

View file

@ -54,7 +54,10 @@ func (j *JsonResponse) ServerError(err error) {
log.Println("[API:ServerError]", err) log.Println("[API:ServerError]", err)
j.writer.WriteHeader(http.StatusInternalServerError) j.writer.WriteHeader(http.StatusInternalServerError)
j.commonHeaders() j.commonHeaders()
j.writer.Write(j.encodeError(err))
if err != nil {
j.writer.Write(j.encodeError(err))
}
} }
func (j *JsonResponse) Forbidden() { func (j *JsonResponse) Forbidden() {

View file

@ -81,6 +81,7 @@ func getRoutes(store *storage.Storage, feedHandler *feed.Handler) *mux.Router {
router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET") router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET")
router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET") router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET")
router.Handle("/history/flush", uiHandler.Use(uiController.FlushHistory)).Name("flushHistory").Methods("GET")
router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET") router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET") router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.42928305 -0800 PST m=+0.010828954 // 2017-11-21 15:41:59.461181295 -0800 PST m=+0.007416953
package static package static

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.43289693 -0800 PST m=+0.014442834 // 2017-11-21 15:41:59.464123652 -0800 PST m=+0.010359310
package static package static

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.43700259 -0800 PST m=+0.018548494 // 2017-11-21 15:41:59.4687788 -0800 PST m=+0.015014458
package static package static

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.455330256 -0800 PST m=+0.036876160 // 2017-11-21 15:41:59.491806442 -0800 PST m=+0.038042100
package template package template

View file

@ -3,6 +3,11 @@
{{ define "content"}} {{ define "content"}}
<section class="page-header"> <section class="page-header">
<h1>{{ t "History" }} ({{ .total }})</h1> <h1>{{ t "History" }} ({{ .total }})</h1>
<ul>
<li>
<a href="{{ route "flushHistory" }}">{{ t "Flush history" }}</a>
</li>
</ul>
</section> </section>
{{ if not .entries }} {{ if not .entries }}

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.438565193 -0800 PST m=+0.020111097 // 2017-11-21 15:41:59.472545112 -0800 PST m=+0.018780770
package template package template
@ -649,6 +649,11 @@ var templateViewsMap = map[string]string{
{{ define "content"}} {{ define "content"}}
<section class="page-header"> <section class="page-header">
<h1>{{ t "History" }} ({{ .total }})</h1> <h1>{{ t "History" }} ({{ .total }})</h1>
<ul>
<li>
<a href="{{ route "flushHistory" }}">{{ t "Flush history" }}</a>
</li>
</ul>
</section> </section>
{{ if not .entries }} {{ if not .entries }}
@ -980,7 +985,7 @@ var templateViewsMapChecksums = map[string]string{
"entry": "32e605edd6d43773ac31329d247ebd81d38d974cd43689d91de79fffec7fe04b", "entry": "32e605edd6d43773ac31329d247ebd81d38d974cd43689d91de79fffec7fe04b",
"feed_entries": "9aff923b6c7452dec1514feada7e0d2bbc1ec21c6f5e9f48b2de41d1b731ffe4", "feed_entries": "9aff923b6c7452dec1514feada7e0d2bbc1ec21c6f5e9f48b2de41d1b731ffe4",
"feeds": "94e43404a4044490c065c888a49bebd3ff51b588b9fb47d03c2598003aa40dca", "feeds": "94e43404a4044490c065c888a49bebd3ff51b588b9fb47d03c2598003aa40dca",
"history": "439000d0be8fd716f3b89860af4d721e05baef0c2ccd2325ba020c940d6aa847", "history": "947603cbde888516e62925f5d08fb0b13d930623d3ee4c690dbc22612fdda75e",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f", "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"login": "568f2f69f248048f3e55e9bbc719077a74ae23fe18f237aa40e3de37e97b7a41", "login": "568f2f69f248048f3e55e9bbc719077a74ae23fe18f237aa40e3de37e97b7a41",
"sessions": "5ac3793f0ee74d0807bab6a173a1aa6508e98add5c022fa54c8fdf5c6b4a0e75", "sessions": "5ac3793f0ee74d0807bab6a173a1aa6508e98add5c022fa54c8fdf5c6b4a0e75",

View file

@ -6,12 +6,14 @@ package controller
import ( import (
"errors" "errors"
"log"
"github.com/miniflux/miniflux2/model" "github.com/miniflux/miniflux2/model"
"github.com/miniflux/miniflux2/server/core" "github.com/miniflux/miniflux2/server/core"
"github.com/miniflux/miniflux2/server/ui/payload" "github.com/miniflux/miniflux2/server/ui/payload"
"log"
) )
// ShowFeedEntry shows a single feed entry in "feed" mode.
func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
sortingDirection := model.DefaultSortingDirection sortingDirection := model.DefaultSortingDirection
@ -102,6 +104,7 @@ func (c *Controller) ShowFeedEntry(ctx *core.Context, request *core.Request, res
})) }))
} }
// ShowCategoryEntry shows a single feed entry in "category" mode.
func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
sortingDirection := model.DefaultSortingDirection sortingDirection := model.DefaultSortingDirection
@ -192,6 +195,7 @@ func (c *Controller) ShowCategoryEntry(ctx *core.Context, request *core.Request,
})) }))
} }
// ShowUnreadEntry shows a single feed entry in "unread" mode.
func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
sortingDirection := model.DefaultSortingDirection sortingDirection := model.DefaultSortingDirection
@ -275,6 +279,7 @@ func (c *Controller) ShowUnreadEntry(ctx *core.Context, request *core.Request, r
})) }))
} }
// ShowReadEntry shows a single feed entry in "history" mode.
func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
sortingDirection := model.DefaultSortingDirection sortingDirection := model.DefaultSortingDirection
@ -349,6 +354,7 @@ func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, res
})) }))
} }
// UpdateEntriesStatus handles Ajax request to update a list of entries.
func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
@ -360,14 +366,14 @@ func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Reques
} }
if len(entryIDs) == 0 { if len(entryIDs) == 0 {
response.Html().BadRequest(errors.New("The list of entryID is empty")) response.Json().BadRequest(errors.New("The list of entryID is empty"))
return return
} }
err = c.store.SetEntriesStatus(user.ID, entryIDs, status) err = c.store.SetEntriesStatus(user.ID, entryIDs, status)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
response.Html().ServerError(nil) response.Json().ServerError(nil)
return return
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/miniflux/miniflux2/server/core" "github.com/miniflux/miniflux2/server/core"
) )
// ShowHistoryPage renders the page with all read entries.
func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, response *core.Response) { func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser() user := ctx.GetLoggedUser()
offset := request.GetQueryIntegerParam("offset", 0) offset := request.GetQueryIntegerParam("offset", 0)
@ -45,3 +46,16 @@ func (c *Controller) ShowHistoryPage(ctx *core.Context, request *core.Request, r
"menu": "history", "menu": "history",
})) }))
} }
// FlushHistory changes all "read" items to "removed".
func (c *Controller) FlushHistory(ctx *core.Context, request *core.Request, response *core.Response) {
user := ctx.GetLoggedUser()
err := c.store.FlushHistory(user.ID)
if err != nil {
response.Html().ServerError(err)
return
}
response.Redirect(ctx.GetRoute("history"))
}

View file

@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT. // Code generated by go generate; DO NOT EDIT.
// 2017-11-21 14:55:14.420877594 -0800 PST m=+0.002423498 // 2017-11-21 15:41:59.457985225 -0800 PST m=+0.004220883
package sql package sql

View file

@ -7,17 +7,20 @@ package storage
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/miniflux/miniflux2/helper" "github.com/miniflux/miniflux2/helper"
"github.com/miniflux/miniflux2/model" "github.com/miniflux/miniflux2/model"
"time"
"github.com/lib/pq" "github.com/lib/pq"
) )
// GetEntryQueryBuilder returns a new EntryQueryBuilder
func (s *Storage) GetEntryQueryBuilder(userID int64, timezone string) *EntryQueryBuilder { func (s *Storage) GetEntryQueryBuilder(userID int64, timezone string) *EntryQueryBuilder {
return NewEntryQueryBuilder(s, userID, timezone) return NewEntryQueryBuilder(s, userID, timezone)
} }
// CreateEntry add a new entry.
func (s *Storage) CreateEntry(entry *model.Entry) error { func (s *Storage) CreateEntry(entry *model.Entry) error {
query := ` query := `
INSERT INTO entries INSERT INTO entries
@ -55,6 +58,7 @@ func (s *Storage) CreateEntry(entry *model.Entry) error {
return nil return nil
} }
// UpdateEntry update an entry when a feed is refreshed.
func (s *Storage) UpdateEntry(entry *model.Entry) error { func (s *Storage) UpdateEntry(entry *model.Entry) error {
query := ` query := `
UPDATE entries SET UPDATE entries SET
@ -76,6 +80,7 @@ func (s *Storage) UpdateEntry(entry *model.Entry) error {
return err return err
} }
// EntryExists checks if an entry already exists based on its hash when refreshing a feed.
func (s *Storage) EntryExists(entry *model.Entry) bool { func (s *Storage) EntryExists(entry *model.Entry) bool {
var result int var result int
query := `SELECT count(*) as c FROM entries WHERE user_id=$1 AND feed_id=$2 AND hash=$3` query := `SELECT count(*) as c FROM entries WHERE user_id=$1 AND feed_id=$2 AND hash=$3`
@ -83,6 +88,7 @@ func (s *Storage) EntryExists(entry *model.Entry) bool {
return result >= 1 return result >= 1
} }
// UpdateEntries update a list of entries while refreshing a feed.
func (s *Storage) UpdateEntries(userID, feedID int64, entries model.Entries) (err error) { func (s *Storage) UpdateEntries(userID, feedID int64, entries model.Entries) (err error) {
for _, entry := range entries { for _, entry := range entries {
entry.UserID = userID entry.UserID = userID
@ -102,22 +108,36 @@ func (s *Storage) UpdateEntries(userID, feedID int64, entries model.Entries) (er
return nil return nil
} }
// SetEntriesStatus update the status of the given list of entries.
func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error { func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:SetEntriesStatus] userID=%d, entryIDs=%v, status=%s", userID, entryIDs, status)) defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:SetEntriesStatus] userID=%d, entryIDs=%v, status=%s", userID, entryIDs, status))
query := `UPDATE entries SET status=$1 WHERE user_id=$2 AND id=ANY($3)` query := `UPDATE entries SET status=$1 WHERE user_id=$2 AND id=ANY($3)`
result, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)) result, err := s.db.Exec(query, status, userID, pq.Array(entryIDs))
if err != nil { if err != nil {
return fmt.Errorf("Unable to update entry status: %v", err) return fmt.Errorf("unable to update entries status: %v", err)
} }
count, err := result.RowsAffected() count, err := result.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("Unable to update this entry: %v", err) return fmt.Errorf("unable to update these entries: %v", err)
} }
if count == 0 { if count == 0 {
return errors.New("Nothing has been updated") return errors.New("nothing has been updated")
}
return nil
}
// FlushHistory set all entries with the status "read" to "removed".
func (s *Storage) FlushHistory(userID int64) error {
defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:FlushHistory] userID=%d", userID))
query := `UPDATE entries SET status=$1 WHERE user_id=$2 AND status=$3`
_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)
if err != nil {
return fmt.Errorf("unable to flush history: %v", err)
} }
return nil return nil