// RSS rendering for the recommendations feed: hand-rolled RSS 2.0 via // encoding/xml, no feed library. The render source is the runs.jsonl tail; // items appear newest batch first, kept order within a batch. Guid embeds // the original Miniflux entry ID (mf:) for traceability. package cmds import ( "encoding/xml" "fmt" "time" "unicode/utf8" "fluxrec/core" ) // Cap on the source-feed name in item titles: feed titles can be // arbitrarily long, and the suffix must stay compact so list views keep // showing the article title itself. const maxFeedTitleRunes = 40 const rssXMLHeader = `` + "\n" type rssGUID struct { IsPermaLink bool `xml:"isPermaLink,attr"` Value string `xml:",chardata"` } type rssItem struct { Title string `xml:"title"` Link string `xml:"link"` GUID rssGUID `xml:"guid"` PubDate string `xml:"pubDate"` Description string `xml:"description"` } type rssChannel struct { Title string `xml:"title"` Description string `xml:"description"` Items []rssItem `xml:"item"` } type rssDocument struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` Channel rssChannel `xml:"channel"` } // RenderRSS renders runs (oldest to newest, as read from runs.jsonl) as an // RSS 2.0 document. Each item title gets the source feed as a rune-capped // suffix (readers show the channel title as the source of every item, so // this is the only in-list provenance), and the description body opens // with feed + model score + the batch-wide median score. Item content was // already cleaned when the run row was written. func RenderRSS(runs []core.RunRow) ([]byte, error) { doc := rssDocument{ Version: "2.0", Channel: rssChannel{ Title: "fluxrec recommendations", Description: "Top-scored Miniflux entries, refreshed on a schedule.", }, } for i := len(runs) - 1; i >= 0; i-- { for _, k := range runs[i].Kept { doc.Channel.Items = append(doc.Channel.Items, rssItem{ Title: rssItemTitle(k), Link: k.URL, GUID: rssGUID{IsPermaLink: false, Value: fmt.Sprintf("mf:%d", k.EntryID)}, PubDate: k.PublishedAt.Format(time.RFC1123Z), Description: rssDescription(k, runs[i].ScoreMedian), }) } } body, err := xml.MarshalIndent(doc, "", " ") if err != nil { return nil, fmt.Errorf("marshaling RSS: %w", err) } return append([]byte(rssXMLHeader), body...), nil } // rssItemTitle builds the displayed title: article title first, then the // (rune-capped) source feed as a suffix so narrow list views truncate the // provenance, not the title itself. func rssItemTitle(k core.KeptItem) string { feed := truncateRunes(k.FeedTitle, maxFeedTitleRunes) if feed == "" { return k.Title } return k.Title + " — " + feed } func truncateRunes(s string, max int) string { if utf8.RuneCountInString(s) <= max { return s } return string([]rune(s)[:max]) + "…" } // rssDescription builds the item body: first a headline line with the // source feed, model score, and batch-wide median (omitted for run rows // written before the median was logged), then the cleaned content (if any). func rssDescription(k core.KeptItem, median *float64) string { head := fmt.Sprintf("score %.2f", k.Score) if median != nil { head += fmt.Sprintf(" · batch median %.2f", *median) } if k.FeedTitle != "" { head = k.FeedTitle + " · " + head } if k.Content == "" { return head } return head + "\n\n" + k.Content }