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  }