// 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 }