mirror of
https://github.com/nkanaev/yarr.git
synced 2026-06-24 09:05:16 +00:00
ai: generate postgres package draft
This commit is contained in:
133
src/storage/postgres/feed.go
Normal file
133
src/storage/postgres/feed.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *PostgresStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
|
||||||
|
title := params.Title
|
||||||
|
if title == "" {
|
||||||
|
title = params.FeedLink
|
||||||
|
}
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
insert into feeds (title, description, link, feed_link, folder_id)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
on conflict (feed_link) do update set folder_id = $5
|
||||||
|
returning id`,
|
||||||
|
title,
|
||||||
|
params.Description,
|
||||||
|
params.Link,
|
||||||
|
params.FeedLink,
|
||||||
|
params.FolderID,
|
||||||
|
)
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &model.Feed{
|
||||||
|
Id: id,
|
||||||
|
Title: title,
|
||||||
|
Description: params.Description,
|
||||||
|
Link: params.Link,
|
||||||
|
FeedLink: params.FeedLink,
|
||||||
|
FolderId: params.FolderID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) DeleteFeed(feedId int64) bool {
|
||||||
|
result, err := s.db.Exec(`delete from feeds where id = $1`, feedId)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nrows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return nrows == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
update feeds set
|
||||||
|
title = coalesce($2, title),
|
||||||
|
feed_link = coalesce($3, feed_link),
|
||||||
|
folder_id = case when $4 then $5 else folder_id end,
|
||||||
|
icon = case when $6 then $7 else icon end
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
feedId,
|
||||||
|
params.Title,
|
||||||
|
params.FeedLink,
|
||||||
|
params.FolderID.Set,
|
||||||
|
params.FolderID.Value,
|
||||||
|
params.Icon.Set,
|
||||||
|
params.Icon.Value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) ListFeeds() []model.Feed {
|
||||||
|
result := make([]model.Feed, 0)
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
select id, folder_id, title, description, link, feed_link,
|
||||||
|
coalesce(length(icon), 0) > 0 as has_icon
|
||||||
|
from feeds
|
||||||
|
order by lower(title)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.Feed
|
||||||
|
err = rows.Scan(
|
||||||
|
&f.Id,
|
||||||
|
&f.FolderId,
|
||||||
|
&f.Title,
|
||||||
|
&f.Description,
|
||||||
|
&f.Link,
|
||||||
|
&f.FeedLink,
|
||||||
|
&f.HasIcon,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result = append(result, f)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) GetFeed(id int64) *model.Feed {
|
||||||
|
var f model.Feed
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
select
|
||||||
|
id, folder_id, title, link, feed_link,
|
||||||
|
icon, coalesce(length(icon), 0) > 0 as has_icon
|
||||||
|
from feeds where id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
|
||||||
|
&f.Icon, &f.HasIcon,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &f
|
||||||
|
}
|
||||||
105
src/storage/postgres/feedstate.go
Normal file
105
src/storage/postgres/feedstate.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *PostgresStorage) ListFeedStates() ([]model.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([]model.FeedState, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var state model.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 *PostgresStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
|
||||||
|
var state model.FeedState
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
select
|
||||||
|
feed_id
|
||||||
|
, last_refreshed
|
||||||
|
, last_error
|
||||||
|
, http_lmod
|
||||||
|
, http_etag
|
||||||
|
from feed_states where feed_id = $1
|
||||||
|
`, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) UpdateFeedState(feedID int64, params model.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 (
|
||||||
|
$1
|
||||||
|
, coalesce($2, '1970-01-01 00:00:00+00'::timestamptz)
|
||||||
|
, coalesce($3, '')
|
||||||
|
, coalesce($4, '')
|
||||||
|
, coalesce($5, '')
|
||||||
|
)
|
||||||
|
on conflict (feed_id) do update set
|
||||||
|
last_refreshed = coalesce($2, last_refreshed),
|
||||||
|
last_error = coalesce($3, last_error),
|
||||||
|
http_lmod = coalesce($4, http_lmod),
|
||||||
|
http_etag = coalesce($5, http_etag)
|
||||||
|
`,
|
||||||
|
feedID,
|
||||||
|
params.LastRefreshed,
|
||||||
|
params.LastError,
|
||||||
|
params.HTTPLastModified,
|
||||||
|
params.HTTPEtag,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
77
src/storage/postgres/folder.go
Normal file
77
src/storage/postgres/folder.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *PostgresStorage) CreateFolder(title string) *model.Folder {
|
||||||
|
expanded := true
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
insert into folders (title, is_expanded) values ($1, $2)
|
||||||
|
on conflict (title) do update set title = $1
|
||||||
|
returning id`,
|
||||||
|
title,
|
||||||
|
expanded,
|
||||||
|
)
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) DeleteFolder(folderId int64) bool {
|
||||||
|
_, err := s.db.Exec(`delete from folders where id = $1`, folderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
update folders set
|
||||||
|
title = coalesce($2, title),
|
||||||
|
is_expanded = coalesce($3, is_expanded)
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
folderId,
|
||||||
|
params.Title,
|
||||||
|
params.IsExpanded,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) ListFolders() []model.Folder {
|
||||||
|
result := make([]model.Folder, 0)
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
select id, title, is_expanded
|
||||||
|
from folders
|
||||||
|
order by lower(title)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.Folder
|
||||||
|
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result = append(result, f)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
332
src/storage/postgres/item.go
Normal file
332
src/storage/postgres/item.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaLinks model.MediaLinks
|
||||||
|
|
||||||
|
func (m *MediaLinks) Scan(src any) error {
|
||||||
|
switch data := src.(type) {
|
||||||
|
case []byte:
|
||||||
|
return json.Unmarshal(data, m)
|
||||||
|
case string:
|
||||||
|
return json.Unmarshal([]byte(data), m)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MediaLinks) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) CreateItems(items []model.Item) bool {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
slices.SortStableFunc(items, func(a, b model.Item) int {
|
||||||
|
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
|
||||||
|
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
|
||||||
|
return cmp.Compare(sa, sb)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
insert into items (
|
||||||
|
guid, feed_id, title, link, date,
|
||||||
|
content, media_links,
|
||||||
|
date_arrived, last_arrived, status
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7,
|
||||||
|
$8, $9, $10
|
||||||
|
)
|
||||||
|
on conflict (feed_id, guid) do update set
|
||||||
|
last_arrived = excluded.last_arrived`,
|
||||||
|
item.GUID,
|
||||||
|
item.FeedId,
|
||||||
|
item.Title,
|
||||||
|
item.Link,
|
||||||
|
item.Date,
|
||||||
|
item.Content,
|
||||||
|
MediaLinks(item.MediaLinks),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
model.UNREAD,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
if err = tx.Rollback(); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
|
||||||
|
cond := make([]string, 0)
|
||||||
|
args := make([]any, 0)
|
||||||
|
n := 0
|
||||||
|
|
||||||
|
next := func() int {
|
||||||
|
n++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.FolderID != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.feed_id in (select id from feeds where folder_id = $%d)", next()))
|
||||||
|
args = append(args, *filter.FolderID)
|
||||||
|
}
|
||||||
|
if filter.FeedID != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.feed_id = $%d", next()))
|
||||||
|
args = append(args, *filter.FeedID)
|
||||||
|
}
|
||||||
|
if filter.Status != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.status = $%d", next()))
|
||||||
|
args = append(args, *filter.Status)
|
||||||
|
}
|
||||||
|
if filter.Search != nil {
|
||||||
|
words := strings.Fields(*filter.Search)
|
||||||
|
terms := make([]string, len(words))
|
||||||
|
for idx, word := range words {
|
||||||
|
terms[idx] = word + ":*"
|
||||||
|
}
|
||||||
|
|
||||||
|
cond = append(cond, fmt.Sprintf(
|
||||||
|
"i.search @@ to_tsquery('english', $%d)", next(),
|
||||||
|
))
|
||||||
|
args = append(args, strings.Join(terms, " & "))
|
||||||
|
}
|
||||||
|
if filter.After != nil {
|
||||||
|
compare := ">"
|
||||||
|
if newestFirst {
|
||||||
|
compare = "<"
|
||||||
|
}
|
||||||
|
cond = append(cond, fmt.Sprintf(
|
||||||
|
"(i.date, i.id) %s (select date, id from items where id = $%d)",
|
||||||
|
compare, next(),
|
||||||
|
))
|
||||||
|
args = append(args, *filter.After)
|
||||||
|
}
|
||||||
|
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||||
|
placeholders := make([]string, len(*filter.IDs))
|
||||||
|
for i, id := range *filter.IDs {
|
||||||
|
placeholders[i] = fmt.Sprintf("$%d", next())
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
cond = append(cond, "i.id in ("+strings.Join(placeholders, ",")+")")
|
||||||
|
}
|
||||||
|
if filter.SinceID != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.id > $%d", next()))
|
||||||
|
args = append(args, filter.SinceID)
|
||||||
|
}
|
||||||
|
if filter.MaxID != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.id < $%d", next()))
|
||||||
|
args = append(args, filter.MaxID)
|
||||||
|
}
|
||||||
|
if filter.Before != nil {
|
||||||
|
cond = append(cond, fmt.Sprintf("i.date < $%d", next()))
|
||||||
|
args = append(args, filter.Before)
|
||||||
|
}
|
||||||
|
|
||||||
|
predicate := "1"
|
||||||
|
if len(cond) > 0 {
|
||||||
|
predicate = strings.Join(cond, " and ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return predicate, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) CountItems() int {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) ListItems(
|
||||||
|
filter model.ItemFilter,
|
||||||
|
limit int,
|
||||||
|
newestFirst bool,
|
||||||
|
withContent bool,
|
||||||
|
) []model.Item {
|
||||||
|
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||||
|
result := make([]model.Item, 0)
|
||||||
|
|
||||||
|
order := "date desc, id desc"
|
||||||
|
if !newestFirst {
|
||||||
|
order = "date asc, id asc"
|
||||||
|
}
|
||||||
|
if filter.IDs != nil || filter.SinceID != nil {
|
||||||
|
order = "i.id asc"
|
||||||
|
}
|
||||||
|
if filter.MaxID != nil {
|
||||||
|
order = "i.id desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
|
||||||
|
if withContent {
|
||||||
|
selectCols += ", i.content"
|
||||||
|
} else {
|
||||||
|
selectCols += ", '' as content"
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
select %s
|
||||||
|
from items i
|
||||||
|
where %s
|
||||||
|
order by %s
|
||||||
|
limit %d
|
||||||
|
`, selectCols, predicate, order, limit)
|
||||||
|
rows, err := s.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var x model.Item
|
||||||
|
err = rows.Scan(
|
||||||
|
&x.Id, &x.GUID, &x.FeedId,
|
||||||
|
&x.Title, &x.Link, &x.Date,
|
||||||
|
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result = append(result, x)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) GetItem(id int64) *model.Item {
|
||||||
|
i := &model.Item{}
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
select
|
||||||
|
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||||
|
i.date, i.status, i.media_links
|
||||||
|
from items i
|
||||||
|
where i.id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||||
|
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
|
||||||
|
_, err := s.db.Exec(`update items set status = $2 where id = $1`,
|
||||||
|
item_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) MarkItemsRead(filter model.MarkFilter) bool {
|
||||||
|
predicate, args := listQueryPredicate(model.ItemFilter{
|
||||||
|
FolderID: filter.FolderID,
|
||||||
|
FeedID: filter.FeedID,
|
||||||
|
Before: filter.Before,
|
||||||
|
}, false)
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
update items as i set status = %d
|
||||||
|
where %s and i.status != %d
|
||||||
|
`, model.READ, predicate, model.STARRED)
|
||||||
|
_, err := s.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) FeedStats() []model.FeedStat {
|
||||||
|
result := make([]model.FeedStat, 0)
|
||||||
|
rows, err := s.db.Query(fmt.Sprintf(`
|
||||||
|
select
|
||||||
|
feed_id,
|
||||||
|
sum(case status when %d then 1 else 0 end),
|
||||||
|
sum(case status when %d then 1 else 0 end)
|
||||||
|
from items
|
||||||
|
group by feed_id
|
||||||
|
`, model.UNREAD, model.STARRED))
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
stat := model.FeedStat{}
|
||||||
|
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
|
||||||
|
result = append(result, stat)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
itemsKeepSize = 50
|
||||||
|
itemsKeepDays = 90
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *PostgresStorage) DeleteOldItems() {
|
||||||
|
keepDaysLimit := fmt.Sprintf("-%d days", itemsKeepDays)
|
||||||
|
result, err := s.db.Exec(`
|
||||||
|
delete from items
|
||||||
|
where id in (
|
||||||
|
select id
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
row_number() over (partition by feed_id order by date desc) as rn,
|
||||||
|
last_arrived,
|
||||||
|
max(last_arrived) over (partition by feed_id) as max_la
|
||||||
|
from items
|
||||||
|
where status != $1
|
||||||
|
) sub
|
||||||
|
where rn > $2
|
||||||
|
and last_arrived < max_la + $3::interval
|
||||||
|
)`,
|
||||||
|
model.STARRED,
|
||||||
|
itemsKeepSize,
|
||||||
|
keepDaysLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
numDeleted, err := result.RowsAffected()
|
||||||
|
if err == nil && numDeleted > 0 {
|
||||||
|
log.Printf("Deleted %d old items", numDeleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/storage/postgres/migration.go
Normal file
142
src/storage/postgres/migration.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrations = []func(*sql.Tx) error{
|
||||||
|
m01_initial,
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxVersion = int64(len(migrations))
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) error {
|
||||||
|
var version int64
|
||||||
|
err := db.QueryRow(
|
||||||
|
`select coalesce(max(version), 0) from schema_version`,
|
||||||
|
).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if version >= maxVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("db version is %d. migrating to %d", version, maxVersion)
|
||||||
|
|
||||||
|
for v := version + 1; v <= maxVersion; v++ {
|
||||||
|
log.Printf("[migration:%d] starting", v)
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrations[v-1](tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`insert into schema_version (version) values ($1)
|
||||||
|
on conflict do nothing`, v,
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[migration:%d] done", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func m01_initial(tx *sql.Tx) error {
|
||||||
|
stmts := []string{
|
||||||
|
`create table if not exists schema_version (
|
||||||
|
version bigint primary key
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`create table if not exists folders (
|
||||||
|
id bigserial primary key,
|
||||||
|
title text not null,
|
||||||
|
is_expanded boolean not null default false
|
||||||
|
)`,
|
||||||
|
`create unique index if not exists idx_folder_title on folders(title)`,
|
||||||
|
|
||||||
|
`create table if not exists feeds (
|
||||||
|
id bigserial primary key,
|
||||||
|
folder_id bigint references folders(id) on delete set null,
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
link text,
|
||||||
|
feed_link text not null,
|
||||||
|
icon bytea
|
||||||
|
)`,
|
||||||
|
`create index if not exists idx_feed_folder_id on feeds(folder_id)`,
|
||||||
|
`create unique index if not exists idx_feed_feed_link on feeds(feed_link)`,
|
||||||
|
|
||||||
|
`create table if not exists items (
|
||||||
|
id bigserial primary key,
|
||||||
|
guid text not null,
|
||||||
|
feed_id bigint not null references feeds(id) on delete cascade,
|
||||||
|
title text,
|
||||||
|
link text,
|
||||||
|
content text,
|
||||||
|
date timestamptz,
|
||||||
|
date_arrived timestamptz,
|
||||||
|
last_arrived timestamptz,
|
||||||
|
status integer,
|
||||||
|
media_links jsonb
|
||||||
|
)`,
|
||||||
|
`create index if not exists idx_item_feed_id on items(feed_id)`,
|
||||||
|
`create index if not exists idx_item__date_id_status on items(date, id, status)`,
|
||||||
|
`create unique index if not exists idx_item_guid on items(feed_id, guid)`,
|
||||||
|
|
||||||
|
`alter table items add column if not exists search tsvector`,
|
||||||
|
`create index if not exists idx_item_search on items using gin(search)`,
|
||||||
|
|
||||||
|
`create or replace function items_search_update() returns trigger as $$
|
||||||
|
begin
|
||||||
|
new.search := to_tsvector('english',
|
||||||
|
coalesce(new.title, '') || ' ' ||
|
||||||
|
coalesce(regexp_replace(new.content, '<[^>]+>', '', 'g'), '')
|
||||||
|
);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql`,
|
||||||
|
|
||||||
|
`create trigger if not exists trg_items_search_insert
|
||||||
|
before insert on items
|
||||||
|
for each row execute function items_search_update()`,
|
||||||
|
|
||||||
|
`create trigger if not exists trg_items_search_update
|
||||||
|
before update of title, content on items
|
||||||
|
for each row execute function items_search_update()`,
|
||||||
|
|
||||||
|
`create table if not exists settings (
|
||||||
|
key text primary key,
|
||||||
|
val jsonb
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`create table if not exists feed_states (
|
||||||
|
feed_id bigint primary key references feeds(id) on delete cascade,
|
||||||
|
last_refreshed timestamptz not null default '1970-01-01 00:00:00+00',
|
||||||
|
last_error text not null default '',
|
||||||
|
http_lmod text not null default '',
|
||||||
|
http_etag text not null default ''
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
131
src/storage/postgres/settings.go
Normal file
131
src/storage/postgres/settings.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func settingsDefaults() model.Settings {
|
||||||
|
return model.Settings{
|
||||||
|
Filter: "",
|
||||||
|
Feed: "",
|
||||||
|
FeedListWidth: 300,
|
||||||
|
ItemListWidth: 300,
|
||||||
|
SortNewestFirst: true,
|
||||||
|
ThemeName: "light",
|
||||||
|
ThemeFont: "",
|
||||||
|
ThemeSize: 1,
|
||||||
|
RefreshRate: 0,
|
||||||
|
Language: "en",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) GetSettings() model.Settings {
|
||||||
|
result := settingsDefaults()
|
||||||
|
rows, err := s.db.Query(`select key, val from settings;`)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var val []byte
|
||||||
|
rows.Scan(&key, &val)
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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 ($1, $2)
|
||||||
|
on conflict (key) do update set val = $2`,
|
||||||
|
key,
|
||||||
|
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 {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
34
src/storage/postgres/storage.go
Normal file
34
src/storage/postgres/storage.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PostgresStorage struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(connStr string) (*PostgresStorage, error) {
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Print("connected to postgres")
|
||||||
|
return &PostgresStorage{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStorage) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user