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