github.com/wtfutil/wtf@v0.43.0/modules/feedreader/widget.go (about) 1 package feedreader 2 3 import ( 4 "crypto/tls" 5 "fmt" 6 "html" 7 "net/http" 8 "regexp" 9 "sort" 10 "strings" 11 12 "github.com/mmcdole/gofeed" 13 "github.com/rivo/tview" 14 "github.com/wtfutil/wtf/utils" 15 "github.com/wtfutil/wtf/view" 16 "jaytaylor.com/html2text" 17 ) 18 19 type ShowType int 20 21 const ( 22 SHOW_TITLE ShowType = iota 23 SHOW_LINK 24 SHOW_CONTENT 25 ) 26 27 // FeedItem represents an item returned from an RSS or Atom feed 28 type FeedItem struct { 29 item *gofeed.Item 30 sourceTitle string 31 viewed bool 32 } 33 34 // Widget is the container for RSS and Atom data 35 type Widget struct { 36 view.ScrollableWidget 37 38 stories []*FeedItem 39 parser *gofeed.Parser 40 settings *Settings 41 err error 42 showType ShowType 43 } 44 45 func rotateShowType(showtype ShowType) ShowType { 46 returnValue := SHOW_TITLE 47 switch showtype { 48 case SHOW_TITLE: 49 returnValue = SHOW_LINK 50 case SHOW_LINK: 51 returnValue = SHOW_CONTENT 52 case SHOW_CONTENT: 53 returnValue = SHOW_TITLE 54 } 55 return returnValue 56 } 57 58 // NewWidget creates a new instance of a widget 59 func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget { 60 parser := gofeed.NewParser() 61 if settings.disableHTTP2 { 62 // If HTTP/2 is disabled, we override the parser client 63 // with a client using a simple HTTP transport which 64 // removes the client's default behavior of first 65 // trying HTTP/2 before downgrading to older protocol 66 // versions. 67 parser.Client = &http.Client{ 68 Transport: &http.Transport{ 69 TLSClientConfig: &tls.Config{ 70 MinVersion: tls.VersionTLS12, 71 MaxVersion: tls.VersionTLS13, 72 }, 73 }, 74 } 75 } 76 77 parser.UserAgent = settings.userAgent 78 79 widget := &Widget{ 80 ScrollableWidget: view.NewScrollableWidget(tviewApp, redrawChan, pages, settings.Common), 81 82 parser: parser, 83 settings: settings, 84 showType: SHOW_TITLE, 85 } 86 87 widget.SetRenderFunction(widget.Render) 88 widget.initializeKeyboardControls() 89 90 return widget 91 } 92 93 /* -------------------- Exported Functions -------------------- */ 94 95 // Fetch retrieves RSS and Atom feed data 96 func (widget *Widget) Fetch(feedURLs []string) ([]*FeedItem, error) { 97 var data []*FeedItem 98 99 for _, feedURL := range feedURLs { 100 feedItems, err := widget.fetchForFeed(feedURL) 101 if err != nil { 102 return nil, err 103 } 104 105 data = append(data, feedItems...) 106 } 107 108 data = widget.sort(data) 109 110 return data, nil 111 } 112 113 // Refresh updates the data in the widget 114 func (widget *Widget) Refresh() { 115 feedItems, err := widget.Fetch(widget.settings.feeds) 116 if err != nil { 117 widget.err = err 118 widget.stories = nil 119 widget.SetItemCount(0) 120 } else { 121 widget.err = nil 122 widget.stories = feedItems 123 widget.SetItemCount(len(feedItems)) 124 } 125 126 widget.Render() 127 } 128 129 // Render sets up the widget data for redrawing to the screen 130 func (widget *Widget) Render() { 131 widget.Redraw(widget.content) 132 } 133 134 /* -------------------- Unexported Functions -------------------- */ 135 136 func (widget *Widget) fetchForFeed(feedURL string) ([]*FeedItem, error) { 137 var ( 138 feed *gofeed.Feed 139 err error 140 ) 141 if auth, isPrivateRSS := widget.settings.credentials[feedURL]; isPrivateRSS { 142 widget.parser.AuthConfig = &gofeed.Auth{ 143 Username: auth.username, 144 Password: auth.password, 145 } 146 feed, err = widget.parser.ParseURL(feedURL) 147 widget.parser.AuthConfig = nil 148 } else { 149 feed, err = widget.parser.ParseURL(feedURL) 150 } 151 152 if err != nil { 153 return nil, err 154 } 155 156 var feedItems []*FeedItem 157 158 for idx, gofeedItem := range feed.Items { 159 if widget.settings.feedLimit >= 1 && idx >= widget.settings.feedLimit { 160 // We only want to get the widget.settings.feedLimit latest articles, 161 // not all of them. To get all, set feedLimit to < 1 162 break 163 } 164 165 feedItem := &FeedItem{ 166 item: gofeedItem, 167 sourceTitle: feed.Title, 168 viewed: false, 169 } 170 171 feedItems = append(feedItems, feedItem) 172 } 173 174 return feedItems, nil 175 } 176 177 func (widget *Widget) content() (string, string, bool) { 178 title := widget.CommonSettings().Title 179 if widget.err != nil { 180 return title, widget.err.Error(), true 181 } 182 data := widget.stories 183 if len(data) == 0 { 184 return title, "No data", false 185 } 186 var str string 187 188 for idx, feedItem := range data { 189 rowColor := widget.RowColor(idx) 190 191 if feedItem.viewed { 192 // Grays out viewed items in the list, while preserving background highlighting when selected 193 rowColor = "gray" 194 if idx == widget.Selected { 195 rowColor = fmt.Sprintf("gray:%s", widget.settings.Colors.RowTheme.HighlightedBackground) 196 } 197 } 198 199 displayText := widget.getShowText(feedItem, rowColor) 200 201 row := fmt.Sprintf( 202 "[%s]%2d. %s[white]", 203 rowColor, 204 idx+1, 205 displayText, 206 ) 207 208 str += utils.HighlightableHelper(widget.View, row, idx, len(feedItem.item.Title)) 209 } 210 211 return title, str, false 212 } 213 214 func (widget *Widget) getShowText(feedItem *FeedItem, rowColor string) string { 215 if feedItem == nil { 216 return "" 217 } 218 219 space := regexp.MustCompile(`\s+`) 220 source := "" 221 publishDate := "" 222 title := space.ReplaceAllString(feedItem.item.Title, " ") 223 224 if widget.settings.showSource && feedItem.sourceTitle != "" { 225 source = "[" + widget.settings.colors.source + "]" + feedItem.sourceTitle + " " 226 } 227 if widget.settings.showPublishDate && feedItem.item.Published != "" { 228 publishDate = "[" + widget.settings.colors.publishDate + "]" + feedItem.item.PublishedParsed.Format(widget.settings.dateFormat) + " " 229 } 230 231 // Convert any escaped characters to their character representation 232 title = html.UnescapeString(source + publishDate + "[" + rowColor + "]" + title) 233 234 switch widget.showType { 235 case SHOW_LINK: 236 return feedItem.item.Link 237 case SHOW_CONTENT: 238 text, _ := html2text.FromString(feedItem.item.Content, html2text.Options{PrettyTables: true}) 239 return strings.TrimSpace(title + "\n" + strings.TrimSpace(text)) 240 default: 241 return title 242 } 243 } 244 245 // feedItems are sorted by published date 246 func (widget *Widget) sort(feedItems []*FeedItem) []*FeedItem { 247 sort.Slice(feedItems, func(i, j int) bool { 248 return feedItems[i].item.PublishedParsed != nil && 249 feedItems[j].item.PublishedParsed != nil && 250 feedItems[i].item.PublishedParsed.After(*feedItems[j].item.PublishedParsed) 251 }) 252 253 return feedItems 254 } 255 256 func (widget *Widget) openStory() { 257 sel := widget.GetSelected() 258 259 if sel >= 0 && widget.stories != nil && sel < len(widget.stories) { 260 story := widget.stories[sel] 261 story.viewed = true 262 263 utils.OpenFile(story.item.Link) 264 } 265 } 266 267 func (widget *Widget) toggleDisplayText() { 268 widget.showType = rotateShowType(widget.showType) 269 widget.Render() 270 }