From c7e0deb1d068d971c8065fec2b4c52f6517e74d3 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:06:24 -0300 Subject: [PATCH 01/15] feat: add refresh_interval column to feeds table --- .../internal/store/migrations/003_feed_refresh_interval.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backend/internal/store/migrations/003_feed_refresh_interval.sql diff --git a/backend/internal/store/migrations/003_feed_refresh_interval.sql b/backend/internal/store/migrations/003_feed_refresh_interval.sql new file mode 100644 index 00000000..b42f6916 --- /dev/null +++ b/backend/internal/store/migrations/003_feed_refresh_interval.sql @@ -0,0 +1,3 @@ +-- Add per-feed refresh interval override. + +ALTER TABLE feeds ADD COLUMN refresh_interval INTEGER DEFAULT NULL; From 3732f593febbfd024117eddb1d4b93d622f344c8 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:06:27 -0300 Subject: [PATCH 02/15] feat: add RefreshInterval field to Feed model --- backend/internal/model/model.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 4579a65c..ef6dc541 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -16,7 +16,8 @@ type Feed struct { Link string `json:"link"` SiteURL string `json:"site_url,omitempty"` Suspended bool `json:"suspended"` - Proxy string `json:"proxy,omitempty"` + Proxy string `json:"proxy,omitempty"` + RefreshInterval *int64 `json:"refresh_interval,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` From 2f94b38153c03823f726cbbe6546ac989a14a7ab Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:11:00 -0300 Subject: [PATCH 03/15] feat: add refresh_interval to feed store queries --- backend/internal/handler/fever_test.go | 14 ++++---- backend/internal/pull/puller_test.go | 6 ++-- backend/internal/store/feed.go | 45 ++++++++++++++++++-------- backend/internal/store/feed_test.go | 2 +- backend/internal/store/store_test.go | 2 +- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/backend/internal/handler/fever_test.go b/backend/internal/handler/fever_test.go index 233d13a0..35fb2f48 100644 --- a/backend/internal/handler/fever_test.go +++ b/backend/internal/handler/fever_test.go @@ -105,7 +105,7 @@ func TestFeverReadAndMarkFlows(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "") + feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } @@ -227,7 +227,7 @@ func TestFeverMarkSavedLinksExistingBookmarkToItem(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "") + feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } @@ -306,7 +306,7 @@ func TestFeverFeedsIncludesFeedsGroups(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", ""); err != nil { + if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil); err != nil { t.Fatalf("create feed: %v", err) } @@ -345,7 +345,7 @@ func TestFeverItemsWithMaxIDZeroReturnsRecentItems(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "") + feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } @@ -408,7 +408,7 @@ func TestFeverFaviconsHaveDataURI(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", ""); err != nil { + if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil); err != nil { t.Fatalf("create feed: %v", err) } @@ -457,7 +457,7 @@ func TestFeverMarkFeedReadRespectsBefore(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "") + feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } @@ -516,7 +516,7 @@ func TestFeverMarkReadSupportsCSVItemIDs(t *testing.T) { if err != nil { t.Fatalf("create group: %v", err) } - feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "") + feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } diff --git a/backend/internal/pull/puller_test.go b/backend/internal/pull/puller_test.go index cc55aa78..d26bfa48 100644 --- a/backend/internal/pull/puller_test.go +++ b/backend/internal/pull/puller_test.go @@ -42,7 +42,7 @@ func TestRefreshFeedPreservesValidatorsWhen304OmitHeaders(t *testing.T) { })) defer server.Close() - feed, err := st.CreateFeed(1, "Feed A", server.URL, "", "") + feed, err := st.CreateFeed(1, "Feed A", server.URL, "", "", nil) if err != nil { t.Fatalf("create feed: %v", err) } @@ -108,10 +108,10 @@ func TestRefreshAllWaitsForRunningJobs(t *testing.T) { })) defer server.Close() - if _, err := st.CreateFeed(1, "Feed A", server.URL+"/a", "", ""); err != nil { + if _, err := st.CreateFeed(1, "Feed A", server.URL+"/a", "", "", nil); err != nil { t.Fatalf("create feed A: %v", err) } - if _, err := st.CreateFeed(1, "Feed B", server.URL+"/b", "", ""); err != nil { + if _, err := st.CreateFeed(1, "Feed B", server.URL+"/b", "", "", nil); err != nil { t.Fatalf("create feed B: %v", err) } diff --git a/backend/internal/store/feed.go b/backend/internal/store/feed.go index 7155a54f..22c4c90b 100644 --- a/backend/internal/store/feed.go +++ b/backend/internal/store/feed.go @@ -13,7 +13,7 @@ import ( func (s *Store) ListFeeds() ([]*model.Feed, error) { rows, err := s.db.Query(` SELECT f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, COALESCE(fs.etag, ''), COALESCE(fs.last_modified, ''), COALESCE(fs.cache_control, ''), COALESCE(fs.expires_at, 0), COALESCE(fs.last_checked_at, 0), COALESCE(fs.next_check_at, 0), COALESCE(fs.last_http_status, 0), COALESCE(fs.retry_after_until, 0), COALESCE(fs.last_success_at, 0), @@ -24,7 +24,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { LEFT JOIN feed_fetch_state fs ON fs.feed_id = f.id LEFT JOIN items i ON i.feed_id = f.id GROUP BY f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, fs.etag, fs.last_modified, fs.cache_control, fs.expires_at, fs.last_checked_at, fs.next_check_at, fs.last_http_status, fs.retry_after_until, fs.last_success_at, fs.last_error_at, fs.last_error, fs.consecutive_failures @@ -39,6 +39,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { for rows.Next() { f := &model.Feed{} var suspended int + var refreshIntervalNull sql.NullInt64 if err := rows.Scan( &f.ID, &f.GroupID, @@ -47,6 +48,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { &f.SiteURL, &suspended, &f.Proxy, + &refreshIntervalNull, &f.CreatedAt, &f.UpdatedAt, &f.FetchState.ETag, @@ -67,6 +69,9 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { return nil, err } f.Suspended = intToBool(suspended) + if refreshIntervalNull.Valid { + f.RefreshInterval = &refreshIntervalNull.Int64 + } feeds = append(feeds, f) } return feeds, rows.Err() @@ -75,9 +80,10 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { func (s *Store) GetFeed(id int64) (*model.Feed, error) { f := &model.Feed{} var suspended int + var refreshIntervalNull sql.NullInt64 err := s.db.QueryRow(` SELECT f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, COALESCE(fs.etag, ''), COALESCE(fs.last_modified, ''), COALESCE(fs.cache_control, ''), COALESCE(fs.expires_at, 0), COALESCE(fs.last_checked_at, 0), COALESCE(fs.next_check_at, 0), COALESCE(fs.last_http_status, 0), COALESCE(fs.retry_after_until, 0), COALESCE(fs.last_success_at, 0), @@ -93,6 +99,7 @@ func (s *Store) GetFeed(id int64) (*model.Feed, error) { &f.SiteURL, &suspended, &f.Proxy, + &refreshIntervalNull, &f.CreatedAt, &f.UpdatedAt, &f.FetchState.ETag, @@ -116,10 +123,13 @@ func (s *Store) GetFeed(id int64) (*model.Feed, error) { } f.Suspended = intToBool(suspended) + if refreshIntervalNull.Valid { + f.RefreshInterval = &refreshIntervalNull.Int64 + } return f, nil } -func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string) (*model.Feed, error) { +func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string, refreshInterval *int64) (*model.Feed, error) { tx, err := s.db.Begin() if err != nil { return nil, err @@ -127,10 +137,10 @@ func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string) (*m defer tx.Rollback() result, err := tx.Exec(` - INSERT INTO feeds (group_id, name, link, site_url, proxy) - VALUES (:group_id, :name, :link, :site_url, :proxy) + INSERT INTO feeds (group_id, name, link, site_url, proxy, refresh_interval) + VALUES (:group_id, :name, :link, :site_url, :proxy, :refresh_interval) `, sql.Named("group_id", groupID), sql.Named("name", name), sql.Named("link", link), - sql.Named("site_url", siteURL), sql.Named("proxy", proxy)) + sql.Named("site_url", siteURL), sql.Named("proxy", proxy), sql.Named("refresh_interval", refreshInterval)) if err != nil { return nil, err } @@ -187,12 +197,13 @@ func (s *Store) SearchFeeds(query string) ([]*SearchFeedResult, error) { // UpdateFeedParams supports partial updates. Only non-nil fields will be updated. // Pointer fields distinguish between "not set" (nil) and "set to zero value" (e.g., &false). type UpdateFeedParams struct { - GroupID *int64 - Name *string - Link *string - SiteURL *string - Suspended *bool - Proxy *string + GroupID *int64 + Name *string + Link *string + SiteURL *string + Suspended *bool + Proxy *string + RefreshInterval *int64 } // UpdateFeed performs partial update of feed fields using a single dynamic UPDATE query. @@ -224,6 +235,14 @@ func (s *Store) UpdateFeed(id int64, params UpdateFeedParams) error { setClauses = append(setClauses, "proxy = :proxy") args = append(args, sql.Named("proxy", *params.Proxy)) } + if params.RefreshInterval != nil { + if *params.RefreshInterval == -1 { + setClauses = append(setClauses, "refresh_interval = NULL") + } else { + setClauses = append(setClauses, "refresh_interval = :refresh_interval") + args = append(args, sql.Named("refresh_interval", *params.RefreshInterval)) + } + } if len(setClauses) == 0 { return nil diff --git a/backend/internal/store/feed_test.go b/backend/internal/store/feed_test.go index 5875e7e4..335a0b40 100644 --- a/backend/internal/store/feed_test.go +++ b/backend/internal/store/feed_test.go @@ -101,7 +101,7 @@ func TestCreateFeed(t *testing.T) { } // Test UNIQUE constraint on link - _, err := store.CreateFeed(group.ID, "Duplicate", link, siteURL, "") + _, err := store.CreateFeed(group.ID, "Duplicate", link, siteURL, "", nil) if err == nil { t.Error("expected error when creating duplicate feed link, got nil") } diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 763526e9..1e447fb9 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -42,7 +42,7 @@ func mustCreateGroup(t *testing.T, store *Store, name string) *model.Group { func mustCreateFeed(t *testing.T, store *Store, groupID int64, name, link, siteURL, proxy string) *model.Feed { t.Helper() - feed, err := store.CreateFeed(groupID, name, link, siteURL, proxy) + feed, err := store.CreateFeed(groupID, name, link, siteURL, proxy, nil) if err != nil { t.Fatalf("CreateFeed() failed: %v", err) } From c894c9da65b4991d2b284e1d3a215f80584f9551 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:14:43 -0300 Subject: [PATCH 04/15] feat: add refresh_interval to feed API and app info endpoint --- backend/internal/handler/feed.go | 73 ++++++++++++++++++++++++----- backend/internal/handler/handler.go | 2 + 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/backend/internal/handler/feed.go b/backend/internal/handler/feed.go index ccb3d5b4..019695ad 100644 --- a/backend/internal/handler/feed.go +++ b/backend/internal/handler/feed.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "fmt" "log/slog" "net/http" "strconv" @@ -17,20 +18,22 @@ import ( ) type createFeedRequest struct { - GroupID int64 `json:"group_id" binding:"required"` - Name string `json:"name" binding:"required"` - Link string `json:"link" binding:"required"` - SiteURL string `json:"site_url"` - Proxy string `json:"proxy"` + GroupID int64 `json:"group_id" binding:"required"` + Name string `json:"name" binding:"required"` + Link string `json:"link" binding:"required"` + SiteURL string `json:"site_url"` + Proxy string `json:"proxy"` + RefreshInterval *int64 `json:"refresh_interval"` } type updateFeedRequest struct { - GroupID *int64 `json:"group_id"` - Name *string `json:"name"` - Link *string `json:"link"` - SiteURL *string `json:"site_url"` - Suspended *bool `json:"suspended"` - Proxy *string `json:"proxy"` // Empty string clears proxy + GroupID *int64 `json:"group_id"` + Name *string `json:"name"` + Link *string `json:"link"` + SiteURL *string `json:"site_url"` + Suspended *bool `json:"suspended"` + Proxy *string `json:"proxy"` + RefreshInterval *int64 `json:"refresh_interval"` } type validateFeedRequest struct { @@ -46,6 +49,10 @@ type validateFeedResponse struct { Feeds []discoveredFeed `json:"feeds"` } +type appInfoResponse struct { + PullInterval int `json:"pull_interval"` +} + type batchCreateFeedsRequest struct { Feeds []batchCreateFeedItem `json:"feeds" binding:"required"` } @@ -57,6 +64,32 @@ type batchCreateFeedItem struct { SiteURL string `json:"site_url"` } +var allowedRefreshIntervals = map[int64]bool{ + 900: true, + 1800: true, + 3600: true, + 7200: true, + 21600: true, + 43200: true, + 86400: true, +} + +func validateRefreshInterval(v *int64) error { + if v == nil { + return nil + } + if !allowedRefreshIntervals[*v] { + return fmt.Errorf("invalid refresh_interval: must be one of 900, 1800, 3600, 7200, 21600, 43200, 86400") + } + return nil +} + +func (h *Handler) getAppInfo(c *gin.Context) { + dataResponse(c, appInfoResponse{ + PullInterval: h.config.PullInterval, + }) +} + const refreshAllTimeout = 30 * time.Minute func (h *Handler) listFeeds(c *gin.Context) { @@ -99,8 +132,12 @@ func (h *Handler) createFeed(c *gin.Context) { badRequestError(c, "invalid link") return } + if err := validateRefreshInterval(req.RefreshInterval); err != nil { + badRequestError(c, err.Error()) + return + } - feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy) + feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy, req.RefreshInterval) if err != nil { internalError(c, err, "create feed") return @@ -131,6 +168,10 @@ func (h *Handler) updateFeed(c *gin.Context) { badRequestError(c, "invalid request") return } + if err := validateRefreshInterval(req.RefreshInterval); err != nil { + badRequestError(c, err.Error()) + return + } params := store.UpdateFeedParams{} if req.GroupID != nil { @@ -155,6 +196,14 @@ func (h *Handler) updateFeed(c *gin.Context) { if req.Proxy != nil { params.Proxy = req.Proxy } + if req.RefreshInterval != nil { + if *req.RefreshInterval == 0 { + nilInterval := int64(-1) + params.RefreshInterval = &nilInterval + } else { + params.RefreshInterval = req.RefreshInterval + } + } if err := h.store.UpdateFeed(id, params); err != nil { if errors.Is(err, store.ErrNotFound) { diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 9da690c7..b92ef115 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -130,6 +130,8 @@ func (h *Handler) SetupRouter() *gin.Engine { auth.POST("/feeds/validate", h.validateFeed) auth.POST("/feeds/:id/refresh", h.refreshFeed) + auth.GET("/app", h.getAppInfo) + auth.GET("/items", h.listItems) auth.GET("/items/:id", h.getItem) auth.PATCH("/items/-/read", h.markItemsRead) From 94c5debc134d4d1a3b1b4f1385e4f697f79bc55d Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:17:17 -0300 Subject: [PATCH 05/15] feat: use per-feed refresh interval in puller scheduling --- backend/internal/pull/puller.go | 47 ++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/internal/pull/puller.go b/backend/internal/pull/puller.go index ea47a0a5..c5c2db06 100644 --- a/backend/internal/pull/puller.go +++ b/backend/internal/pull/puller.go @@ -25,6 +25,13 @@ type Puller struct { concurrency *semaphore.Weighted } +func (p *Puller) effectiveInterval(feed *model.Feed) time.Duration { + if feed.RefreshInterval != nil && *feed.RefreshInterval > 0 { + return time.Duration(*feed.RefreshInterval) * time.Second + } + return p.interval +} + func New(st *store.Store, cfg *config.Config) *Puller { return &Puller{ store: st, @@ -41,10 +48,10 @@ func New(st *store.Store, cfg *config.Config) *Puller { func (p *Puller) Start(ctx context.Context) error { p.logger.Info("pull service started", "interval", p.interval, "timeout", p.timeout, "concurrency", p.config.PullConcurrency) - // Run immediately on startup p.pullAll(ctx) - ticker := time.NewTicker(p.interval) + tickerInterval := p.interval + ticker := time.NewTicker(tickerInterval) defer ticker.Stop() for { @@ -55,7 +62,34 @@ func (p *Puller) Start(ctx context.Context) error { case <-ticker.C: p.pullAll(ctx) } + + newInterval := p.computeMinInterval() + if newInterval != tickerInterval { + tickerInterval = newInterval + ticker.Reset(tickerInterval) + p.logger.Info("adjusted pull ticker", "interval", tickerInterval) + } + } +} + +func (p *Puller) computeMinInterval() time.Duration { + feeds, err := p.store.ListFeeds() + if err != nil { + p.logger.Error("failed to list feeds for ticker computation", "error", err) + return p.interval + } + + minInterval := p.interval + for _, feed := range feeds { + if feed.Suspended { + continue + } + effective := p.effectiveInterval(feed) + if effective < minInterval { + minInterval = effective + } } + return minInterval } // pullAll fetches all feeds concurrently with semaphore limiting. @@ -76,7 +110,8 @@ func (p *Puller) pullAll(ctx context.Context) { LastErrorAt: feed.FetchState.LastErrorAt, LastCheckedAt: feed.FetchState.LastCheckedAt, } - return !pullpolicy.ShouldSkip(now, state, p.interval, p.maxBackoff) + interval := p.effectiveInterval(feed) + return !pullpolicy.ShouldSkip(now, state, interval, p.maxBackoff) }) } @@ -99,7 +134,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) { HTTPStatus: httpStatus, LastError: err.Error(), RetryAfterUntil: retryAfterUntil, - IntervalSeconds: int64(p.interval.Seconds()), + IntervalSeconds: int64(p.effectiveInterval(feed).Seconds()), MaxBackoff: int64(p.maxBackoff.Seconds()), }); err != nil { p.logger.Error("failed to record failure", "feed_id", feed.ID, "error", err) @@ -134,7 +169,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) { nextCheckAt := pullpolicy.ComputeNextCheckAt( checkedAt, - p.interval, + p.effectiveInterval(feed), p.maxBackoff, 0, result.RetryAfterUntil, @@ -162,7 +197,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) { nextCheckAt := pullpolicy.ComputeNextCheckAt( checkedAt, - p.interval, + p.effectiveInterval(feed), p.maxBackoff, 0, result.RetryAfterUntil, From 88fdb734cf431625aab7313e63f42e8493078251 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:21:31 -0300 Subject: [PATCH 06/15] fix: allow refresh_interval=0 in validation to revert to default --- backend/internal/handler/feed.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/handler/feed.go b/backend/internal/handler/feed.go index 019695ad..e7a5feac 100644 --- a/backend/internal/handler/feed.go +++ b/backend/internal/handler/feed.go @@ -75,7 +75,7 @@ var allowedRefreshIntervals = map[int64]bool{ } func validateRefreshInterval(v *int64) error { - if v == nil { + if v == nil || *v == 0 { return nil } if !allowedRefreshIntervals[*v] { From d5c1de687816cdf8c90d16561e6fa85899b09d0d Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:23:14 -0300 Subject: [PATCH 07/15] feat: add refresh_interval to frontend API types and app info endpoint --- frontend/src/lib/api/index.ts | 6 ++++++ frontend/src/lib/api/types.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index d2321b17..fd5da4f3 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -22,6 +22,7 @@ import type { SearchResponse, OIDCStatusResponse, OIDCLoginResponse, + AppInfoResponse, } from "./types"; // Session APIs @@ -130,6 +131,11 @@ export const searchAPI = { ), }; +// App APIs +export const appAPI = { + getInfo: () => api.get>("/app"), +}; + // OPML APIs const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api"; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index fe36b794..11d4245e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -14,6 +14,7 @@ export interface Feed { site_url?: string; suspended: boolean; proxy?: string; + refresh_interval?: number | null; created_at: number; updated_at: number; fetch_state: FeedFetchState; @@ -88,9 +89,11 @@ export interface CreateFeedRequest { name: string; link: string; site_url?: string; - proxy?: string; +proxy?: string; + refresh_interval?: number | null; } + export interface UpdateFeedRequest { group_id?: number; name?: string; @@ -98,6 +101,7 @@ export interface UpdateFeedRequest { site_url?: string; suspended?: boolean; proxy?: string; + refresh_interval?: number | null; } export interface ValidateFeedRequest { @@ -175,6 +179,10 @@ export interface BatchCreateFeedsResponse { errors?: string[]; } +export interface AppInfoResponse { + pull_interval: number; +} + // OIDC export interface OIDCStatusResponse { enabled: boolean; From 5b2e357102e746ff45a18f5052871af0ee807bef Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:23:16 -0300 Subject: [PATCH 08/15] feat: add i18n keys for refresh frequency --- frontend/src/lib/i18n/messages/en.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/lib/i18n/messages/en.ts b/frontend/src/lib/i18n/messages/en.ts index cd056482..4f677566 100644 --- a/frontend/src/lib/i18n/messages/en.ts +++ b/frontend/src/lib/i18n/messages/en.ts @@ -97,6 +97,15 @@ export const enMessages = { "feed.add.title": "Add Feed", "feed.add.urlLabel": "Feed URL", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Refresh Frequency", + "feed.add.refreshFrequencyDefault": "Default ({interval})", + "feed.add.refreshFrequency.900": "Every 15 minutes", + "feed.add.refreshFrequency.1800": "Every 30 minutes", + "feed.add.refreshFrequency.3600": "Every 1 hour", + "feed.add.refreshFrequency.7200": "Every 2 hours", + "feed.add.refreshFrequency.21600": "Every 6 hours", + "feed.add.refreshFrequency.43200": "Every 12 hours", + "feed.add.refreshFrequency.86400": "Every 24 hours", "feed.add.validateTitle": "Validate feed URL", "feed.edit.deleteConfirm.description": 'Are you sure you want to unsubscribe from "{name}"? All items from this feed will be deleted.', From 4258a6f5f69668ddb4ffb50053a089c202897399 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:27:02 -0300 Subject: [PATCH 09/15] feat: add refresh frequency dropdown to edit feed dialog --- .../src/components/feed/edit-feed-dialog.tsx | 99 +++++++++++++++---- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/feed/edit-feed-dialog.tsx b/frontend/src/components/feed/edit-feed-dialog.tsx index 8d2248f7..9212d9da 100644 --- a/frontend/src/components/feed/edit-feed-dialog.tsx +++ b/frontend/src/components/feed/edit-feed-dialog.tsx @@ -31,12 +31,19 @@ import { import { useUIStore } from "@/store"; import { useGroups } from "@/queries/groups"; import { useUpdateFeed, useDeleteFeed } from "@/queries/feeds"; -import type { UpdateFeedRequest } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { appAPI, type { UpdateFeedRequest } from "@/lib/api"; import { toast } from "sonner"; import { useI18n } from "@/lib/i18n"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; +function formatInterval(seconds: number): string { + if (seconds < 3600) return `${seconds / 60} min`; + if (seconds < 86400) return `${seconds / 3600}h`; + return `${seconds / 86400}d`; +} + export function EditFeedDialog() { const { t } = useI18n(); const { isEditFeedOpen, editingFeed, setEditFeedOpen } = useUIStore(); @@ -49,6 +56,7 @@ export function EditFeedDialog() { const [groupId, setGroupId] = useState(""); const [proxy, setProxy] = useState(""); const [suspended, setSuspended] = useState(false); + const [refreshInterval, setRefreshInterval] = useState("default"); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); @@ -58,6 +66,16 @@ export function EditFeedDialog() { const urlInputRef = useRef(null); const isMobile = useIsMobile(); + const { data: appInfo } = useQuery({ + queryKey: ["appInfo"], + queryFn: async () => { + const res = await appAPI.getInfo(); + return res.data; + }, + staleTime: Infinity, + }); + const globalPullInterval = appInfo?.pull_interval ?? 1800; + useEffect(() => { if (editingFeed) { setUrl(editingFeed.link); @@ -65,7 +83,12 @@ export function EditFeedDialog() { setGroupId(editingFeed.group_id.toString()); setProxy(editingFeed.proxy ?? ""); setSuspended(editingFeed.suspended); - setIsAdvancedOpen(!!editingFeed.proxy); + if (editingFeed.refresh_interval != null) { + setRefreshInterval(editingFeed.refresh_interval.toString()); + } else { + setRefreshInterval("default"); + } + setIsAdvancedOpen(!!editingFeed.proxy || editingFeed.refresh_interval != null); setIsMobileErrorTooltipOpen(false); } }, [editingFeed]); @@ -76,6 +99,7 @@ export function EditFeedDialog() { setGroupId(""); setProxy(""); setSuspended(false); + setRefreshInterval("default"); setIsAdvancedOpen(false); setIsDeleteOpen(false); }; @@ -125,6 +149,17 @@ export function EditFeedDialog() { request.proxy = newProxy; } + if (refreshInterval === "default") { + if (editingFeed.refresh_interval != null) { + request.refresh_interval = 0; + } + } else { + const newVal = parseInt(refreshInterval, 10); + if (editingFeed.refresh_interval !== newVal) { + request.refresh_interval = newVal; + } + } + if (Object.keys(request).length === 0) { toast.info(t("feed.edit.noChanges")); handleClose(); @@ -299,25 +334,47 @@ export function EditFeedDialog() { /> {t("feed.add.advanced")} - - - setProxy(e.target.value)} - className="h-10" - autoComplete="off" - spellCheck={false} - /> -

- {t("feed.add.proxyHint")} -

+ +
+ + +
+
+ + setProxy(e.target.value)} + className="h-10" + autoComplete="off" + spellCheck={false} + /> +

+ {t("feed.add.proxyHint")} +

+
From dca6086835df3b83d7b0a7b98587e1ae95d52fd8 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:31:46 -0300 Subject: [PATCH 10/15] fix: cast dynamic i18n key to TranslationKey in edit feed dialog --- frontend/src/components/feed/edit-feed-dialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/feed/edit-feed-dialog.tsx b/frontend/src/components/feed/edit-feed-dialog.tsx index 9212d9da..08683b93 100644 --- a/frontend/src/components/feed/edit-feed-dialog.tsx +++ b/frontend/src/components/feed/edit-feed-dialog.tsx @@ -32,9 +32,10 @@ import { useUIStore } from "@/store"; import { useGroups } from "@/queries/groups"; import { useUpdateFeed, useDeleteFeed } from "@/queries/feeds"; import { useQuery } from "@tanstack/react-query"; -import { appAPI, type { UpdateFeedRequest } from "@/lib/api"; +import { appAPI } from "@/lib/api"; +import type { UpdateFeedRequest } from "@/lib/api"; import { toast } from "sonner"; -import { useI18n } from "@/lib/i18n"; +import { useI18n, type TranslationKey } from "@/lib/i18n"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; @@ -349,7 +350,7 @@ export function EditFeedDialog() { {[900, 1800, 3600, 7200, 21600, 43200, 86400].map((seconds) => ( - {t(`feed.add.refreshFrequency.${seconds}`)} + {t(`feed.add.refreshFrequency.${seconds}` as TranslationKey)} ))} From d32570b7e20059b61e31753ef83cfc2906d43a59 Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 11:33:49 -0300 Subject: [PATCH 11/15] feat: add refresh frequency dropdown to add feed dialog --- .../src/components/feed/add-feed-dialog.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/feed/add-feed-dialog.tsx b/frontend/src/components/feed/add-feed-dialog.tsx index 13dbd6fa..290ea033 100644 --- a/frontend/src/components/feed/add-feed-dialog.tsx +++ b/frontend/src/components/feed/add-feed-dialog.tsx @@ -26,12 +26,14 @@ import { useGroups } from "@/queries/groups"; import { useCreateFeed } from "@/queries/feeds"; import { feedAPI, + appAPI, type CreateFeedRequest, type DiscoveredFeed, } from "@/lib/api"; import { toast } from "sonner"; -import { useI18n } from "@/lib/i18n"; +import { useI18n, type TranslationKey } from "@/lib/i18n"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; export function AddFeedDialog() { const { t } = useI18n(); @@ -39,10 +41,27 @@ export function AddFeedDialog() { const { data: groups = [] } = useGroups(); const createFeed = useCreateFeed(); + const { data: appInfo } = useQuery({ + queryKey: ["appInfo"], + queryFn: async () => { + const res = await appAPI.getInfo(); + return res.data; + }, + staleTime: Infinity, + }); + const globalPullInterval = appInfo?.pull_interval ?? 1800; + + function formatInterval(seconds: number): string { + if (seconds < 3600) return `${seconds / 60} min`; + if (seconds < 86400) return `${seconds / 3600}h`; + return `${seconds / 86400}d`; + } + const [url, setUrl] = useState(""); const [name, setName] = useState(""); const [groupId, setGroupId] = useState(""); const [proxy, setProxy] = useState(""); + const [refreshInterval, setRefreshInterval] = useState("default"); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isValidating, setIsValidating] = useState(false); @@ -54,6 +73,7 @@ export function AddFeedDialog() { setName(""); setGroupId(""); setProxy(""); + setRefreshInterval("default"); setIsAdvancedOpen(false); setDetectedFeeds([]); setIsFeedSelectOpen(false); @@ -126,6 +146,10 @@ export function AddFeedDialog() { request.proxy = proxy.trim(); } + if (refreshInterval !== "default") { + request.refresh_interval = parseInt(refreshInterval, 10); + } + await createFeed.mutateAsync(request); toast.success(t("feed.toast.added")); handleClose(); @@ -243,6 +267,26 @@ export function AddFeedDialog() { {t("feed.add.advanced")} +
+ + +
From 76c15519dbd94e2067ba0fbb8392a656b179244c Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 12:43:39 -0300 Subject: [PATCH 12/15] style: align Feed struct fields after goimports --- backend/internal/model/model.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index ef6dc541..a0460ed9 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -10,16 +10,16 @@ type Group struct { // Feed represents an RSS/Atom feed. type Feed struct { - ID int64 `json:"id"` - GroupID int64 `json:"group_id"` - Name string `json:"name"` - Link string `json:"link"` - SiteURL string `json:"site_url,omitempty"` - Suspended bool `json:"suspended"` + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + Name string `json:"name"` + Link string `json:"link"` + SiteURL string `json:"site_url,omitempty"` + Suspended bool `json:"suspended"` Proxy string `json:"proxy,omitempty"` RefreshInterval *int64 `json:"refresh_interval,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` FetchState FeedFetchState `json:"fetch_state"` From cc3dde6e7417c65bd82a5253251eb343da75728e Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 12:44:07 -0300 Subject: [PATCH 13/15] chore: add build artifacts to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ff699553..81a0c843 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ backend/internal/web/dist/* .env* # .env.example is an exception as it only contains dummy secrets. !.env.example +backend/fusion +frontend/package-lock.json From aaf887c41ee598d4464ed673947479e7c0b5b03b Mon Sep 17 00:00:00 2001 From: William Date: Mon, 20 Apr 2026 13:44:54 -0300 Subject: [PATCH 14/15] feat: add pt and de translations for per feed refresh frequncy feature --- frontend/src/lib/i18n/messages/de.ts | 9 +++++++++ frontend/src/lib/i18n/messages/pt.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/frontend/src/lib/i18n/messages/de.ts b/frontend/src/lib/i18n/messages/de.ts index 07462abc..91116b72 100644 --- a/frontend/src/lib/i18n/messages/de.ts +++ b/frontend/src/lib/i18n/messages/de.ts @@ -99,6 +99,15 @@ export const deMessages: PartialMessages = { "feed.add.title": "Feed hinzufugen", "feed.add.urlLabel": "Feed-URL", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Aktualisierungsfrequenz", + "feed.add.refreshFrequencyDefault": "Standard ({interval})", + "feed.add.refreshFrequency.900": "Alle 15 minutes", + "feed.add.refreshFrequency.1800": "Alle 30 minutes", + "feed.add.refreshFrequency.3600": "Alle 1 hour", + "feed.add.refreshFrequency.7200": "Alle 2 hours", + "feed.add.refreshFrequency.21600": "Alle 6 hours", + "feed.add.refreshFrequency.43200": "Alle 12 hours", + "feed.add.refreshFrequency.86400": "Alle 24 hours", "feed.add.validateTitle": "Feed-URL prufen", "feed.edit.deleteConfirm.description": 'Mochten Sie "{name}" wirklich abbestellen? Alle Artikel dieses Feeds werden geloscht.', diff --git a/frontend/src/lib/i18n/messages/pt.ts b/frontend/src/lib/i18n/messages/pt.ts index d32080fe..15ca7aea 100644 --- a/frontend/src/lib/i18n/messages/pt.ts +++ b/frontend/src/lib/i18n/messages/pt.ts @@ -99,6 +99,15 @@ export const ptMessages: PartialMessages = { "feed.add.title": "Adicionar feed", "feed.add.urlLabel": "URL do feed", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Frequência de atualização", + "feed.add.refreshFrequencyDefault": "Padrão ({interval})", + "feed.add.refreshFrequency.900": "A cada 15 minutos", + "feed.add.refreshFrequency.1800": "A cada 30 minutos", + "feed.add.refreshFrequency.3600": "A cada 1 hora", + "feed.add.refreshFrequency.7200": "A cada 2 horas", + "feed.add.refreshFrequency.21600": "A cada 6 horas", + "feed.add.refreshFrequency.43200": "A cada 12 horas", + "feed.add.refreshFrequency.86400": "A cada 24 horas", "feed.add.validateTitle": "Validar URL do feed", "feed.edit.deleteConfirm.description": 'Tem certeza de que deseja cancelar a inscricao de "{name}"? Todos os artigos deste feed serao excluidos.', From eef33538b336fdf0f43ac01f135087148f6db016 Mon Sep 17 00:00:00 2001 From: William Date: Wed, 2 Sep 2026 16:21:30 -0300 Subject: [PATCH 15/15] fix: address per-feed refresh frequency review feedback - rename refresh_interval to refresh_interval_seconds across backend, frontend API types, and the feeds table migration for clarity - reschedule feed when its refresh interval changes by resetting next_check_at so the new schedule takes effect immediately - fix German refresh frequency labels to use native time units - add missing zh/fr/es/ru/sv translations --- backend/internal/handler/feed.go | 42 ++++++------ backend/internal/model/model.go | 20 +++--- backend/internal/pull/puller.go | 4 +- backend/internal/store/feed.go | 64 +++++++++++-------- .../migrations/003_feed_refresh_interval.sql | 2 +- .../src/components/feed/add-feed-dialog.tsx | 2 +- .../src/components/feed/edit-feed-dialog.tsx | 14 ++-- frontend/src/lib/api/types.ts | 9 ++- frontend/src/lib/i18n/messages/de.ts | 14 ++-- frontend/src/lib/i18n/messages/es.ts | 9 +++ frontend/src/lib/i18n/messages/fr.ts | 9 +++ frontend/src/lib/i18n/messages/ru.ts | 9 +++ frontend/src/lib/i18n/messages/sv.ts | 9 +++ frontend/src/lib/i18n/messages/zh.ts | 9 +++ 14 files changed, 135 insertions(+), 81 deletions(-) diff --git a/backend/internal/handler/feed.go b/backend/internal/handler/feed.go index e7a5feac..22da08b5 100644 --- a/backend/internal/handler/feed.go +++ b/backend/internal/handler/feed.go @@ -18,22 +18,22 @@ import ( ) type createFeedRequest struct { - GroupID int64 `json:"group_id" binding:"required"` - Name string `json:"name" binding:"required"` - Link string `json:"link" binding:"required"` - SiteURL string `json:"site_url"` - Proxy string `json:"proxy"` - RefreshInterval *int64 `json:"refresh_interval"` + GroupID int64 `json:"group_id" binding:"required"` + Name string `json:"name" binding:"required"` + Link string `json:"link" binding:"required"` + SiteURL string `json:"site_url"` + Proxy string `json:"proxy"` + RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds"` } type updateFeedRequest struct { - GroupID *int64 `json:"group_id"` - Name *string `json:"name"` - Link *string `json:"link"` - SiteURL *string `json:"site_url"` - Suspended *bool `json:"suspended"` - Proxy *string `json:"proxy"` - RefreshInterval *int64 `json:"refresh_interval"` + GroupID *int64 `json:"group_id"` + Name *string `json:"name"` + Link *string `json:"link"` + SiteURL *string `json:"site_url"` + Suspended *bool `json:"suspended"` + Proxy *string `json:"proxy"` + RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds"` } type validateFeedRequest struct { @@ -79,7 +79,7 @@ func validateRefreshInterval(v *int64) error { return nil } if !allowedRefreshIntervals[*v] { - return fmt.Errorf("invalid refresh_interval: must be one of 900, 1800, 3600, 7200, 21600, 43200, 86400") + return fmt.Errorf("invalid refresh_interval_seconds: must be one of 900, 1800, 3600, 7200, 21600, 43200, 86400") } return nil } @@ -132,12 +132,12 @@ func (h *Handler) createFeed(c *gin.Context) { badRequestError(c, "invalid link") return } - if err := validateRefreshInterval(req.RefreshInterval); err != nil { + if err := validateRefreshInterval(req.RefreshIntervalSeconds); err != nil { badRequestError(c, err.Error()) return } - feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy, req.RefreshInterval) + feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy, req.RefreshIntervalSeconds) if err != nil { internalError(c, err, "create feed") return @@ -168,7 +168,7 @@ func (h *Handler) updateFeed(c *gin.Context) { badRequestError(c, "invalid request") return } - if err := validateRefreshInterval(req.RefreshInterval); err != nil { + if err := validateRefreshInterval(req.RefreshIntervalSeconds); err != nil { badRequestError(c, err.Error()) return } @@ -196,12 +196,12 @@ func (h *Handler) updateFeed(c *gin.Context) { if req.Proxy != nil { params.Proxy = req.Proxy } - if req.RefreshInterval != nil { - if *req.RefreshInterval == 0 { + if req.RefreshIntervalSeconds != nil { + if *req.RefreshIntervalSeconds == 0 { nilInterval := int64(-1) - params.RefreshInterval = &nilInterval + params.RefreshIntervalSeconds = &nilInterval } else { - params.RefreshInterval = req.RefreshInterval + params.RefreshIntervalSeconds = req.RefreshIntervalSeconds } } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index a0460ed9..5c8135e2 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -10,16 +10,16 @@ type Group struct { // Feed represents an RSS/Atom feed. type Feed struct { - ID int64 `json:"id"` - GroupID int64 `json:"group_id"` - Name string `json:"name"` - Link string `json:"link"` - SiteURL string `json:"site_url,omitempty"` - Suspended bool `json:"suspended"` - Proxy string `json:"proxy,omitempty"` - RefreshInterval *int64 `json:"refresh_interval,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + Name string `json:"name"` + Link string `json:"link"` + SiteURL string `json:"site_url,omitempty"` + Suspended bool `json:"suspended"` + Proxy string `json:"proxy,omitempty"` + RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` FetchState FeedFetchState `json:"fetch_state"` diff --git a/backend/internal/pull/puller.go b/backend/internal/pull/puller.go index c5c2db06..899b3b70 100644 --- a/backend/internal/pull/puller.go +++ b/backend/internal/pull/puller.go @@ -26,8 +26,8 @@ type Puller struct { } func (p *Puller) effectiveInterval(feed *model.Feed) time.Duration { - if feed.RefreshInterval != nil && *feed.RefreshInterval > 0 { - return time.Duration(*feed.RefreshInterval) * time.Second + if feed.RefreshIntervalSeconds != nil && *feed.RefreshIntervalSeconds > 0 { + return time.Duration(*feed.RefreshIntervalSeconds) * time.Second } return p.interval } diff --git a/backend/internal/store/feed.go b/backend/internal/store/feed.go index 22c4c90b..5a8ae58c 100644 --- a/backend/internal/store/feed.go +++ b/backend/internal/store/feed.go @@ -13,7 +13,7 @@ import ( func (s *Store) ListFeeds() ([]*model.Feed, error) { rows, err := s.db.Query(` SELECT f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval_seconds, f.created_at, f.updated_at, COALESCE(fs.etag, ''), COALESCE(fs.last_modified, ''), COALESCE(fs.cache_control, ''), COALESCE(fs.expires_at, 0), COALESCE(fs.last_checked_at, 0), COALESCE(fs.next_check_at, 0), COALESCE(fs.last_http_status, 0), COALESCE(fs.retry_after_until, 0), COALESCE(fs.last_success_at, 0), @@ -24,7 +24,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { LEFT JOIN feed_fetch_state fs ON fs.feed_id = f.id LEFT JOIN items i ON i.feed_id = f.id GROUP BY f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval_seconds, f.created_at, f.updated_at, fs.etag, fs.last_modified, fs.cache_control, fs.expires_at, fs.last_checked_at, fs.next_check_at, fs.last_http_status, fs.retry_after_until, fs.last_success_at, fs.last_error_at, fs.last_error, fs.consecutive_failures @@ -39,7 +39,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { for rows.Next() { f := &model.Feed{} var suspended int - var refreshIntervalNull sql.NullInt64 + var refreshIntervalSecondsNull sql.NullInt64 if err := rows.Scan( &f.ID, &f.GroupID, @@ -48,7 +48,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { &f.SiteURL, &suspended, &f.Proxy, - &refreshIntervalNull, + &refreshIntervalSecondsNull, &f.CreatedAt, &f.UpdatedAt, &f.FetchState.ETag, @@ -69,8 +69,8 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { return nil, err } f.Suspended = intToBool(suspended) - if refreshIntervalNull.Valid { - f.RefreshInterval = &refreshIntervalNull.Int64 + if refreshIntervalSecondsNull.Valid { + f.RefreshIntervalSeconds = &refreshIntervalSecondsNull.Int64 } feeds = append(feeds, f) } @@ -80,10 +80,10 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { func (s *Store) GetFeed(id int64) (*model.Feed, error) { f := &model.Feed{} var suspended int - var refreshIntervalNull sql.NullInt64 + var refreshIntervalSecondsNull sql.NullInt64 err := s.db.QueryRow(` SELECT f.id, f.group_id, f.name, f.link, f.site_url, - f.suspended, f.proxy, f.refresh_interval, f.created_at, f.updated_at, + f.suspended, f.proxy, f.refresh_interval_seconds, f.created_at, f.updated_at, COALESCE(fs.etag, ''), COALESCE(fs.last_modified, ''), COALESCE(fs.cache_control, ''), COALESCE(fs.expires_at, 0), COALESCE(fs.last_checked_at, 0), COALESCE(fs.next_check_at, 0), COALESCE(fs.last_http_status, 0), COALESCE(fs.retry_after_until, 0), COALESCE(fs.last_success_at, 0), @@ -99,7 +99,7 @@ func (s *Store) GetFeed(id int64) (*model.Feed, error) { &f.SiteURL, &suspended, &f.Proxy, - &refreshIntervalNull, + &refreshIntervalSecondsNull, &f.CreatedAt, &f.UpdatedAt, &f.FetchState.ETag, @@ -123,13 +123,13 @@ func (s *Store) GetFeed(id int64) (*model.Feed, error) { } f.Suspended = intToBool(suspended) - if refreshIntervalNull.Valid { - f.RefreshInterval = &refreshIntervalNull.Int64 + if refreshIntervalSecondsNull.Valid { + f.RefreshIntervalSeconds = &refreshIntervalSecondsNull.Int64 } return f, nil } -func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string, refreshInterval *int64) (*model.Feed, error) { +func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string, refreshIntervalSeconds *int64) (*model.Feed, error) { tx, err := s.db.Begin() if err != nil { return nil, err @@ -137,10 +137,10 @@ func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string, ref defer tx.Rollback() result, err := tx.Exec(` - INSERT INTO feeds (group_id, name, link, site_url, proxy, refresh_interval) - VALUES (:group_id, :name, :link, :site_url, :proxy, :refresh_interval) + INSERT INTO feeds (group_id, name, link, site_url, proxy, refresh_interval_seconds) + VALUES (:group_id, :name, :link, :site_url, :proxy, :refresh_interval_seconds) `, sql.Named("group_id", groupID), sql.Named("name", name), sql.Named("link", link), - sql.Named("site_url", siteURL), sql.Named("proxy", proxy), sql.Named("refresh_interval", refreshInterval)) + sql.Named("site_url", siteURL), sql.Named("proxy", proxy), sql.Named("refresh_interval_seconds", refreshIntervalSeconds)) if err != nil { return nil, err } @@ -197,13 +197,13 @@ func (s *Store) SearchFeeds(query string) ([]*SearchFeedResult, error) { // UpdateFeedParams supports partial updates. Only non-nil fields will be updated. // Pointer fields distinguish between "not set" (nil) and "set to zero value" (e.g., &false). type UpdateFeedParams struct { - GroupID *int64 - Name *string - Link *string - SiteURL *string - Suspended *bool - Proxy *string - RefreshInterval *int64 + GroupID *int64 + Name *string + Link *string + SiteURL *string + Suspended *bool + Proxy *string + RefreshIntervalSeconds *int64 } // UpdateFeed performs partial update of feed fields using a single dynamic UPDATE query. @@ -235,12 +235,12 @@ func (s *Store) UpdateFeed(id int64, params UpdateFeedParams) error { setClauses = append(setClauses, "proxy = :proxy") args = append(args, sql.Named("proxy", *params.Proxy)) } - if params.RefreshInterval != nil { - if *params.RefreshInterval == -1 { - setClauses = append(setClauses, "refresh_interval = NULL") + if params.RefreshIntervalSeconds != nil { + if *params.RefreshIntervalSeconds == -1 { + setClauses = append(setClauses, "refresh_interval_seconds = NULL") } else { - setClauses = append(setClauses, "refresh_interval = :refresh_interval") - args = append(args, sql.Named("refresh_interval", *params.RefreshInterval)) + setClauses = append(setClauses, "refresh_interval_seconds = :refresh_interval_seconds") + args = append(args, sql.Named("refresh_interval_seconds", *params.RefreshIntervalSeconds)) } } @@ -321,6 +321,16 @@ func (s *Store) UpdateFeed(id int64, params UpdateFeedParams) error { } } + if params.RefreshIntervalSeconds != nil { + if _, err := tx.Exec(` + UPDATE feed_fetch_state + SET next_check_at = unixepoch(), updated_at = unixepoch() + WHERE feed_id = :feed_id + `, sql.Named("feed_id", id)); err != nil { + return err + } + } + return tx.Commit() } diff --git a/backend/internal/store/migrations/003_feed_refresh_interval.sql b/backend/internal/store/migrations/003_feed_refresh_interval.sql index b42f6916..d4bf4c03 100644 --- a/backend/internal/store/migrations/003_feed_refresh_interval.sql +++ b/backend/internal/store/migrations/003_feed_refresh_interval.sql @@ -1,3 +1,3 @@ -- Add per-feed refresh interval override. -ALTER TABLE feeds ADD COLUMN refresh_interval INTEGER DEFAULT NULL; +ALTER TABLE feeds ADD COLUMN refresh_interval_seconds INTEGER DEFAULT NULL; diff --git a/frontend/src/components/feed/add-feed-dialog.tsx b/frontend/src/components/feed/add-feed-dialog.tsx index 290ea033..141bb722 100644 --- a/frontend/src/components/feed/add-feed-dialog.tsx +++ b/frontend/src/components/feed/add-feed-dialog.tsx @@ -147,7 +147,7 @@ export function AddFeedDialog() { } if (refreshInterval !== "default") { - request.refresh_interval = parseInt(refreshInterval, 10); + request.refresh_interval_seconds = parseInt(refreshInterval, 10); } await createFeed.mutateAsync(request); diff --git a/frontend/src/components/feed/edit-feed-dialog.tsx b/frontend/src/components/feed/edit-feed-dialog.tsx index 08683b93..ac42f502 100644 --- a/frontend/src/components/feed/edit-feed-dialog.tsx +++ b/frontend/src/components/feed/edit-feed-dialog.tsx @@ -84,12 +84,12 @@ export function EditFeedDialog() { setGroupId(editingFeed.group_id.toString()); setProxy(editingFeed.proxy ?? ""); setSuspended(editingFeed.suspended); - if (editingFeed.refresh_interval != null) { - setRefreshInterval(editingFeed.refresh_interval.toString()); + if (editingFeed.refresh_interval_seconds != null) { + setRefreshInterval(editingFeed.refresh_interval_seconds.toString()); } else { setRefreshInterval("default"); } - setIsAdvancedOpen(!!editingFeed.proxy || editingFeed.refresh_interval != null); + setIsAdvancedOpen(!!editingFeed.proxy || editingFeed.refresh_interval_seconds != null); setIsMobileErrorTooltipOpen(false); } }, [editingFeed]); @@ -151,13 +151,13 @@ export function EditFeedDialog() { } if (refreshInterval === "default") { - if (editingFeed.refresh_interval != null) { - request.refresh_interval = 0; + if (editingFeed.refresh_interval_seconds != null) { + request.refresh_interval_seconds = 0; } } else { const newVal = parseInt(refreshInterval, 10); - if (editingFeed.refresh_interval !== newVal) { - request.refresh_interval = newVal; + if (editingFeed.refresh_interval_seconds !== newVal) { + request.refresh_interval_seconds = newVal; } } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 11d4245e..743d6335 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -14,7 +14,7 @@ export interface Feed { site_url?: string; suspended: boolean; proxy?: string; - refresh_interval?: number | null; + refresh_interval_seconds?: number | null; created_at: number; updated_at: number; fetch_state: FeedFetchState; @@ -89,11 +89,10 @@ export interface CreateFeedRequest { name: string; link: string; site_url?: string; -proxy?: string; - refresh_interval?: number | null; + proxy?: string; + refresh_interval_seconds?: number | null; } - export interface UpdateFeedRequest { group_id?: number; name?: string; @@ -101,7 +100,7 @@ export interface UpdateFeedRequest { site_url?: string; suspended?: boolean; proxy?: string; - refresh_interval?: number | null; + refresh_interval_seconds?: number | null; } export interface ValidateFeedRequest { diff --git a/frontend/src/lib/i18n/messages/de.ts b/frontend/src/lib/i18n/messages/de.ts index 91116b72..256a0f77 100644 --- a/frontend/src/lib/i18n/messages/de.ts +++ b/frontend/src/lib/i18n/messages/de.ts @@ -101,13 +101,13 @@ export const deMessages: PartialMessages = { "feed.add.urlPlaceholder": "https://example.com/feed.xml", "feed.add.refreshFrequencyLabel": "Aktualisierungsfrequenz", "feed.add.refreshFrequencyDefault": "Standard ({interval})", - "feed.add.refreshFrequency.900": "Alle 15 minutes", - "feed.add.refreshFrequency.1800": "Alle 30 minutes", - "feed.add.refreshFrequency.3600": "Alle 1 hour", - "feed.add.refreshFrequency.7200": "Alle 2 hours", - "feed.add.refreshFrequency.21600": "Alle 6 hours", - "feed.add.refreshFrequency.43200": "Alle 12 hours", - "feed.add.refreshFrequency.86400": "Alle 24 hours", + "feed.add.refreshFrequency.900": "Alle 15 Minuten", + "feed.add.refreshFrequency.1800": "Alle 30 Minuten", + "feed.add.refreshFrequency.3600": "Alle 1 Stunde", + "feed.add.refreshFrequency.7200": "Alle 2 Stunden", + "feed.add.refreshFrequency.21600": "Alle 6 Stunden", + "feed.add.refreshFrequency.43200": "Alle 12 Stunden", + "feed.add.refreshFrequency.86400": "Alle 24 Stunden", "feed.add.validateTitle": "Feed-URL prufen", "feed.edit.deleteConfirm.description": 'Mochten Sie "{name}" wirklich abbestellen? Alle Artikel dieses Feeds werden geloscht.', diff --git a/frontend/src/lib/i18n/messages/es.ts b/frontend/src/lib/i18n/messages/es.ts index 57ae0efc..835afbda 100644 --- a/frontend/src/lib/i18n/messages/es.ts +++ b/frontend/src/lib/i18n/messages/es.ts @@ -99,6 +99,15 @@ export const esMessages: PartialMessages = { "feed.add.title": "Agregar feed", "feed.add.urlLabel": "URL del feed", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Frecuencia de actualizacion", + "feed.add.refreshFrequencyDefault": "Predeterminado ({interval})", + "feed.add.refreshFrequency.900": "Cada 15 minutos", + "feed.add.refreshFrequency.1800": "Cada 30 minutos", + "feed.add.refreshFrequency.3600": "Cada 1 hora", + "feed.add.refreshFrequency.7200": "Cada 2 horas", + "feed.add.refreshFrequency.21600": "Cada 6 horas", + "feed.add.refreshFrequency.43200": "Cada 12 horas", + "feed.add.refreshFrequency.86400": "Cada 24 horas", "feed.add.validateTitle": "Validar URL del feed", "feed.edit.deleteConfirm.description": 'Seguro que quieres cancelar la suscripcion de "{name}"? Todos los articulos de este feed se eliminaran.', diff --git a/frontend/src/lib/i18n/messages/fr.ts b/frontend/src/lib/i18n/messages/fr.ts index 9117466b..2fb2c13c 100644 --- a/frontend/src/lib/i18n/messages/fr.ts +++ b/frontend/src/lib/i18n/messages/fr.ts @@ -99,6 +99,15 @@ export const frMessages: PartialMessages = { "feed.add.title": "Ajouter un flux", "feed.add.urlLabel": "URL du flux", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Frequence d'actualisation", + "feed.add.refreshFrequencyDefault": "Defaut ({interval})", + "feed.add.refreshFrequency.900": "Toutes les 15 minutes", + "feed.add.refreshFrequency.1800": "Toutes les 30 minutes", + "feed.add.refreshFrequency.3600": "Toutes les heures", + "feed.add.refreshFrequency.7200": "Toutes les 2 heures", + "feed.add.refreshFrequency.21600": "Toutes les 6 heures", + "feed.add.refreshFrequency.43200": "Toutes les 12 heures", + "feed.add.refreshFrequency.86400": "Toutes les 24 heures", "feed.add.validateTitle": "Verifier l'URL du flux", "feed.edit.deleteConfirm.description": 'Voulez-vous vraiment vous desabonner de "{name}" ? Tous les articles de ce flux seront supprimes.', diff --git a/frontend/src/lib/i18n/messages/ru.ts b/frontend/src/lib/i18n/messages/ru.ts index 5501f144..d2ef9229 100644 --- a/frontend/src/lib/i18n/messages/ru.ts +++ b/frontend/src/lib/i18n/messages/ru.ts @@ -99,6 +99,15 @@ export const ruMessages: PartialMessages = { "feed.add.title": "Добавить ленту", "feed.add.urlLabel": "URL ленты", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Частота обновления", + "feed.add.refreshFrequencyDefault": "По умолчанию ({interval})", + "feed.add.refreshFrequency.900": "Каждые 15 минут", + "feed.add.refreshFrequency.1800": "Каждые 30 минут", + "feed.add.refreshFrequency.3600": "Каждый час", + "feed.add.refreshFrequency.7200": "Каждые 2 часа", + "feed.add.refreshFrequency.21600": "Каждые 6 часов", + "feed.add.refreshFrequency.43200": "Каждые 12 часов", + "feed.add.refreshFrequency.86400": "Каждые 24 часа", "feed.add.validateTitle": "Проверить URL ленты", "feed.edit.deleteConfirm.description": "Вы уверены, что хотите отписаться от \"{name}\"? Все статьи этой ленты будут удалены.", diff --git a/frontend/src/lib/i18n/messages/sv.ts b/frontend/src/lib/i18n/messages/sv.ts index a95f2d2e..4766b8c4 100644 --- a/frontend/src/lib/i18n/messages/sv.ts +++ b/frontend/src/lib/i18n/messages/sv.ts @@ -99,6 +99,15 @@ export const svMessages: PartialMessages = { "feed.add.title": "Lagg till flode", "feed.add.urlLabel": "Flodes-URL", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "Uppdateringsfrekvens", + "feed.add.refreshFrequencyDefault": "Standard ({interval})", + "feed.add.refreshFrequency.900": "Var 15:e minut", + "feed.add.refreshFrequency.1800": "Var 30:e minut", + "feed.add.refreshFrequency.3600": "Varje timme", + "feed.add.refreshFrequency.7200": "Var 2:a timme", + "feed.add.refreshFrequency.21600": "Var 6:e timme", + "feed.add.refreshFrequency.43200": "Var 12:e timme", + "feed.add.refreshFrequency.86400": "Var 24:e timme", "feed.add.validateTitle": "Verifiera flodes-URL", "feed.edit.deleteConfirm.description": 'Ar du saker pa att du vill avsluta prenumerationen pa "{name}"? Alla artiklar fran detta flode kommer att raderas.', diff --git a/frontend/src/lib/i18n/messages/zh.ts b/frontend/src/lib/i18n/messages/zh.ts index a42b8e58..c95fc214 100644 --- a/frontend/src/lib/i18n/messages/zh.ts +++ b/frontend/src/lib/i18n/messages/zh.ts @@ -99,6 +99,15 @@ export const zhMessages: PartialMessages = { "feed.add.title": "添加订阅", "feed.add.urlLabel": "订阅地址", "feed.add.urlPlaceholder": "https://example.com/feed.xml", + "feed.add.refreshFrequencyLabel": "刷新频率", + "feed.add.refreshFrequencyDefault": "默认({interval})", + "feed.add.refreshFrequency.900": "每 15 分钟", + "feed.add.refreshFrequency.1800": "每 30 分钟", + "feed.add.refreshFrequency.3600": "每 1 小时", + "feed.add.refreshFrequency.7200": "每 2 小时", + "feed.add.refreshFrequency.21600": "每 6 小时", + "feed.add.refreshFrequency.43200": "每 12 小时", + "feed.add.refreshFrequency.86400": "每 24 小时", "feed.add.validateTitle": "验证订阅地址", "feed.edit.deleteConfirm.description": "确定要取消订阅“{name}”吗?该订阅的所有文章将被删除。",