Files
box-box/internal/store/news.go

158 lines
3.9 KiB
Go
Raw Normal View History

2026-05-25 12:02:18 -04:00
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertNewsSource inserts or updates RSS/Atom feed metadata.
func (s *Store) UpsertNewsSource(src NewsSource) error {
updatedAt := src.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
_, err := s.db.Exec(`
INSERT INTO news_sources (
source, name, feed_url, category, enabled, fetched_at, expires_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source) DO UPDATE SET
name = excluded.name,
feed_url = excluded.feed_url,
category = excluded.category,
enabled = excluded.enabled,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
`,
src.Source,
src.Name,
src.FeedURL,
nullString(src.Category),
boolInt(src.Enabled),
nullableTime(src.FetchedAt),
nullableTime(src.ExpiresAt),
updatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert news source: %w", err)
}
return nil
}
// UpsertNewsItem inserts or updates a normalized feed item.
// read_at is never overwritten by a re-fetch.
// og_image_url and og_description preserve an existing value when the new one is empty.
2026-05-25 12:02:18 -04:00
func (s *Store) UpsertNewsItem(item NewsItem) error {
_, err := s.db.Exec(`
INSERT INTO news_items (
url, source, title, published_at, summary, category, fetched_at,
og_image_url, og_description
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2026-05-25 12:02:18 -04:00
ON CONFLICT(url) DO UPDATE SET
source = excluded.source,
title = excluded.title,
published_at = excluded.published_at,
summary = excluded.summary,
category = excluded.category,
fetched_at = excluded.fetched_at,
og_image_url = COALESCE(excluded.og_image_url, news_items.og_image_url),
og_description= COALESCE(excluded.og_description, news_items.og_description)
2026-05-25 12:02:18 -04:00
`,
item.URL,
item.Source,
item.Title,
nullableTime(item.PublishedAt),
nullString(item.Summary),
nullString(item.Category),
item.FetchedAt.Unix(),
nullString(item.OGImageURL),
nullString(item.OGDescription),
2026-05-25 12:02:18 -04:00
)
if err != nil {
return fmt.Errorf("upsert news item: %w", err)
}
return nil
}
// MarkNewsItemRead sets read_at to now for the given URL.
func (s *Store) MarkNewsItemRead(url string) error {
_, err := s.db.Exec(
`UPDATE news_items SET read_at = ? WHERE url = ?`,
time.Now().Unix(), url,
)
return err
}
2026-05-25 12:02:18 -04:00
// ListNewsItems returns newest cached news items, optionally filtered by source.
func (s *Store) ListNewsItems(limit int, source string) ([]NewsItem, error) {
if limit <= 0 || limit > 100 {
limit = 25
}
query := `
SELECT url, source, title, published_at, summary, category, fetched_at,
og_image_url, og_description, read_at
2026-05-25 12:02:18 -04:00
FROM news_items
`
var args []any
if source != "" {
query += ` WHERE source = ?`
args = append(args, source)
}
query += ` ORDER BY COALESCE(published_at, fetched_at) DESC, fetched_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NewsItem
for rows.Next() {
var item NewsItem
var published, readAt sql.NullInt64
var summary, category, ogImage, ogDesc sql.NullString
2026-05-25 12:02:18 -04:00
var fetched int64
if err := rows.Scan(
&item.URL,
&item.Source,
&item.Title,
&published,
&summary,
&category,
&fetched,
&ogImage,
&ogDesc,
&readAt,
2026-05-25 12:02:18 -04:00
); err != nil {
return nil, err
}
item.PublishedAt = nullTimePtr(published)
item.Summary = summary.String
item.Category = category.String
item.FetchedAt = time.Unix(fetched, 0).UTC()
item.OGImageURL = ogImage.String
item.OGDescription = ogDesc.String
item.ReadAt = nullTimePtr(readAt)
2026-05-25 12:02:18 -04:00
out = append(out, item)
}
return out, rows.Err()
}
func nullableTime(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return v.Unix()
}
func nullTimePtr(v sql.NullInt64) *time.Time {
if !v.Valid {
return nil
}
t := time.Unix(v.Int64, 0).UTC()
return &t
}