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 diff --git a/backend/internal/handler/feed.go b/backend/internal/handler/feed.go index ccb3d5b4..22da08b5 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"` + 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"` // 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"` + RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds"` } 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 || *v == 0 { + return nil + } + if !allowedRefreshIntervals[*v] { + return fmt.Errorf("invalid refresh_interval_seconds: 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.RefreshIntervalSeconds); 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.RefreshIntervalSeconds) 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.RefreshIntervalSeconds); 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.RefreshIntervalSeconds != nil { + if *req.RefreshIntervalSeconds == 0 { + nilInterval := int64(-1) + params.RefreshIntervalSeconds = &nilInterval + } else { + params.RefreshIntervalSeconds = req.RefreshIntervalSeconds + } + } if err := h.store.UpdateFeed(id, params); err != nil { if errors.Is(err, store.ErrNotFound) { 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/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) diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 4579a65c..5c8135e2 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -10,15 +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"` - 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 ea47a0a5..899b3b70 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.RefreshIntervalSeconds != nil && *feed.RefreshIntervalSeconds > 0 { + return time.Duration(*feed.RefreshIntervalSeconds) * 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, 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..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.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.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,6 +39,7 @@ func (s *Store) ListFeeds() ([]*model.Feed, error) { for rows.Next() { f := &model.Feed{} var suspended int + var refreshIntervalSecondsNull 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, + &refreshIntervalSecondsNull, &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 refreshIntervalSecondsNull.Valid { + f.RefreshIntervalSeconds = &refreshIntervalSecondsNull.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 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.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), @@ -93,6 +99,7 @@ func (s *Store) GetFeed(id int64) (*model.Feed, error) { &f.SiteURL, &suspended, &f.Proxy, + &refreshIntervalSecondsNull, &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 refreshIntervalSecondsNull.Valid { + f.RefreshIntervalSeconds = &refreshIntervalSecondsNull.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, refreshIntervalSeconds *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_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("site_url", siteURL), sql.Named("proxy", proxy), sql.Named("refresh_interval_seconds", refreshIntervalSeconds)) 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 + RefreshIntervalSeconds *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.RefreshIntervalSeconds != nil { + if *params.RefreshIntervalSeconds == -1 { + setClauses = append(setClauses, "refresh_interval_seconds = NULL") + } else { + setClauses = append(setClauses, "refresh_interval_seconds = :refresh_interval_seconds") + args = append(args, sql.Named("refresh_interval_seconds", *params.RefreshIntervalSeconds)) + } + } if len(setClauses) == 0 { return nil @@ -302,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/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/migrations/003_feed_refresh_interval.sql b/backend/internal/store/migrations/003_feed_refresh_interval.sql new file mode 100644 index 00000000..d4bf4c03 --- /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_seconds INTEGER DEFAULT NULL; 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) } diff --git a/frontend/src/components/feed/add-feed-dialog.tsx b/frontend/src/components/feed/add-feed-dialog.tsx index 13dbd6fa..141bb722 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_seconds = 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")} +
+ + +
diff --git a/frontend/src/components/feed/edit-feed-dialog.tsx b/frontend/src/components/feed/edit-feed-dialog.tsx index 8d2248f7..ac42f502 100644 --- a/frontend/src/components/feed/edit-feed-dialog.tsx +++ b/frontend/src/components/feed/edit-feed-dialog.tsx @@ -31,12 +31,20 @@ import { import { useUIStore } from "@/store"; import { useGroups } from "@/queries/groups"; import { useUpdateFeed, useDeleteFeed } from "@/queries/feeds"; +import { useQuery } from "@tanstack/react-query"; +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"; +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 +57,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 +67,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 +84,12 @@ export function EditFeedDialog() { setGroupId(editingFeed.group_id.toString()); setProxy(editingFeed.proxy ?? ""); setSuspended(editingFeed.suspended); - setIsAdvancedOpen(!!editingFeed.proxy); + if (editingFeed.refresh_interval_seconds != null) { + setRefreshInterval(editingFeed.refresh_interval_seconds.toString()); + } else { + setRefreshInterval("default"); + } + setIsAdvancedOpen(!!editingFeed.proxy || editingFeed.refresh_interval_seconds != null); setIsMobileErrorTooltipOpen(false); } }, [editingFeed]); @@ -76,6 +100,7 @@ export function EditFeedDialog() { setGroupId(""); setProxy(""); setSuspended(false); + setRefreshInterval("default"); setIsAdvancedOpen(false); setIsDeleteOpen(false); }; @@ -125,6 +150,17 @@ export function EditFeedDialog() { request.proxy = newProxy; } + if (refreshInterval === "default") { + if (editingFeed.refresh_interval_seconds != null) { + request.refresh_interval_seconds = 0; + } + } else { + const newVal = parseInt(refreshInterval, 10); + if (editingFeed.refresh_interval_seconds !== newVal) { + request.refresh_interval_seconds = newVal; + } + } + if (Object.keys(request).length === 0) { toast.info(t("feed.edit.noChanges")); handleClose(); @@ -299,25 +335,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")} +

+
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..743d6335 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_seconds?: number | null; created_at: number; updated_at: number; fetch_state: FeedFetchState; @@ -89,6 +90,7 @@ export interface CreateFeedRequest { link: string; site_url?: string; proxy?: string; + refresh_interval_seconds?: number | null; } export interface UpdateFeedRequest { @@ -98,6 +100,7 @@ export interface UpdateFeedRequest { site_url?: string; suspended?: boolean; proxy?: string; + refresh_interval_seconds?: number | null; } export interface ValidateFeedRequest { @@ -175,6 +178,10 @@ export interface BatchCreateFeedsResponse { errors?: string[]; } +export interface AppInfoResponse { + pull_interval: number; +} + // OIDC export interface OIDCStatusResponse { enabled: boolean; diff --git a/frontend/src/lib/i18n/messages/de.ts b/frontend/src/lib/i18n/messages/de.ts index 07462abc..256a0f77 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 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/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.', 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/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.', 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}”吗?该订阅的所有文章将被删除。",