mirror of
https://github.com/nkanaev/yarr.git
synced 2026-06-24 09:05:16 +00:00
Compare commits
10 Commits
31274d17a5
...
d30124bf3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30124bf3c | ||
|
|
138b5ad991 | ||
|
|
2f263e9803 | ||
|
|
76529c895e | ||
|
|
847ec3861a | ||
|
|
85f3956b24 | ||
|
|
7553824520 | ||
|
|
54e197ad85 | ||
|
|
f50894ddb0 | ||
|
|
59af8aa62d |
2
makefile
2
makefile
@@ -1,7 +1,7 @@
|
|||||||
VERSION=2.6
|
VERSION=2.6
|
||||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||||
|
|
||||||
GO_TAGS = sqlite_foreign_keys sqlite_json
|
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
|
||||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||||
|
|
||||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ func (m *Middleware) Handler(c *router.Context) {
|
|||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"username": username,
|
"username": username,
|
||||||
"error": "Invalid username/password",
|
"error": "Invalid username/password",
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings().Map(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings().Map(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,17 +56,14 @@ type FeverFavicon struct {
|
|||||||
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
||||||
data["api_version"] = 3
|
data["api_version"] = 3
|
||||||
data["auth"] = 1
|
data["auth"] = 1
|
||||||
|
// TODO: remove duplicates
|
||||||
data["last_refreshed_on_time"] = lastRefreshed
|
data["last_refreshed_on_time"] = lastRefreshed
|
||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
|
func getLastRefreshedOnTime(feedStates []storage.FeedState) int64 {
|
||||||
if len(httpStates) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastRefreshed int64
|
var lastRefreshed int64
|
||||||
for _, state := range httpStates {
|
for _, state := range feedStates {
|
||||||
if state.LastRefreshed.Unix() > lastRefreshed {
|
if state.LastRefreshed.Unix() > lastRefreshed {
|
||||||
lastRefreshed = state.LastRefreshed.Unix()
|
lastRefreshed = state.LastRefreshed.Unix()
|
||||||
}
|
}
|
||||||
@@ -123,10 +120,11 @@ func (s *Server) handleFever(c *router.Context) {
|
|||||||
case formHasValue(c.Req.Form, "mark"):
|
case formHasValue(c.Req.Form, "mark"):
|
||||||
s.feverMarkHandler(c)
|
s.feverMarkHandler(c)
|
||||||
default:
|
default:
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
c.JSON(http.StatusOK, map[string]any{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 1,
|
"auth": 1,
|
||||||
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
"last_refreshed_on_time": getLastRefreshedOnTime(states),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,20 +166,25 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
|
|||||||
for i, folder := range folders {
|
for i, folder := range folders {
|
||||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
||||||
}
|
}
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverFeedsHandler(c *router.Context) {
|
func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||||
feeds := s.db.ListFeeds()
|
feeds := s.db.ListFeeds()
|
||||||
httpStates := s.db.ListHTTPStates()
|
states, _ := s.db.ListFeedStates()
|
||||||
|
statesMap := make(map[int64]storage.FeedState)
|
||||||
|
for _, state := range states {
|
||||||
|
statesMap[state.FeedID] = state
|
||||||
|
}
|
||||||
|
|
||||||
feverFeeds := make([]*FeverFeed, len(feeds))
|
feverFeeds := make([]*FeverFeed, len(feeds))
|
||||||
for i, feed := range feeds {
|
for i, feed := range feeds {
|
||||||
var lastUpdated int64
|
var lastUpdated int64
|
||||||
if state, ok := httpStates[feed.Id]; ok {
|
if state, ok := statesMap[feed.Id]; ok {
|
||||||
lastUpdated = state.LastRefreshed.Unix()
|
lastUpdated = state.LastRefreshed.Unix()
|
||||||
}
|
}
|
||||||
feverFeeds[i] = &FeverFeed{
|
feverFeeds[i] = &FeverFeed{
|
||||||
@@ -197,7 +200,7 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
|
|||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"feeds": feverFeeds,
|
"feeds": feverFeeds,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(httpStates))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||||
@@ -216,9 +219,10 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
|||||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"favicons": favicons,
|
"favicons": favicons,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
// for memory pressure reasons, we only return a limited number of items
|
// for memory pressure reasons, we only return a limited number of items
|
||||||
@@ -280,16 +284,18 @@ func (s *Server) feverItemsHandler(c *router.Context) {
|
|||||||
|
|
||||||
totalItems := s.db.CountItems()
|
totalItems := s.db.CountItems()
|
||||||
|
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"items": feverItems,
|
"items": feverItems,
|
||||||
"total_items": totalItems,
|
"total_items": totalItems,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"links": make([]any, 0),
|
"links": make([]any, 0),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||||
@@ -309,9 +315,10 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
itemFilter.After = &items[len(items)-1].Id
|
||||||
}
|
}
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"unread_item_ids": joinInts(itemIds),
|
"unread_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||||
@@ -331,9 +338,10 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
itemFilter.After = &items[len(items)-1].Id
|
||||||
}
|
}
|
||||||
|
states, _ := s.db.ListFeedStates()
|
||||||
writeFeverJSON(c, map[string]any{
|
writeFeverJSON(c, map[string]any{
|
||||||
"saved_item_ids": joinInts(itemIds),
|
"saved_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(states))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverMarkHandler(c *router.Context) {
|
func (s *Server) feverMarkHandler(c *router.Context) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *Server) handler() http.Handler {
|
|||||||
|
|
||||||
func (s *Server) handleIndex(c *router.Context) {
|
func (s *Server) handleIndex(c *router.Context) {
|
||||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||||
"settings": s.db.GetSettings(),
|
"settings": s.db.GetSettings().Map(),
|
||||||
"authenticated": s.Username != "" && s.Password != "",
|
"authenticated": s.Username != "" && s.Password != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -141,12 +141,10 @@ func (s *Server) handleFolder(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
s.db.UpdateFolder(id, storage.UpdateFolderParams{
|
||||||
s.db.RenameFolder(id, *body.Title)
|
Title: body.Title,
|
||||||
}
|
IsExpanded: body.IsExpanded,
|
||||||
if body.IsExpanded != nil {
|
})
|
||||||
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
|
|
||||||
}
|
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else if c.Req.Method == "DELETE" {
|
} else if c.Req.Method == "DELETE" {
|
||||||
s.db.DeleteFolder(id)
|
s.db.DeleteFolder(id)
|
||||||
@@ -164,7 +162,15 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleFeedErrors(c *router.Context) {
|
func (s *Server) handleFeedErrors(c *router.Context) {
|
||||||
errors := s.db.GetFeedErrors()
|
errors := make(map[int64]string)
|
||||||
|
states, err := s.db.ListFeedStates()
|
||||||
|
if err == nil {
|
||||||
|
for _, state := range states {
|
||||||
|
if state.LastError != "" {
|
||||||
|
errors[state.FeedID] = state.LastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, errors)
|
c.JSON(http.StatusOK, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,17 +248,15 @@ func (s *Server) handleFeedList(c *router.Context) {
|
|||||||
map[string]any{"status": "multiple", "choice": result.Sources},
|
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||||
)
|
)
|
||||||
case result.Feed != nil:
|
case result.Feed != nil:
|
||||||
feed := s.db.CreateFeed(
|
feed := s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
result.Feed.Title,
|
Title: result.Feed.Title,
|
||||||
"",
|
Link: result.Feed.SiteURL,
|
||||||
result.Feed.SiteURL,
|
FeedLink: result.FeedLink,
|
||||||
result.FeedLink,
|
FolderID: form.FolderID,
|
||||||
form.FolderID,
|
})
|
||||||
)
|
|
||||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
s.db.CreateItems(items)
|
s.db.CreateItems(items)
|
||||||
s.db.SyncSearch()
|
|
||||||
}
|
}
|
||||||
s.worker.FindFeedFavicon(*feed)
|
s.worker.FindFeedFavicon(*feed)
|
||||||
|
|
||||||
@@ -418,14 +422,14 @@ func (s *Server) handleSettings(c *router.Context) {
|
|||||||
if c.Req.Method == "GET" {
|
if c.Req.Method == "GET" {
|
||||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||||
} else if c.Req.Method == "PUT" {
|
} else if c.Req.Method == "PUT" {
|
||||||
settings := make(map[string]any)
|
var params storage.UpdateSettingsParams
|
||||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
if err := json.NewDecoder(c.Req.Body).Decode(¶ms); err != nil {
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.db.UpdateSettings(settings) {
|
if s.db.UpdateSettings(params) {
|
||||||
if _, ok := settings["refresh_rate"]; ok {
|
if params.RefreshRate != nil {
|
||||||
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
|
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
|
||||||
}
|
}
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else {
|
} else {
|
||||||
@@ -448,16 +452,24 @@ func (s *Server) handleOPMLImport(c *router.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, f := range doc.Feeds {
|
for _, f := range doc.Feeds {
|
||||||
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
|
s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
|
Title: f.Title,
|
||||||
|
Link: f.SiteUrl,
|
||||||
|
FeedLink: f.FeedUrl,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
for _, f := range doc.Folders {
|
for _, f := range doc.Folders {
|
||||||
folder := s.db.CreateFolder(f.Title)
|
folder := s.db.CreateFolder(f.Title)
|
||||||
for _, ff := range f.AllFeeds() {
|
for _, ff := range f.AllFeeds() {
|
||||||
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
|
s.db.CreateFeed(storage.CreateFeedParams{
|
||||||
|
Title: ff.Title,
|
||||||
|
Link: ff.SiteUrl,
|
||||||
|
FeedLink: ff.FeedUrl,
|
||||||
|
FolderID: &folder.Id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.worker.FindFavicons()
|
|
||||||
s.worker.RefreshFeeds()
|
s.worker.RefreshFeeds()
|
||||||
|
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func TestFeedIcons(t *testing.T) {
|
|||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
db, _ := storage.New(":memory:")
|
db, _ := storage.New(":memory:")
|
||||||
icon := []byte("test")
|
icon := []byte("test")
|
||||||
feed := db.CreateFeed("", "", "", "", nil)
|
feed := db.CreateFeed(storage.CreateFeedParams{})
|
||||||
db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(&icon)})
|
db.UpdateFeed(feed.Id, storage.UpdateFeedParams{Icon: storage.SetNullable(&icon)})
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ func (h *Server) GetAddr() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start() {
|
func (s *Server) Start() {
|
||||||
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
|
refreshRate := s.db.GetSettings().RefreshRate
|
||||||
s.worker.FindFavicons()
|
|
||||||
s.worker.StartFeedCleaner()
|
s.worker.StartFeedCleaner()
|
||||||
s.worker.SetRefreshRate(refreshRate)
|
s.worker.SetRefreshRate(refreshRate)
|
||||||
if refreshRate > 0 {
|
if refreshRate > 0 {
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ type Feed struct {
|
|||||||
HasIcon bool `json:"has_icon"`
|
HasIcon bool `json:"has_icon"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId *int64) *Feed {
|
type CreateFeedParams struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Link string
|
||||||
|
FeedLink string
|
||||||
|
FolderID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateFeed(params CreateFeedParams) *Feed {
|
||||||
|
title := params.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = feedLink
|
title = params.FeedLink
|
||||||
}
|
}
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
insert into feeds (title, description, link, feed_link, folder_id)
|
insert into feeds (title, description, link, feed_link, folder_id)
|
||||||
@@ -26,10 +35,10 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
|||||||
on conflict (feed_link) do update set folder_id = :folder_id
|
on conflict (feed_link) do update set folder_id = :folder_id
|
||||||
returning id`,
|
returning id`,
|
||||||
sql.Named("title", title),
|
sql.Named("title", title),
|
||||||
sql.Named("description", description),
|
sql.Named("description", params.Description),
|
||||||
sql.Named("link", link),
|
sql.Named("link", params.Link),
|
||||||
sql.Named("feed_link", feedLink),
|
sql.Named("feed_link", params.FeedLink),
|
||||||
sql.Named("folder_id", folderId),
|
sql.Named("folder_id", params.FolderID),
|
||||||
)
|
)
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
@@ -41,10 +50,10 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
|||||||
return &Feed{
|
return &Feed{
|
||||||
Id: id,
|
Id: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: params.Description,
|
||||||
Link: link,
|
Link: params.Link,
|
||||||
FeedLink: feedLink,
|
FeedLink: params.FeedLink,
|
||||||
FolderId: folderId,
|
FolderId: params.FolderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,36 +136,6 @@ func (s *Storage) ListFeeds() []Feed {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ListFeedsMissingIcons() []Feed {
|
|
||||||
result := make([]Feed, 0)
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
select id, folder_id, title, description, link, feed_link
|
|
||||||
from feeds
|
|
||||||
where icon is null
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var f Feed
|
|
||||||
err = rows.Scan(
|
|
||||||
&f.Id,
|
|
||||||
&f.FolderId,
|
|
||||||
&f.Title,
|
|
||||||
&f.Description,
|
|
||||||
&f.Link,
|
|
||||||
&f.FeedLink,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result = append(result, f)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) GetFeed(id int64) *Feed {
|
func (s *Storage) GetFeed(id int64) *Feed {
|
||||||
var f Feed
|
var f Feed
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
@@ -176,42 +155,3 @@ func (s *Storage) GetFeed(id int64) *Feed {
|
|||||||
}
|
}
|
||||||
return &f
|
return &f
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ResetFeedErrors() {
|
|
||||||
if _, err := s.db.Exec(`delete from feed_errors`); err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) SetFeedError(feedID int64, lastError error) {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
insert into feed_errors (feed_id, error)
|
|
||||||
values (:feed_id, :error)
|
|
||||||
on conflict (feed_id) do update set error = excluded.error`,
|
|
||||||
sql.Named("feed_id", feedID),
|
|
||||||
sql.Named("error", lastError.Error()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) GetFeedErrors() map[int64]string {
|
|
||||||
errors := make(map[int64]string)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`select feed_id, error from feed_errors`)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var id int64
|
|
||||||
var error string
|
|
||||||
if err = rows.Scan(&id, &error); err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
errors[id] = error
|
|
||||||
}
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestCreateFeed(t *testing.T) {
|
func TestCreateFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
|
||||||
if feed1 == nil || feed1.Id == 0 {
|
if feed1 == nil || feed1.Id == 0 {
|
||||||
t.Fatal("expected feed")
|
t.Fatal("expected feed")
|
||||||
}
|
}
|
||||||
@@ -19,16 +19,16 @@ func TestCreateFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateFeedSameLink(t *testing.T) {
|
func TestCreateFeedSameLink(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", FeedLink: "http://example1.com/feed.xml"})
|
||||||
if feed1 == nil || feed1.Id == 0 {
|
if feed1 == nil || feed1.Id == 0 {
|
||||||
t.Fatal("expected feed")
|
t.Fatal("expected feed")
|
||||||
}
|
}
|
||||||
|
|
||||||
for range 10 {
|
for range 10 {
|
||||||
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
db.CreateFeed(CreateFeedParams{Title: "title", FeedLink: "http://example2.com/feed.xml"})
|
||||||
}
|
}
|
||||||
|
|
||||||
feed2 := db.CreateFeed("title", "", "http://example.com", "http://example1.com/feed.xml", nil)
|
feed2 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
if feed1.Id != feed2.Id {
|
if feed1.Id != feed2.Id {
|
||||||
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
||||||
}
|
}
|
||||||
@@ -40,8 +40,8 @@ func TestReadFeed(t *testing.T) {
|
|||||||
t.Fatal("cannot get nonexistent feed")
|
t.Fatal("cannot get nonexistent feed")
|
||||||
}
|
}
|
||||||
|
|
||||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
feed2 := db.CreateFeed("feed 2", "", "http://example2.com", "http://example2.com/feed.xml", nil)
|
feed2 := db.CreateFeed(CreateFeedParams{Title: "feed 2", Link: "http://example2.com", FeedLink: "http://example2.com/feed.xml"})
|
||||||
feeds := db.ListFeeds()
|
feeds := db.ListFeeds()
|
||||||
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
|
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
|
||||||
t.Fatalf("invalid feed list: %#v", feeds)
|
t.Fatalf("invalid feed list: %#v", feeds)
|
||||||
@@ -50,7 +50,7 @@ func TestReadFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateFeed(t *testing.T) {
|
func TestUpdateFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "feed 1", Link: "http://example1.com", FeedLink: "http://example1.com/feed.xml"})
|
||||||
folder := db.CreateFolder("test")
|
folder := db.CreateFolder("test")
|
||||||
icon := []byte("icon")
|
icon := []byte("icon")
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func TestUpdateFeed(t *testing.T) {
|
|||||||
|
|
||||||
func TestDeleteFeed(t *testing.T) {
|
func TestDeleteFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
feed1 := db.CreateFeed(CreateFeedParams{Title: "title", Link: "http://example.com", FeedLink: "http://example.com/feed.xml"})
|
||||||
|
|
||||||
if db.DeleteFeed(100500) {
|
if db.DeleteFeed(100500) {
|
||||||
t.Error("cannot delete what does not exist")
|
t.Error("cannot delete what does not exist")
|
||||||
|
|||||||
119
src/storage/feedstate.go
Normal file
119
src/storage/feedstate.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeedState struct {
|
||||||
|
FeedID int64
|
||||||
|
LastRefreshed time.Time
|
||||||
|
LastError string
|
||||||
|
HTTPLastModified string
|
||||||
|
HTTPEtag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ListFeedStates() ([]FeedState, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
select
|
||||||
|
feed_id
|
||||||
|
, last_refreshed
|
||||||
|
, last_error
|
||||||
|
, http_lmod
|
||||||
|
, http_etag
|
||||||
|
from feed_states
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
states := make([]FeedState, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var state FeedState
|
||||||
|
err := rows.Scan(
|
||||||
|
&state.FeedID,
|
||||||
|
&state.LastRefreshed,
|
||||||
|
&state.LastError,
|
||||||
|
&state.HTTPLastModified,
|
||||||
|
&state.HTTPEtag,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
states = append(states, state)
|
||||||
|
}
|
||||||
|
return states, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetFeedState(feedID int64) (*FeedState, error) {
|
||||||
|
var state FeedState
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
select
|
||||||
|
feed_id
|
||||||
|
, last_refreshed
|
||||||
|
, last_error
|
||||||
|
, http_lmod
|
||||||
|
, http_etag
|
||||||
|
from feed_states where feed_id = :id
|
||||||
|
`, sql.Named("id", feedID)).Scan(
|
||||||
|
&state.FeedID,
|
||||||
|
&state.LastRefreshed,
|
||||||
|
&state.LastError,
|
||||||
|
&state.HTTPLastModified,
|
||||||
|
&state.HTTPEtag,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFeedStateParams struct {
|
||||||
|
LastRefreshed *time.Time
|
||||||
|
LastError *string
|
||||||
|
HTTPLastModified *string
|
||||||
|
HTTPEtag *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateFeedState(feedID int64, params UpdateFeedStateParams) (bool, error) {
|
||||||
|
lastError := params.LastError
|
||||||
|
if lastError != nil && *lastError == "" {
|
||||||
|
lastError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
insert into feed_states (
|
||||||
|
feed_id
|
||||||
|
, last_refreshed
|
||||||
|
, last_error
|
||||||
|
, http_lmod
|
||||||
|
, http_etag
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
:id
|
||||||
|
, coalesce(:last_refreshed, 0)
|
||||||
|
, coalesce(:last_error, '')
|
||||||
|
, coalesce(:http_lmod, '')
|
||||||
|
, coalesce(:http_etag, '')
|
||||||
|
)
|
||||||
|
on conflict (feed_id) do update set
|
||||||
|
last_refreshed = coalesce(:last_refreshed, last_refreshed),
|
||||||
|
last_error = coalesce(:last_error, last_error),
|
||||||
|
http_lmod = coalesce(:http_lmod, http_lmod),
|
||||||
|
http_etag = coalesce(:http_etag, http_etag)
|
||||||
|
`,
|
||||||
|
sql.Named("id", feedID),
|
||||||
|
sql.Named("last_refreshed", params.LastRefreshed),
|
||||||
|
sql.Named("last_error", params.LastError),
|
||||||
|
sql.Named("http_lmod", params.HTTPLastModified),
|
||||||
|
sql.Named("http_etag", params.HTTPEtag),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
129
src/storage/feedstate_test.go
Normal file
129
src/storage/feedstate_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateFeedState_Full(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
errMsg := "error"
|
||||||
|
lmod := "today"
|
||||||
|
etag := "v1"
|
||||||
|
|
||||||
|
ok, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{
|
||||||
|
LastRefreshed: &now,
|
||||||
|
LastError: &errMsg,
|
||||||
|
HTTPLastModified: &lmod,
|
||||||
|
HTTPEtag: &etag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected true")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.GetFeedState(f.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
t.Fatal("expected state, got nil")
|
||||||
|
}
|
||||||
|
if !state.LastRefreshed.Equal(now) {
|
||||||
|
t.Errorf("expected %v, got %v", now, state.LastRefreshed)
|
||||||
|
}
|
||||||
|
if state.LastError != errMsg {
|
||||||
|
t.Errorf("expected %s, got %v", errMsg, state.LastError)
|
||||||
|
}
|
||||||
|
if state.HTTPLastModified != lmod {
|
||||||
|
t.Errorf("expected %s, got %s", lmod, state.HTTPLastModified)
|
||||||
|
}
|
||||||
|
if state.HTTPEtag != etag {
|
||||||
|
t.Errorf("expected %s, got %s", etag, state.HTTPEtag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFeedState_Partial(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
|
||||||
|
etag := "v1"
|
||||||
|
s.UpdateFeedState(f.Id, UpdateFeedStateParams{HTTPEtag: &etag})
|
||||||
|
|
||||||
|
newErr := "new error"
|
||||||
|
_, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{
|
||||||
|
LastError: &newErr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.GetFeedState(f.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.LastError != newErr {
|
||||||
|
t.Errorf("expected %s, got %v", newErr, state.LastError)
|
||||||
|
}
|
||||||
|
if state.HTTPEtag != etag {
|
||||||
|
t.Errorf("etag should be unchanged, got %s", state.HTTPEtag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFeedState_ClearError(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f := s.CreateFeed(CreateFeedParams{Title: "Test", FeedLink: "http://example.com"})
|
||||||
|
errMsg := "error"
|
||||||
|
s.UpdateFeedState(f.Id, UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
|
||||||
|
empty := ""
|
||||||
|
_, err := s.UpdateFeedState(f.Id, UpdateFeedStateParams{
|
||||||
|
LastError: &empty,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.GetFeedState(f.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.LastError != "" {
|
||||||
|
t.Errorf("expected empty error string, got %v", state.LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFeedStates(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
f1 := s.CreateFeed(CreateFeedParams{Title: "F1", FeedLink: "L1"})
|
||||||
|
f2 := s.CreateFeed(CreateFeedParams{Title: "F2", FeedLink: "L2"})
|
||||||
|
|
||||||
|
errMsg := "fail"
|
||||||
|
s.UpdateFeedState(f1.Id, UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
s.UpdateFeedState(f2.Id, UpdateFeedStateParams{HTTPEtag: ptr("e")})
|
||||||
|
|
||||||
|
states, err := s.ListFeedStates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(states) != 2 {
|
||||||
|
t.Errorf("expected 2 states, got %d", len(states))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -38,20 +38,27 @@ func (s *Storage) DeleteFolder(folderId int64) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) RenameFolder(folderId int64, newTitle string) bool {
|
type UpdateFolderParams struct {
|
||||||
_, err := s.db.Exec(`update folders set title = :title where id = :id`,
|
Title *string
|
||||||
sql.Named("title", newTitle),
|
IsExpanded *bool
|
||||||
sql.Named("id", folderId),
|
|
||||||
)
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ToggleFolderExpanded(folderId int64, isExpanded bool) bool {
|
func (s *Storage) UpdateFolder(folderId int64, params UpdateFolderParams) (bool, error) {
|
||||||
_, err := s.db.Exec(`update folders set is_expanded = :is_expanded where id = :id`,
|
_, err := s.db.Exec(`
|
||||||
sql.Named("is_expanded", isExpanded),
|
update folders set
|
||||||
|
title = coalesce(:title, title),
|
||||||
|
is_expanded = coalesce(:is_expanded, is_expanded)
|
||||||
|
where id = :id
|
||||||
|
`,
|
||||||
sql.Named("id", folderId),
|
sql.Named("id", folderId),
|
||||||
|
sql.Named("title", params.Title),
|
||||||
|
sql.Named("is_expanded", params.IsExpanded),
|
||||||
)
|
)
|
||||||
return err == nil
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ListFolders() []Folder {
|
func (s *Storage) ListFolders() []Folder {
|
||||||
|
|||||||
78
src/storage/folder_test.go
Normal file
78
src/storage/folder_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateFolder(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
folder := db.CreateFolder("old title")
|
||||||
|
if folder.IsExpanded != true {
|
||||||
|
t.Fatal("expected folder to be expanded by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("rename only", func(t *testing.T) {
|
||||||
|
newTitle := "new title"
|
||||||
|
ok, err := db.UpdateFolder(folder.Id, UpdateFolderParams{
|
||||||
|
Title: &newTitle,
|
||||||
|
})
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("UpdateFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := db.ListFolders()
|
||||||
|
if len(folders) != 1 || folders[0].Title != "new title" {
|
||||||
|
t.Errorf("expected title to be updated, got %s", folders[0].Title)
|
||||||
|
}
|
||||||
|
if folders[0].IsExpanded != true {
|
||||||
|
t.Error("expected expansion state to remain unchanged")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("toggle expanded only", func(t *testing.T) {
|
||||||
|
isExpanded := false
|
||||||
|
ok, err := db.UpdateFolder(folder.Id, UpdateFolderParams{
|
||||||
|
IsExpanded: &isExpanded,
|
||||||
|
})
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("UpdateFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := db.ListFolders()
|
||||||
|
if len(folders) != 1 || folders[0].IsExpanded != false {
|
||||||
|
t.Errorf("expected is_expanded to be false, got %v", folders[0].IsExpanded)
|
||||||
|
}
|
||||||
|
if folders[0].Title != "new title" {
|
||||||
|
t.Error("expected title to remain unchanged")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update both", func(t *testing.T) {
|
||||||
|
bothTitle := "both"
|
||||||
|
isExpanded := true
|
||||||
|
ok, err := db.UpdateFolder(folder.Id, UpdateFolderParams{
|
||||||
|
Title: &bothTitle,
|
||||||
|
IsExpanded: &isExpanded,
|
||||||
|
})
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("UpdateFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := db.ListFolders()
|
||||||
|
if len(folders) != 1 || folders[0].Title != "both" || folders[0].IsExpanded != true {
|
||||||
|
t.Errorf("expected both to be updated, got title=%s expanded=%v", folders[0].Title, folders[0].IsExpanded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update none", func(t *testing.T) {
|
||||||
|
ok, err := db.UpdateFolder(folder.Id, UpdateFolderParams{})
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("UpdateFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders := db.ListFolders()
|
||||||
|
if len(folders) != 1 || folders[0].Title != "both" || folders[0].IsExpanded != true {
|
||||||
|
t.Errorf("expected no changes, got title=%s expanded=%v", folders[0].Title, folders[0].IsExpanded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HTTPState struct {
|
|
||||||
FeedID int64
|
|
||||||
LastRefreshed time.Time
|
|
||||||
|
|
||||||
LastModified string
|
|
||||||
Etag string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) ListHTTPStates() map[int64]HTTPState {
|
|
||||||
result := make(map[int64]HTTPState)
|
|
||||||
rows, err := s.db.Query(`select feed_id, last_refreshed, last_modified, etag from http_states`)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var state HTTPState
|
|
||||||
err = rows.Scan(
|
|
||||||
&state.FeedID,
|
|
||||||
&state.LastRefreshed,
|
|
||||||
&state.LastModified,
|
|
||||||
&state.Etag,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result[state.FeedID] = state
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
|
|
||||||
row := s.db.QueryRow(`
|
|
||||||
select feed_id, last_refreshed, last_modified, etag
|
|
||||||
from http_states where feed_id = :feed_id
|
|
||||||
`, sql.Named("feed_id", feedID))
|
|
||||||
|
|
||||||
if row == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var state HTTPState
|
|
||||||
row.Scan(
|
|
||||||
&state.FeedID,
|
|
||||||
&state.LastRefreshed,
|
|
||||||
&state.LastModified,
|
|
||||||
&state.Etag,
|
|
||||||
)
|
|
||||||
return &state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) SetHTTPState(feedID int64, lastModified, etag string) {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
insert into http_states (feed_id, last_modified, etag, last_refreshed)
|
|
||||||
values (:feed_id, :last_modified, :etag, datetime())
|
|
||||||
on conflict (feed_id) do update set last_modified = :last_modified, etag = :etag, last_refreshed = datetime()`,
|
|
||||||
sql.Named("feed_id", feedID),
|
|
||||||
sql.Named("last_modified", lastModified),
|
|
||||||
sql.Named("etag", etag),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ItemStatus int
|
type ItemStatus int
|
||||||
@@ -195,7 +193,7 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []any) {
|
|||||||
|
|
||||||
cond = append(
|
cond = append(
|
||||||
cond,
|
cond,
|
||||||
"i.search_rowid in (select rowid from search where search match :search)",
|
"i.id in (select rowid as id from search where search match :search)",
|
||||||
)
|
)
|
||||||
args = append(args, sql.Named("search", strings.Join(terms, " ")))
|
args = append(args, sql.Named("search", strings.Join(terms, " ")))
|
||||||
}
|
}
|
||||||
@@ -379,46 +377,6 @@ func (s *Storage) FeedStats() []FeedStat {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) SyncSearch() {
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
select id, title, content
|
|
||||||
from items
|
|
||||||
where search_rowid is null;
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]Item, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var item Item
|
|
||||||
rows.Scan(&item.Id, &item.Title, &item.Content)
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items {
|
|
||||||
result, err := s.db.Exec(`
|
|
||||||
insert into search (title, description, content) values (:title, "", :content)`,
|
|
||||||
sql.Named("title", item.Title),
|
|
||||||
sql.Named("content", htmlutil.ExtractText(item.Content)),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if numrows, err := result.RowsAffected(); err == nil && numrows == 1 {
|
|
||||||
if rowId, err := result.LastInsertId(); err == nil {
|
|
||||||
s.db.Exec(
|
|
||||||
`update items set search_rowid = :search_rowid where id = :id`,
|
|
||||||
sql.Named("search_rowid", rowId),
|
|
||||||
sql.Named("id", item.Id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
itemsKeepSize = 50
|
itemsKeepSize = 50
|
||||||
itemsKeepDays = 90
|
itemsKeepDays = 90
|
||||||
@@ -458,5 +416,9 @@ func (s *Storage) DeleteOldItems() {
|
|||||||
numDeleted, err := result.RowsAffected()
|
numDeleted, err := result.RowsAffected()
|
||||||
if err == nil && numDeleted > 0 {
|
if err == nil && numDeleted > 0 {
|
||||||
log.Printf("Deleted %d old items", numDeleted)
|
log.Printf("Deleted %d old items", numDeleted)
|
||||||
|
|
||||||
|
if _, err := s.db.Exec("vacuum"); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ func testItemsSetup(db *Storage) testItemScope {
|
|||||||
folder1 := db.CreateFolder("folder1")
|
folder1 := db.CreateFolder("folder1")
|
||||||
folder2 := db.CreateFolder("folder2")
|
folder2 := db.CreateFolder("folder2")
|
||||||
|
|
||||||
feed11 := db.CreateFeed("feed11", "", "", "http://test.com/feed11.xml", &folder1.Id)
|
feed11 := db.CreateFeed(CreateFeedParams{Title: "feed11", FeedLink: "http://test.com/feed11.xml", FolderID: &folder1.Id})
|
||||||
feed12 := db.CreateFeed("feed12", "", "", "http://test.com/feed12.xml", &folder1.Id)
|
feed12 := db.CreateFeed(CreateFeedParams{Title: "feed12", FeedLink: "http://test.com/feed12.xml", FolderID: &folder1.Id})
|
||||||
feed21 := db.CreateFeed("feed21", "", "", "http://test.com/feed21.xml", &folder2.Id)
|
feed21 := db.CreateFeed(CreateFeedParams{Title: "feed21", FeedLink: "http://test.com/feed21.xml", FolderID: &folder2.Id})
|
||||||
feed01 := db.CreateFeed("feed01", "", "", "http://test.com/feed01.xml", nil)
|
feed01 := db.CreateFeed(CreateFeedParams{Title: "feed01", FeedLink: "http://test.com/feed01.xml"})
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
db.CreateItems([]Item{
|
db.CreateItems([]Item{
|
||||||
@@ -212,7 +212,6 @@ func TestListItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filter by search
|
// filter by search
|
||||||
db.SyncSearch()
|
|
||||||
search1 := "title111"
|
search1 := "title111"
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
||||||
want = []string{"item111"}
|
want = []string{"item111"}
|
||||||
@@ -326,7 +325,7 @@ func TestDeleteOldItems(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("keeps at least 50 items", func(t *testing.T) {
|
t.Run("keeps at least 50 items", func(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
items := make([]Item, 100)
|
items := make([]Item, 100)
|
||||||
for i := range 100 {
|
for i := range 100 {
|
||||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
|
||||||
@@ -347,7 +346,7 @@ func TestDeleteOldItems(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("keeps all less than 90 days old", func(t *testing.T) {
|
t.Run("keeps all less than 90 days old", func(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
items := make([]Item, 100)
|
items := make([]Item, 100)
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||||
@@ -369,7 +368,7 @@ func TestDeleteOldItems(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("keeps starred", func(t *testing.T) {
|
t.Run("keeps starred", func(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
items := make([]Item, 100)
|
items := make([]Item, 100)
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||||
@@ -398,7 +397,7 @@ func TestCreateItemsLastArrived(t *testing.T) {
|
|||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
defer db.db.Close()
|
defer db.db.Close()
|
||||||
feed := db.CreateFeed("test feed", "", "", "http://example.com/feed", nil)
|
feed := db.CreateFeed(CreateFeedParams{Title: "test feed", FeedLink: "http://example.com/feed"})
|
||||||
|
|
||||||
item := Item{
|
item := Item{
|
||||||
GUID: "item1",
|
GUID: "item1",
|
||||||
@@ -432,3 +431,85 @@ func TestCreateItemsLastArrived(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
defer db.Close()
|
||||||
|
feed := db.CreateFeed(CreateFeedParams{Title: "f", FeedLink: "http://f.xml"})
|
||||||
|
|
||||||
|
db.CreateItems([]Item{
|
||||||
|
{
|
||||||
|
GUID: "i1",
|
||||||
|
FeedId: feed.Id,
|
||||||
|
Title: "Hello World",
|
||||||
|
Content: "This is a <b>test</b> of the <i>emergency</i> broadcast system.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GUID: "i2",
|
||||||
|
FeedId: feed.Id,
|
||||||
|
Title: "FTS5 Unicode",
|
||||||
|
Content: "Unicode support with characters like: Привет, 世界, 🚀",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GUID: "i3",
|
||||||
|
FeedId: feed.Id,
|
||||||
|
Title: "Hidden Tag",
|
||||||
|
Content: `<div class="secret-class">Don't find me by my class name</div>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. Basic search
|
||||||
|
s1 := "emergency"
|
||||||
|
have := getItemGuids(db.ListItems(ItemFilter{Search: &s1}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
|
t.Errorf("basic search failed: expected [i1], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. HTML stripping: Should find text, but NOT the tags
|
||||||
|
s2 := "test"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s2}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
|
t.Errorf("html text search failed: expected [i1], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
s3 := "secret-class"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s3}, 10, true, false))
|
||||||
|
if len(have) > 0 {
|
||||||
|
t.Errorf("html tag search should have failed but found: %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Multi-word (AND)
|
||||||
|
s4 := "broadcast system"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s4}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
|
t.Errorf("multi-word search failed: expected [i1], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Unicode
|
||||||
|
s5 := "Привет"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s5}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i2"}) {
|
||||||
|
t.Errorf("unicode search failed: expected [i2], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
s6 := "世界"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s6}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i2"}) {
|
||||||
|
t.Errorf("unicode search (CJK) failed: expected [i2], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Trigger: Update
|
||||||
|
db.db.Exec("update items set title = 'Updated Title' where guid = 'i1'")
|
||||||
|
s7 := "Updated"
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s7}, 10, true, false))
|
||||||
|
if !reflect.DeepEqual(have, []string{"i1"}) {
|
||||||
|
t.Errorf("update trigger failed: expected [i1], got %v", have)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Trigger: Delete
|
||||||
|
db.db.Exec("delete from items where guid = 'i1'")
|
||||||
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &s7}, 10, true, false))
|
||||||
|
if len(have) > 0 {
|
||||||
|
t.Errorf("delete trigger failed: found deleted item: %v", have)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ var migrations = []func(*sql.Tx) error{
|
|||||||
m10_add_item_medialinks,
|
m10_add_item_medialinks,
|
||||||
m11_add_item_last_arrived,
|
m11_add_item_last_arrived,
|
||||||
m12_remove_feed_sizes,
|
m12_remove_feed_sizes,
|
||||||
|
m13_consolidate_feed_states,
|
||||||
|
m14_upgrade_fts5,
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxVersion = int64(len(migrations))
|
var maxVersion = int64(len(migrations))
|
||||||
@@ -345,3 +347,76 @@ func m12_remove_feed_sizes(tx *sql.Tx) error {
|
|||||||
_, err := tx.Exec(`drop table if exists feed_sizes`)
|
_, err := tx.Exec(`drop table if exists feed_sizes`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func m13_consolidate_feed_states(tx *sql.Tx) error {
|
||||||
|
sql := `
|
||||||
|
create table feed_states (
|
||||||
|
feed_id references feeds(id) on delete cascade unique
|
||||||
|
, last_refreshed datetime not null default 0
|
||||||
|
, last_error string not null default ''
|
||||||
|
|
||||||
|
, http_lmod string not null default ''
|
||||||
|
, http_etag string not null default ''
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into feed_states (
|
||||||
|
feed_id
|
||||||
|
, last_refreshed
|
||||||
|
, last_error
|
||||||
|
, http_lmod
|
||||||
|
, http_etag
|
||||||
|
)
|
||||||
|
select
|
||||||
|
f.id
|
||||||
|
, coalesce(h.last_refreshed, 0)
|
||||||
|
, coalesce(e.error, '')
|
||||||
|
, coalesce(h.last_modified, '')
|
||||||
|
, coalesce(h.etag, '')
|
||||||
|
from feeds f
|
||||||
|
left join http_states h on f.id = h.feed_id
|
||||||
|
left join feed_errors e on f.id = e.feed_id
|
||||||
|
where h.feed_id is not null or e.feed_id is not null;
|
||||||
|
|
||||||
|
drop table http_states;
|
||||||
|
drop table feed_errors;
|
||||||
|
`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func m14_upgrade_fts5(tx *sql.Tx) error {
|
||||||
|
sql := `
|
||||||
|
-- 1. Drop old FTS4 table and trigger
|
||||||
|
drop table if exists search;
|
||||||
|
drop trigger if exists del_item_search;
|
||||||
|
|
||||||
|
-- 2. Remove search_rowid from items
|
||||||
|
drop index if exists idx_item_search_rowid;
|
||||||
|
alter table items drop column search_rowid;
|
||||||
|
|
||||||
|
-- 3. Create FTS5 virtual table
|
||||||
|
create virtual table search using fts5(
|
||||||
|
title, content,
|
||||||
|
content='items',
|
||||||
|
content_rowid='id',
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Create triggers for automatic FTS sync
|
||||||
|
create trigger items_ai after insert on items begin
|
||||||
|
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
|
||||||
|
end;
|
||||||
|
create trigger items_ad after delete on items begin
|
||||||
|
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
|
||||||
|
end;
|
||||||
|
create trigger items_au after update on items begin
|
||||||
|
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
|
||||||
|
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
|
||||||
|
end;
|
||||||
|
|
||||||
|
-- 5. Populate FTS5 table with existing data
|
||||||
|
insert into search(rowid, title, content) select id, title, strip_html(content) from items;
|
||||||
|
`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,92 +6,166 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func settingsDefaults() map[string]any {
|
type Settings struct {
|
||||||
|
Filter string `json:"filter"`
|
||||||
|
Feed string `json:"feed"`
|
||||||
|
FeedListWidth int `json:"feed_list_width"`
|
||||||
|
ItemListWidth int `json:"item_list_width"`
|
||||||
|
SortNewestFirst bool `json:"sort_newest_first"`
|
||||||
|
ThemeName string `json:"theme_name"`
|
||||||
|
ThemeFont string `json:"theme_font"`
|
||||||
|
ThemeSize int `json:"theme_size"`
|
||||||
|
RefreshRate int64 `json:"refresh_rate"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Settings) Map() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"filter": "",
|
"filter": s.Filter,
|
||||||
"feed": "",
|
"feed": s.Feed,
|
||||||
"feed_list_width": 300,
|
"feed_list_width": s.FeedListWidth,
|
||||||
"item_list_width": 300,
|
"item_list_width": s.ItemListWidth,
|
||||||
"sort_newest_first": true,
|
"sort_newest_first": s.SortNewestFirst,
|
||||||
"theme_name": "light",
|
"theme_name": s.ThemeName,
|
||||||
"theme_font": "",
|
"theme_font": s.ThemeFont,
|
||||||
"theme_size": 1,
|
"theme_size": s.ThemeSize,
|
||||||
"refresh_rate": 0,
|
"refresh_rate": s.RefreshRate,
|
||||||
"language": "en",
|
"language": s.Language,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) GetSettingsValue(key string) any {
|
func settingsDefaults() Settings {
|
||||||
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key))
|
return Settings{
|
||||||
if row == nil {
|
Filter: "",
|
||||||
return settingsDefaults()[key]
|
Feed: "",
|
||||||
|
FeedListWidth: 300,
|
||||||
|
ItemListWidth: 300,
|
||||||
|
SortNewestFirst: true,
|
||||||
|
ThemeName: "light",
|
||||||
|
ThemeFont: "",
|
||||||
|
ThemeSize: 1,
|
||||||
|
RefreshRate: 0,
|
||||||
|
Language: "en",
|
||||||
}
|
}
|
||||||
var val []byte
|
|
||||||
row.Scan(&val)
|
|
||||||
if len(val) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var valDecoded any
|
|
||||||
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return valDecoded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) GetSettingsValueInt64(key string) int64 {
|
func (s *Storage) GetSettings() Settings {
|
||||||
val := s.GetSettingsValue(key)
|
|
||||||
if val != nil {
|
|
||||||
if fval, ok := val.(float64); ok {
|
|
||||||
return int64(fval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Storage) GetSettings() map[string]any {
|
|
||||||
result := settingsDefaults()
|
result := settingsDefaults()
|
||||||
rows, err := s.db.Query(`select key, val from settings;`)
|
rows, err := s.db.Query(`select key, val from settings;`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var key string
|
var key string
|
||||||
var val []byte
|
var val []byte
|
||||||
var valDecoded any
|
|
||||||
|
|
||||||
rows.Scan(&key, &val)
|
rows.Scan(&key, &val)
|
||||||
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
|
||||||
log.Print(err)
|
switch key {
|
||||||
continue
|
case "filter":
|
||||||
|
json.Unmarshal(val, &result.Filter)
|
||||||
|
case "feed":
|
||||||
|
json.Unmarshal(val, &result.Feed)
|
||||||
|
case "feed_list_width":
|
||||||
|
json.Unmarshal(val, &result.FeedListWidth)
|
||||||
|
case "item_list_width":
|
||||||
|
json.Unmarshal(val, &result.ItemListWidth)
|
||||||
|
case "sort_newest_first":
|
||||||
|
json.Unmarshal(val, &result.SortNewestFirst)
|
||||||
|
case "theme_name":
|
||||||
|
json.Unmarshal(val, &result.ThemeName)
|
||||||
|
case "theme_font":
|
||||||
|
json.Unmarshal(val, &result.ThemeFont)
|
||||||
|
case "theme_size":
|
||||||
|
json.Unmarshal(val, &result.ThemeSize)
|
||||||
|
case "refresh_rate":
|
||||||
|
json.Unmarshal(val, &result.RefreshRate)
|
||||||
|
case "language":
|
||||||
|
json.Unmarshal(val, &result.Language)
|
||||||
}
|
}
|
||||||
result[key] = valDecoded
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) UpdateSettings(kv map[string]any) bool {
|
type UpdateSettingsParams struct {
|
||||||
defaults := settingsDefaults()
|
Filter *string `json:"filter"`
|
||||||
for key, val := range kv {
|
Feed *string `json:"feed"`
|
||||||
if defaults[key] == nil {
|
FeedListWidth *int `json:"feed_list_width"`
|
||||||
continue
|
ItemListWidth *int `json:"item_list_width"`
|
||||||
|
SortNewestFirst *bool `json:"sort_newest_first"`
|
||||||
|
ThemeName *string `json:"theme_name"`
|
||||||
|
ThemeFont *string `json:"theme_font"`
|
||||||
|
ThemeSize *int `json:"theme_size"`
|
||||||
|
RefreshRate *int64 `json:"refresh_rate"`
|
||||||
|
Language *string `json:"language"`
|
||||||
}
|
}
|
||||||
valEncoded, err := json.Marshal(val)
|
|
||||||
|
func (s *Storage) UpdateSettings(params UpdateSettingsParams) bool {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(`
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
update := func(key string, val any) error {
|
||||||
|
valEncoded, err := json.Marshal(val)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`
|
||||||
insert into settings (key, val) values (:key, :val)
|
insert into settings (key, val) values (:key, :val)
|
||||||
on conflict (key) do update set val=:val`,
|
on conflict (key) do update set val=:val`,
|
||||||
sql.Named("key", key),
|
sql.Named("key", key),
|
||||||
sql.Named("val", valEncoded),
|
sql.Named("val", valEncoded),
|
||||||
)
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
if params.Filter != nil {
|
||||||
|
errs = append(errs, update("filter", *params.Filter))
|
||||||
|
}
|
||||||
|
if params.Feed != nil {
|
||||||
|
errs = append(errs, update("feed", *params.Feed))
|
||||||
|
}
|
||||||
|
if params.FeedListWidth != nil {
|
||||||
|
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
|
||||||
|
}
|
||||||
|
if params.ItemListWidth != nil {
|
||||||
|
errs = append(errs, update("item_list_width", *params.ItemListWidth))
|
||||||
|
}
|
||||||
|
if params.SortNewestFirst != nil {
|
||||||
|
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
|
||||||
|
}
|
||||||
|
if params.ThemeName != nil {
|
||||||
|
errs = append(errs, update("theme_name", *params.ThemeName))
|
||||||
|
}
|
||||||
|
if params.ThemeFont != nil {
|
||||||
|
errs = append(errs, update("theme_font", *params.ThemeFont))
|
||||||
|
}
|
||||||
|
if params.ThemeSize != nil {
|
||||||
|
errs = append(errs, update("theme_size", *params.ThemeSize))
|
||||||
|
}
|
||||||
|
if params.RefreshRate != nil {
|
||||||
|
errs = append(errs, update("refresh_rate", *params.RefreshRate))
|
||||||
|
}
|
||||||
|
if params.Language != nil {
|
||||||
|
errs = append(errs, update("language", *params.Language))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, err := range errs {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/storage/settings_test.go
Normal file
150
src/storage/settings_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsDefaults(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
settings := s.GetSettings()
|
||||||
|
defaults := settingsDefaults()
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(settings, defaults) {
|
||||||
|
t.Errorf("expected defaults %+v, got %+v", defaults, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettings(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
params := UpdateSettingsParams{
|
||||||
|
ThemeName: ptr("night"),
|
||||||
|
FeedListWidth: ptr(400),
|
||||||
|
RefreshRate: ptr(int64(15)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := s.UpdateSettings(params); !ok {
|
||||||
|
t.Fatal("UpdateSettings failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := s.GetSettings()
|
||||||
|
|
||||||
|
if settings.ThemeName != "night" {
|
||||||
|
t.Errorf("expected theme_name night, got %s", settings.ThemeName)
|
||||||
|
}
|
||||||
|
if settings.FeedListWidth != 400 {
|
||||||
|
t.Errorf("expected feed_list_width 400, got %d", settings.FeedListWidth)
|
||||||
|
}
|
||||||
|
if settings.RefreshRate != 15 {
|
||||||
|
t.Errorf("expected refresh_rate 15, got %d", settings.RefreshRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettings(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
s.UpdateSettings(UpdateSettingsParams{Language: ptr("fr")})
|
||||||
|
|
||||||
|
settings := s.GetSettings()
|
||||||
|
if settings.Language != "fr" {
|
||||||
|
t.Errorf("expected fr, got %v", settings.Language)
|
||||||
|
}
|
||||||
|
if settings.ThemeName != "light" {
|
||||||
|
t.Errorf("expected light, got %v", settings.ThemeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsExhaustive(t *testing.T) {
|
||||||
|
s := testDB()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
settingsType := reflect.TypeOf(Settings{})
|
||||||
|
paramsType := reflect.TypeOf(UpdateSettingsParams{})
|
||||||
|
|
||||||
|
settings := s.GetSettings()
|
||||||
|
m := settings.Map()
|
||||||
|
|
||||||
|
for i := 0; i < settingsType.NumField(); i++ {
|
||||||
|
field := settingsType.Field(i)
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag == "" {
|
||||||
|
t.Errorf("Field %s missing json tag", field.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// json tags might have options like "name,omitempty", take only the first part
|
||||||
|
jsonKey := strings.Split(jsonTag, ",")[0]
|
||||||
|
|
||||||
|
// 1. Check Map()
|
||||||
|
if _, ok := m[jsonKey]; !ok {
|
||||||
|
t.Errorf("Key %q (from field %s) missing from Settings.Map()", jsonKey, field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check UpdateSettingsParams
|
||||||
|
foundInParams := false
|
||||||
|
for j := 0; j < paramsType.NumField(); j++ {
|
||||||
|
pField := paramsType.Field(j)
|
||||||
|
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
|
||||||
|
if pJsonTag == jsonKey {
|
||||||
|
foundInParams = true
|
||||||
|
// Also check it's a pointer
|
||||||
|
if pField.Type.Kind() != reflect.Ptr {
|
||||||
|
t.Errorf("Field %s in UpdateSettingsParams should be a pointer", pField.Name)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInParams {
|
||||||
|
t.Errorf("Key %q (from field %s) missing from UpdateSettingsParams", jsonKey, field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test round-trip update
|
||||||
|
// We'll create a new UpdateSettingsParams and set ONLY this field
|
||||||
|
paramsValue := reflect.New(paramsType).Elem()
|
||||||
|
for j := 0; j < paramsType.NumField(); j++ {
|
||||||
|
pField := paramsType.Field(j)
|
||||||
|
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
|
||||||
|
if pJsonTag == jsonKey {
|
||||||
|
// Create a new value of the underlying type
|
||||||
|
val := reflect.New(field.Type).Elem()
|
||||||
|
switch field.Type.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
val.SetString("test_" + jsonKey)
|
||||||
|
case reflect.Int, reflect.Int64:
|
||||||
|
val.SetInt(42)
|
||||||
|
case reflect.Bool:
|
||||||
|
val.SetBool(false)
|
||||||
|
}
|
||||||
|
paramsValue.Field(j).Set(val.Addr())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := s.UpdateSettings(paramsValue.Interface().(UpdateSettingsParams)); !ok {
|
||||||
|
t.Errorf("UpdateSettings failed for %q", jsonKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := s.GetSettings()
|
||||||
|
updatedValue := reflect.ValueOf(updated).Field(i)
|
||||||
|
|
||||||
|
switch field.Type.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
if updatedValue.String() != "test_"+jsonKey {
|
||||||
|
t.Errorf("Round-trip failed for %q: expected %q, got %q (check UpdateSettings/GetSettings switch)", jsonKey, "test_"+jsonKey, updatedValue.String())
|
||||||
|
}
|
||||||
|
case reflect.Int, reflect.Int64:
|
||||||
|
if updatedValue.Int() != 42 {
|
||||||
|
t.Errorf("Round-trip failed for %q: expected 42, got %d (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Int())
|
||||||
|
}
|
||||||
|
case reflect.Bool:
|
||||||
|
if updatedValue.Bool() != false {
|
||||||
|
t.Errorf("Round-trip failed for %q: expected false, got %v (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Bool())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,18 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register("sqlite3_yarr", &sqlite3.SQLiteDriver{
|
||||||
|
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||||
|
return conn.RegisterFunc("strip_html", htmlutil.ExtractText, true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
@@ -28,7 +37,7 @@ func New(path string) (*Storage, error) {
|
|||||||
path = path + "?" + params
|
path = path + "?" + params
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", path)
|
db, err := sql.Open("sqlite3_yarr", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -38,3 +47,7 @@ func New(path string) (*Storage, error) {
|
|||||||
}
|
}
|
||||||
return &Storage{db: db}, nil
|
return &Storage{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import (
|
|||||||
|
|
||||||
func testDB() *Storage {
|
func testDB() *Storage {
|
||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
db, _ := New(":memory:")
|
db, err := New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/scraper"
|
"github.com/nkanaev/yarr/src/content/scraper"
|
||||||
"github.com/nkanaev/yarr/src/parser"
|
"github.com/nkanaev/yarr/src/parser"
|
||||||
@@ -162,9 +163,9 @@ func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
|||||||
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
||||||
lmod := ""
|
lmod := ""
|
||||||
etag := ""
|
etag := ""
|
||||||
if state := db.GetHTTPState(f.Id); state != nil {
|
if state, _ := db.GetFeedState(f.Id); state != nil {
|
||||||
lmod = state.LastModified
|
lmod = state.HTTPLastModified
|
||||||
etag = state.Etag
|
etag = state.HTTPEtag
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := client.getConditional(f.FeedLink, lmod, etag)
|
res, err := client.getConditional(f.FeedLink, lmod, etag)
|
||||||
@@ -190,8 +191,13 @@ func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
|||||||
|
|
||||||
lmod = res.Header.Get("Last-Modified")
|
lmod = res.Header.Get("Last-Modified")
|
||||||
etag = res.Header.Get("Etag")
|
etag = res.Header.Get("Etag")
|
||||||
|
now := time.Now().UTC()
|
||||||
if lmod != "" || etag != "" {
|
if lmod != "" || etag != "" {
|
||||||
db.SetHTTPState(f.Id, lmod, etag)
|
db.UpdateFeedState(f.Id, storage.UpdateFeedStateParams{
|
||||||
|
HTTPLastModified: &lmod,
|
||||||
|
HTTPEtag: &etag,
|
||||||
|
LastRefreshed: &now,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return ConvertItems(feed.Items, f), nil
|
return ConvertItems(feed.Items, f), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ func (w *Worker) StartFeedCleaner() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) FindFavicons() {
|
|
||||||
go func() {
|
|
||||||
for _, feed := range w.db.ListFeedsMissingIcons() {
|
|
||||||
w.FindFeedFavicon(feed)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Worker) FindFeedFavicon(feed storage.Feed) {
|
func (w *Worker) FindFeedFavicon(feed storage.Feed) {
|
||||||
icon, err := findFavicon(feed.Link, feed.FeedLink)
|
icon, err := findFavicon(feed.Link, feed.FeedLink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,7 +100,7 @@ func (w *Worker) RefreshFeeds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) refresher(feeds []storage.Feed) {
|
func (w *Worker) refresher(feeds []storage.Feed) {
|
||||||
w.db.ResetFeedErrors()
|
// w.db.ResetFeedErrors()
|
||||||
|
|
||||||
srcqueue := make(chan storage.Feed, len(feeds))
|
srcqueue := make(chan storage.Feed, len(feeds))
|
||||||
dstqueue := make(chan []storage.Item)
|
dstqueue := make(chan []storage.Item)
|
||||||
@@ -126,7 +118,6 @@ func (w *Worker) refresher(feeds []storage.Feed) {
|
|||||||
w.db.CreateItems(items)
|
w.db.CreateItems(items)
|
||||||
}
|
}
|
||||||
atomic.AddInt32(w.pending, -1)
|
atomic.AddInt32(w.pending, -1)
|
||||||
w.db.SyncSearch()
|
|
||||||
}
|
}
|
||||||
close(srcqueue)
|
close(srcqueue)
|
||||||
close(dstqueue)
|
close(dstqueue)
|
||||||
@@ -136,9 +127,16 @@ func (w *Worker) refresher(feeds []storage.Feed) {
|
|||||||
|
|
||||||
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
|
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
|
||||||
for feed := range srcqueue {
|
for feed := range srcqueue {
|
||||||
|
empty := ""
|
||||||
|
w.db.UpdateFeedState(feed.Id, storage.UpdateFeedStateParams{LastError: &empty})
|
||||||
|
|
||||||
items, err := listItems(feed, w.db)
|
items, err := listItems(feed, w.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.db.SetFeedError(feed.Id, err)
|
errMsg := err.Error()
|
||||||
|
w.db.UpdateFeedState(feed.Id, storage.UpdateFeedStateParams{LastError: &errMsg})
|
||||||
|
}
|
||||||
|
if len(items) > 0 && !feed.HasIcon {
|
||||||
|
w.FindFeedFavicon(feed)
|
||||||
}
|
}
|
||||||
dstqueue <- items
|
dstqueue <- items
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user