From 5c2d9bfc4c5b26910089c2ccda285dc55824bb32 Mon Sep 17 00:00:00 2001 From: nkanaev Date: Sat, 13 Jun 2026 14:46:34 +0100 Subject: [PATCH] ai: generate postgres package draft --- src/storage/postgres/feed.go | 133 ++++++++++++ src/storage/postgres/feedstate.go | 105 ++++++++++ src/storage/postgres/folder.go | 77 +++++++ src/storage/postgres/item.go | 332 ++++++++++++++++++++++++++++++ src/storage/postgres/migration.go | 142 +++++++++++++ src/storage/postgres/settings.go | 131 ++++++++++++ src/storage/postgres/storage.go | 34 +++ 7 files changed, 954 insertions(+) create mode 100644 src/storage/postgres/feed.go create mode 100644 src/storage/postgres/feedstate.go create mode 100644 src/storage/postgres/folder.go create mode 100644 src/storage/postgres/item.go create mode 100644 src/storage/postgres/migration.go create mode 100644 src/storage/postgres/settings.go create mode 100644 src/storage/postgres/storage.go diff --git a/src/storage/postgres/feed.go b/src/storage/postgres/feed.go new file mode 100644 index 0000000..327211e --- /dev/null +++ b/src/storage/postgres/feed.go @@ -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 +} diff --git a/src/storage/postgres/feedstate.go b/src/storage/postgres/feedstate.go new file mode 100644 index 0000000..c96acc5 --- /dev/null +++ b/src/storage/postgres/feedstate.go @@ -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 +} diff --git a/src/storage/postgres/folder.go b/src/storage/postgres/folder.go new file mode 100644 index 0000000..98e7c25 --- /dev/null +++ b/src/storage/postgres/folder.go @@ -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 +} diff --git a/src/storage/postgres/item.go b/src/storage/postgres/item.go new file mode 100644 index 0000000..a01838f --- /dev/null +++ b/src/storage/postgres/item.go @@ -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) + } +} diff --git a/src/storage/postgres/migration.go b/src/storage/postgres/migration.go new file mode 100644 index 0000000..e70966d --- /dev/null +++ b/src/storage/postgres/migration.go @@ -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 +} diff --git a/src/storage/postgres/settings.go b/src/storage/postgres/settings.go new file mode 100644 index 0000000..0fd6aaf --- /dev/null +++ b/src/storage/postgres/settings.go @@ -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 +} diff --git a/src/storage/postgres/storage.go b/src/storage/postgres/storage.go new file mode 100644 index 0000000..2d986ed --- /dev/null +++ b/src/storage/postgres/storage.go @@ -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() +}