From 2f75794194209586d0b17d26e4e87d94d3e6aead Mon Sep 17 00:00:00 2001 From: Sam Scholten Date: Wed, 7 Oct 2026 15:31:52 +1000 Subject: feeds: subscription audit command (per-feed volume + star stats) GET /v1/feeds + feed_id entries filter in the client, one cheap volume probe per feed over a --days window, star stats joined from labels.jsonl by feed id, category column, --min-per-day/--min-seen filters. Read-only; justfile recipe with one-line doc comments throughout. --- README.md | 11 +- cmds/feeds.go | 185 ++++++++++++++++++++++++++++++++ cmds/feeds_test.go | 278 ++++++++++++++++++++++++++++++++++++++++++++++++ justfile | 20 ++-- main.go | 3 + miniflux/client.go | 36 ++++++- miniflux/client_test.go | 59 +++++++++- 7 files changed, 577 insertions(+), 15 deletions(-) create mode 100644 cmds/feeds.go create mode 100644 cmds/feeds_test.go diff --git a/README.md b/README.md index eb84765..1288601 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ No deps beyond stdlib + vendored ML core. ## Commands ``` -fluxrec export stars + sampled negatives → labels.jsonl (local) -fluxrec train labels.jsonl → model.json + report.json (local) -fluxrec serve poll, score, RSS at /recommendations.xml (server) -fluxrec retrostar model-ranked candidates to star by hand (cold start) -fluxrec score debug: stdin titles → stdout scores +fluxrec export stars + sampled negatives → labels.jsonl (local) +fluxrec train labels.jsonl → model.json + report.json (local) +fluxrec serve poll, score, RSS at /recommendations.xml (server) +fluxrec retrostar model-ranked candidates to star by hand (cold start) +fluxrec feeds per-feed volume + star stats (pruning aid) +fluxrec score debug: stdin titles → stdout scores ``` ## Setup diff --git a/cmds/feeds.go b/cmds/feeds.go new file mode 100644 index 0000000..48d07de --- /dev/null +++ b/cmds/feeds.go @@ -0,0 +1,185 @@ +// Feeds command: subscription pruning audit. Lists every subscribed feed +// with its entry volume over a recent window (one cheap /v1/entries probe +// per feed: limit=1, read only the total) joined with star stats from +// labels.jsonl, so "loud" and "valuable to me" sit in the same table. +// Read-only — it never writes to Miniflux; unsubscribing stays a human +// action in the UI. +// +// The two columns have different denominators on purpose: volume comes +// from the API (everything published in the window), stars come from +// labels.jsonl (the sample export happened to capture, refreshed only when +// export runs). Star rate is therefore a heuristic, and feeds with no +// label rows render "-" — never confuse "no data" with a 0% rate. +package cmds + +import ( + "flag" + "fmt" + "io" + "os" + "sort" + "time" + + "fluxrec/core" + "fluxrec/miniflux" +) + +type FeedsCommand struct { + Days int + Labels string + MinPerDay float64 + MinSeen int +} + +func (c *FeedsCommand) Name() string { return "feeds" } + +func (c *FeedsCommand) Init(args []string) error { + fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + fs.Usage = func() { + fmt.Fprint(fs.Output(), `Usage: fluxrec feeds [flags] + +Print one row per subscribed feed: entry volume over the window (from the +API) plus stars seen in labels.jsonl, sorted loudest first. For spotting +dailies and high-volume feeds worth pruning. Read-only: nothing is +written to Miniflux. + +Filter units differ on purpose: volume scales with the window, so its +threshold is per-day (window-invariant); "seen" counts label rows, which +accumulate over export history rather than the window, so its threshold +is an absolute sample size. +`) + fs.PrintDefaults() + } + fs.IntVar(&c.Days, "days", 180, "Count entries published within this many days") + fs.StringVar(&c.Labels, "labels", "labels.jsonl", "Path to labels.jsonl for star stats (empty = volume columns only)") + fs.Float64Var(&c.MinPerDay, "min-per-day", 0.05, "Only show feeds averaging at least this many entries/day (0 = all; default hides dead/dormant feeds)") + fs.IntVar(&c.MinSeen, "min-seen", 0, "Only show feeds with at least this many labeled rows (0 = all; hides '-' rows)") + if err := fs.Parse(args); err != nil { + return err + } + if c.Days < 1 { + return fmt.Errorf("--days must be >= 1, got %d", c.Days) + } + if c.MinPerDay < 0 { + return fmt.Errorf("--min-per-day must be >= 0, got %g", c.MinPerDay) + } + if c.MinSeen < 0 { + return fmt.Errorf("--min-seen must be >= 0, got %d", c.MinSeen) + } + return nil +} + +// feedRow is one line of the audit table. +type feedRow struct { + feed miniflux.FeedInfo + volume int // entries in the window (API total) + seen int // labeled rows for this feed (sample, not all entries) + stars int // of those, the positives + hasLbl bool // any rows at all — distinguishes "no data" from "0%" +} + +func (c *FeedsCommand) Run(stdin io.Reader, stdout io.Writer) error { + cfg, err := miniflux.ConfigFromEnv() + if err != nil { + return err + } + client := miniflux.NewClient(cfg) + + feeds, err := client.Feeds() + if err != nil { + return err + } + + // Star stats per feed, joined by feed ID so renames are harmless. + // A missing labels file is not an error: a fresh checkout runs the + // audit volume-only. + stats := map[int64]*feedRow{} + if c.Labels != "" { + rows, err := core.ReadLabels(c.Labels) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading labels: %w", err) + } + for _, r := range rows { + s := stats[r.FeedID] + if s == nil { + s = &feedRow{} + stats[r.FeedID] = s + } + s.seen++ + if r.Label == core.LabelPos { + s.stars++ + } + s.hasLbl = true + } + } + + cutoff := time.Now().Add(-time.Duration(c.Days) * 24 * time.Hour).Unix() + table := make([]feedRow, 0, len(feeds)) + for _, f := range feeds { + vol, err := c.feedVolume(client, f.ID, cutoff) + if err != nil { + return err + } + row := feedRow{feed: f, volume: vol} + if s := stats[f.ID]; s != nil { + row.seen, row.stars, row.hasLbl = s.seen, s.stars, s.hasLbl + } + table = append(table, row) + } + + sort.SliceStable(table, func(i, j int) bool { + if table[i].volume == table[j].volume { + return table[i].feed.ID < table[j].feed.ID + } + return table[i].volume > table[j].volume + }) + + fmt.Fprintf(stdout, "%-6s %-36s %-16s %6s %6s %6s %6s %6s\n", "id", "feed", "category", "days", "/day", "stars", "seen", "rate") + shown := 0 + for _, r := range table { + perDay := float64(r.volume) / float64(c.Days) + if perDay < c.MinPerDay || r.seen < c.MinSeen { + continue + } + shown++ + stars, seen, rate := "-", "-", "-" + if r.hasLbl { + stars = fmt.Sprint(r.stars) + seen = fmt.Sprint(r.seen) + rate = fmt.Sprintf("%.0f%%", 100*float64(r.stars)/float64(r.seen)) + } + disabled := "" + if r.feed.Disabled { + disabled = " (disabled)" + } + fmt.Fprintf(stdout, "%-6d %-36s %-16s %6d %6.1f %6s %6s %6s%s\n", + r.feed.ID, clip(r.feed.Title, 36), clip(r.feed.Category.Title, 16), + r.volume, perDay, stars, seen, rate, disabled) + } + fmt.Fprintf(stdout, "feeds: showing %d of %d subscriptions, window %d days; star stats from %s (sample — export refreshes it)\n", + shown, len(table), c.Days, c.Labels) + return nil +} + +// clip shortens s to at most n runes, ending a truncated string with an +// ellipsis, so one over-long title can't break the table's alignment. +func clip(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return string(r[:n]) + } + return string(r[:n-1]) + "…" +} + +// feedVolume counts entries published at or after cutoff for one feed via +// a limit=1 probe — the cheap way to read EntrySet.Total. +func (c *FeedsCommand) feedVolume(client *miniflux.Client, feedID int64, cutoff int64) (int, error) { + set, err := client.Entries(miniflux.Filters{FeedID: feedID, PublishedAfter: cutoff, Limit: 1}) + if err != nil { + return 0, fmt.Errorf("counting feed %d: %w", feedID, err) + } + return set.Total, nil +} diff --git a/cmds/feeds_test.go b/cmds/feeds_test.go new file mode 100644 index 0000000..60151cc --- /dev/null +++ b/cmds/feeds_test.go @@ -0,0 +1,278 @@ +// Test for `fluxrec feeds` against an httptest fake Miniflux: rows are +// sorted loudest-first, label stats join by feed ID, and a feed with no +// label rows renders "-" rather than a fake 0% rate. +package cmds + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "fluxrec/core" +) + +// feedsFake serves three feeds and per-feed entry counts keyed by the +// feed_id query param, honoring published_after like pagedServer does. +type feedsFake struct { + feedsJSON string + pool map[int64][]testEntry // feed ID -> entries + queries []string +} + +func (f *feedsFake) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f.queries = append(f.queries, r.URL.Path+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/feeds" { + fmt.Fprint(w, f.feedsJSON) + return + } + q := r.URL.Query() + fid, _ := strconv.ParseInt(q.Get("feed_id"), 10, 64) + pool := f.pool[fid] + if pa, _ := strconv.ParseInt(q.Get("published_after"), 10, 64); pa > 0 { + cutoff := time.Unix(pa, 0) + filtered := pool[:0:0] + for _, e := range pool { + if !e.PublishedAt.Before(cutoff) { + filtered = append(filtered, e) + } + } + pool = filtered + } + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 { + limit = len(pool) + } + if limit > len(pool) { + limit = len(pool) + } + fmt.Fprint(w, entriesJSON(len(pool), pool[:limit])) + } +} + +func TestFeedsAudit(t *testing.T) { + now := time.Now() + fresh := func(entries []testEntry) []testEntry { + for i := range entries { + entries[i].PublishedAt = now.Add(-24 * time.Hour) + } + return entries + } + fake := &feedsFake{ + feedsJSON: `[ + {"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false, + "category": {"id": 10, "title": "Blogs"}}, + {"id": 2, "title": "Firehose Daily", "site_url": "https://fire.example", "disabled": false, + "category": {"id": 11, "title": "Flood"}}, + {"id": 3, "title": "New Sub", "site_url": "https://new.example", "disabled": false, + "category": {"id": 10, "title": "Blogs"}} + ]`, + pool: map[int64][]testEntry{ + 1: fresh(makeEntries(2, 100, false, 1)), + 2: fresh(makeEntries(90, 200, false, 2)), + 3: fresh(makeEntries(4, 300, false, 3)), + }, + } + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + t.Setenv("MINIFLUX_URL", srv.URL) + t.Setenv("MINIFLUX_TOKEN", "test-token") + + dir := t.TempDir() + labelsPath := filepath.Join(dir, "labels.jsonl") + if err := core.AppendLabels(labelsPath, []core.LabelRow{ + {EntryID: 1, URL: "https://quiet.example/a", Title: "A", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelPos, CapturedAt: now}, + {EntryID: 2, URL: "https://quiet.example/b", Title: "B", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelNeg, CapturedAt: now}, + {EntryID: 3, URL: "https://fire.example/c", Title: "C", FeedID: 2, FeedTitle: "Firehose Daily", Label: core.LabelNeg, CapturedAt: now}, + }); err != nil { + t.Fatalf("AppendLabels: %v", err) + } + + cmd := &FeedsCommand{Days: 30, Labels: labelsPath} + var out bytes.Buffer + if err := cmd.Run(nil, &out); err != nil { + t.Fatalf("feeds: %v", err) + } + got := out.String() + + // Sorted by volume: Firehose (90) before New Sub (4) before Quiet (2). + fi, ni, qi := strings.Index(got, "Firehose Daily"), strings.Index(got, "New Sub"), strings.Index(got, "Quiet Blog") + if fi < 0 || ni < 0 || qi < 0 { + t.Fatalf("output missing a feed row:\n%s", got) + } + if !(fi < ni && ni < qi) { + t.Errorf("rows not sorted by volume desc:\n%s", got) + } + + // Label stats joined by feed ID: Quiet = 1/2 (50%), Firehose = 0/1 (0%). + if !strings.Contains(got, "50%") { + t.Errorf("Quiet Blog rate should be 50%%:\n%s", got) + } + + // New Sub has no label rows: "-" placeholders, not a bogus 0%. + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "New Sub") && !strings.Contains(line, "-") { + t.Errorf("New Sub should render '-' for missing label stats: %q", line) + } + } + + // Per-feed day's rate: Firehose = 90/30 = 3.0/day. + if !strings.Contains(got, "3.0") { + t.Errorf("Firehose per-day rate missing:\n%s", got) + } + + // Categories render next to their feeds. + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "Firehose Daily") && !strings.Contains(line, "Flood") { + t.Errorf("Firehose row missing its category: %q", line) + } + if strings.Contains(line, "Quiet Blog") && !strings.Contains(line, "Blogs") { + t.Errorf("Quiet Blog row missing its category: %q", line) + } + } + + // Unfiltered run shows all three feeds. + if !strings.Contains(got, "showing 3 of 3 subscriptions") { + t.Errorf("footer should count all feeds when unfiltered:\n%s", got) + } + + // One /v1/feeds call plus one volume probe per feed, each carrying + // feed_id and published_after. + if len(fake.queries) != 4 { + t.Fatalf("expected 4 API calls, got %d: %v", len(fake.queries), fake.queries) + } + for _, q := range fake.queries[1:] { + if !strings.Contains(q, "feed_id=") || !strings.Contains(q, "published_after=") { + t.Errorf("volume probe missing filters: %q", q) + } + } +} + +// Filters: --min-per-day keeps only the firehose (volume scales with the +// window, so its threshold is per-day); --min-seen keeps only the +// well-sampled feed (label rows accumulate over export history, so its +// threshold is absolute). +func TestFeedsFilters(t *testing.T) { + now := time.Now() + fresh := func(entries []testEntry) []testEntry { + for i := range entries { + entries[i].PublishedAt = now.Add(-24 * time.Hour) + } + return entries + } + newServer := func(t *testing.T) *httptest.Server { + t.Helper() + fake := &feedsFake{ + feedsJSON: `[ + {"id": 1, "title": "Loud", "site_url": "https://loud.example", "category": {"id": 1, "title": "Flood"}}, + {"id": 2, "title": "Quiet", "site_url": "https://quiet.example", "category": {"id": 2, "title": "Blogs"}} + ]`, + pool: map[int64][]testEntry{ + 1: fresh(makeEntries(90, 100, false, 1)), + 2: fresh(makeEntries(3, 500, false, 2)), + }, + } + srv := httptest.NewServer(fake.handler()) + t.Cleanup(srv.Close) + t.Setenv("MINIFLUX_URL", srv.URL) + t.Setenv("MINIFLUX_TOKEN", "test-token") + return srv + } + + labelsPath := filepath.Join(t.TempDir(), "labels.jsonl") + rows := []core.LabelRow{} + for i := 0; i < 2; i++ { + rows = append(rows, core.LabelRow{EntryID: int64(10 + i), URL: "u", Title: "t", + FeedID: 1, FeedTitle: "Loud", Label: core.LabelNeg, CapturedAt: now}) + } + for i := 0; i < 10; i++ { + rows = append(rows, core.LabelRow{EntryID: int64(100 + i), URL: "u", Title: "t", + FeedID: 2, FeedTitle: "Quiet", Label: core.LabelNeg, CapturedAt: now}) + } + if err := core.AppendLabels(labelsPath, rows); err != nil { + t.Fatalf("AppendLabels: %v", err) + } + + run := func(cmd *FeedsCommand) string { + t.Helper() + var out bytes.Buffer + if err := cmd.Run(nil, &out); err != nil { + t.Fatalf("feeds: %v", err) + } + return out.String() + } + + newServer(t) + got := run(&FeedsCommand{Days: 30, Labels: labelsPath, MinPerDay: 1.0}) + if !strings.Contains(got, "Loud") || strings.Contains(got, "Quiet") { + t.Errorf("--min-per-day 1.0 should keep only Loud (3.0/day vs 0.1/day):\n%s", got) + } + if !strings.Contains(got, "showing 1 of 2") { + t.Errorf("filtered footer should count the shown subset:\n%s", got) + } + + newServer(t) + got = run(&FeedsCommand{Days: 30, Labels: labelsPath, MinSeen: 5}) + if strings.Contains(got, "Loud") || !strings.Contains(got, "Quiet") { + t.Errorf("--min-seen 5 should keep only Quiet (10 rows vs 2):\n%s", got) + } +} + +// Without a labels file the audit still prints the volume columns (fresh +// checkout) rather than erroring. +func TestFeedsAuditWithoutLabels(t *testing.T) { + fake := &feedsFake{ + feedsJSON: `[{"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false}]`, + pool: map[int64][]testEntry{1: {}}, + } + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + t.Setenv("MINIFLUX_URL", srv.URL) + t.Setenv("MINIFLUX_TOKEN", "test-token") + + cmd := &FeedsCommand{Days: 30, Labels: filepath.Join(t.TempDir(), "nonexistent.jsonl")} + var out bytes.Buffer + if err := cmd.Run(nil, &out); err != nil { + t.Fatalf("feeds without labels file: %v", err) + } + if !strings.Contains(out.String(), "Quiet Blog") { + t.Errorf("volume-only audit should still list the feed:\n%s", out.String()) + } +} + +// Guard: entries outside the window must not count toward volume. (Kept +// explicit because the fake honors published_after — if the command ever +// stops sending it, this fails loudly instead of silently inflating counts.) +func TestFeedsAuditWindowFilter(t *testing.T) { + now := time.Now() + entries := makeEntries(3, 100, false, 1) + entries[0].PublishedAt = now.Add(-24 * time.Hour) // in window + entries[1].PublishedAt = now.Add(-10 * 24 * time.Hour) // in window + entries[2].PublishedAt = now.Add(-200 * 24 * time.Hour) // out: 200d > 30d window + fake := &feedsFake{ + feedsJSON: `[{"id": 1, "title": "Sparse Blog", "site_url": "https://sparse.example", "disabled": false}]`, + pool: map[int64][]testEntry{1: entries}, + } + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + t.Setenv("MINIFLUX_URL", srv.URL) + t.Setenv("MINIFLUX_TOKEN", "test-token") + + cmd := &FeedsCommand{Days: 30, Labels: ""} + var out bytes.Buffer + if err := cmd.Run(nil, &out); err != nil { + t.Fatalf("feeds: %v", err) + } + for _, line := range strings.Split(out.String(), "\n") { + if strings.Contains(line, "Sparse Blog") && !strings.Contains(line, " 2 ") { + t.Errorf("Sparse Blog volume should be 2 (window applied): %q", line) + } + } +} diff --git a/justfile b/justfile index 666ed85..f9fb1bd 100644 --- a/justfile +++ b/justfile @@ -39,10 +39,15 @@ dump-fixtures DIR="testdata": check-auth retrostar LIMIT="50": go run . retrostar --limit {{LIMIT}} +# Pruning audit: per-feed volume + star stats +feeds DAYS="180" MIN_PER_DAY="0.05" MIN_SEEN="0": check-auth + go run . feeds --days 180 --min-per-day 0.05 --min-seen 0 + # ── retrain loop ── -# Export new labels, train, print the report path. Eyeball report.json -# (precision@15 >= 0.10) before `just ship SERVER` — retrain never deploys. +# Eyeball report.json (precision@15 >= 0.10) before `just ship SERVER` — +# retrain never deploys. +# Export new labels, train, print the report path retrain: check-auth go run . export go run . train labels.jsonl @@ -51,14 +56,15 @@ retrain: check-auth # ── deploy lane (local PC → server) ── # SERVER is an ssh config alias; the agent does auth. No hostnames in this # file. Run `ship` only after eyeballing report.json — retrain never deploys. +# The server state dir is bind-mounted at /data (created once on the VPS; +# see README "Deployment"); fluxrec runs under the compose stack at +# ~/services on the server. -# Ship a freshly trained model.json to the server state dir (bind-mounted -# at /data; created once on the VPS: see README "Deployment") +# Ship a freshly trained model.json to the server state dir deploy SERVER: scp model.json {{SERVER}}:~/fluxrec-data/ -# Bounce the serve container so it picks up the new model (fluxrec runs -# under the compose stack at ~/services on the server) +# Bounce the serve container so it picks up the new model restart SERVER: ssh {{SERVER}} 'cd ~/services && docker-compose restart fluxrec' @@ -68,4 +74,4 @@ ship SERVER: (deploy SERVER) (restart SERVER) # Trigger an out-of-schedule poll (serialized + cooldown-gated server-side) poll-now URL: - curl -sS -X POST {{URL}}/api/poll \ No newline at end of file + curl -sS -X POST {{URL}}/api/poll diff --git a/main.go b/main.go index d5adc15..efd8118 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,8 @@ func main() { cmd = &cmds.ServeCommand{} case "retrostar": cmd = &cmds.RetrostarCommand{} + case "feeds": + cmd = &cmds.FeedsCommand{} default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmdName) printHelp() @@ -69,6 +71,7 @@ Commands: train Train a TF-IDF + LR model from labels.jsonl serve Poll Miniflux, score, serve recommendations RSS (server) retrostar Model-ranked candidates for retroactive starring (cold start) + feeds Subscription audit: per-feed volume + star stats (pruning aid) Usage: echo "some title" | fluxrec score --model model.json diff --git a/miniflux/client.go b/miniflux/client.go index ae23fe3..d430852 100644 --- a/miniflux/client.go +++ b/miniflux/client.go @@ -82,6 +82,18 @@ type Feed struct { Title string `json:"title"` } +// FeedInfo is one subscription as returned by GET /v1/feeds (the pruning- +// audit view; richer than the per-entry Feed subset above). +type FeedInfo struct { + ID int64 `json:"id"` + Title string `json:"title"` + SiteURL string `json:"site_url"` + Disabled bool `json:"disabled"` + Category struct { + Title string `json:"title"` + } `json:"category"` +} + // Entry is the subset of a Miniflux entry fluxrec needs. JSON field names // match the Miniflux API verbatim so decoding is a straight mirror. type Entry struct { @@ -103,6 +115,7 @@ type Entry struct { type Filters struct { Starred *bool // only starred entries when true Status string // "read" | "unread"; "" = any + FeedID int64 // restrict to one feed (feeds-lane volume probes) ChangedAfter int64 // unix timestamp; incremental export cursor PublishedAfter int64 // unix timestamp; only entries published after this AfterEntryID int64 // serve-lane poll cursor @@ -137,10 +150,28 @@ func (c *Client) RawEntries(f Filters) ([]byte, error) { return c.fetchEntries(f) } +// Feeds calls GET /v1/feeds, returning every subscription (the audit lane: +// one call, no paging). +func (c *Client) Feeds() ([]FeedInfo, error) { + body, err := c.getJSON(c.baseURL + "/v1/feeds") + if err != nil { + return nil, err + } + var feeds []FeedInfo + if err := json.Unmarshal(body, &feeds); err != nil { + return nil, fmt.Errorf("decoding /v1/feeds response: %w", err) + } + return feeds, nil +} + // fetchEntries issues the GET and returns the body on HTTP 200. func (c *Client) fetchEntries(f Filters) ([]byte, error) { - u := entriesURL(c.baseURL, f) + return c.getJSON(entriesURL(c.baseURL, f)) +} +// getJSON GETs u with the auth header and returns the body on HTTP 200; +// any other status becomes an error naming the status and a response sniff. +func (c *Client) getJSON(u string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) @@ -174,6 +205,9 @@ func entriesURL(base string, f Filters) string { if f.Status != "" { q.Set("status", f.Status) } + if f.FeedID > 0 { + q.Set("feed_id", strconv.FormatInt(f.FeedID, 10)) + } if f.ChangedAfter > 0 { q.Set("changed_after", strconv.FormatInt(f.ChangedAfter, 10)) } diff --git a/miniflux/client_test.go b/miniflux/client_test.go index 8f8c6ad..e3774a3 100644 --- a/miniflux/client_test.go +++ b/miniflux/client_test.go @@ -80,7 +80,7 @@ const entriesFixture = `{ }` // fakeServer records the last request's auth header and query, then replies -// with the fixture (or an error status). +// with the fixture (or an error status). Accepts /v1/entries and /v1/feeds. type fakeServer struct { t *testing.T status int @@ -98,7 +98,7 @@ func (f *fakeServer) handler() http.HandlerFunc { for k, v := range r.URL.Query() { f.gotQuery[k] = v[0] } - if r.URL.Path != "/v1/entries" { + if r.URL.Path != "/v1/entries" && r.URL.Path != "/v1/feeds" { f.t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -165,6 +165,7 @@ func TestEntriesSendsFilters(t *testing.T) { _, err := client.Entries(Filters{ Starred: &starred, Status: "read", + FeedID: 77, ChangedAfter: 1700000000, PublishedAfter: 1700000000, AfterEntryID: 40999, @@ -178,6 +179,7 @@ func TestEntriesSendsFilters(t *testing.T) { want := map[string]string{ "starred": "true", "status": "read", + "feed_id": "77", "changed_after": "1700000000", "published_after": "1700000000", "after_entry_id": "40999", @@ -225,6 +227,59 @@ func TestEntriesBadJSON(t *testing.T) { } } +const feedsFixture = `[ + { + "id": 77, + "user_id": 3, + "feed_url": "https://example.com/feed.xml", + "site_url": "https://example.com", + "title": "Example Blog", + "disabled": false, + "category": {"id": 1, "title": "Tech", "user_id": 3} + }, + { + "id": 102, + "user_id": 3, + "feed_url": "https://daily.example/rss", + "site_url": "https://daily.example", + "title": "Some Daily", + "disabled": true, + "category": {"id": 2, "title": "News", "user_id": 3} + } +]` + +func TestFeedsDecodesFixture(t *testing.T) { + f := &fakeServer{t: t, status: http.StatusOK, body: feedsFixture} + client := newFakeClient(t, f) + + feeds, err := client.Feeds() + if err != nil { + t.Fatalf("Feeds: %v", err) + } + if !f.sawRequest { + t.Fatal("fake server saw no request") + } + if f.gotHeader != "test-token-123" { + t.Errorf("X-Auth-Token = %q, want test-token-123", f.gotHeader) + } + if len(feeds) != 2 { + t.Fatalf("len(feeds) = %d, want 2", len(feeds)) + } + if feeds[0].ID != 77 || feeds[0].Title != "Example Blog" || feeds[0].SiteURL != "https://example.com" { + t.Errorf("feeds[0] = %+v", feeds[0]) + } + if feeds[0].Disabled { + t.Error("feeds[0].Disabled = true, want false") + } + if !feeds[1].Disabled { + t.Error("feeds[1].Disabled = false, want true") + } + if feeds[0].Category.Title != "Tech" || feeds[1].Category.Title != "News" { + t.Errorf("categories = %q / %q, want Tech / News", + feeds[0].Category.Title, feeds[1].Category.Title) + } +} + func TestConfigFromEnv(t *testing.T) { t.Run("primary vars", func(t *testing.T) { t.Setenv("MINIFLUX_URL", "https://flux.example") -- cgit v1.2.3