Add initial prototype

This commit is contained in:
Dave Gallant
2020-03-31 21:31:52 -04:00
parent 89681da2ca
commit 6be207ffb7
15 changed files with 558 additions and 81 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

35
.goreleaser.yml Normal file
View File

@@ -0,0 +1,35 @@
before:
hooks:
- go mod tidy
- go get -v
- rm -rf dist
builds:
-
env:
- CGO_ENABLED=0
ldflags:
- -s -w
goos:
- darwin
- linux
goarch:
- amd64
archives:
- replacements:
darwin: Darwin
linux: Linux
amd64: x86_64
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
release:
github:
owner: davegallant
name: srv

15
.travis.yml Normal file
View File

@@ -0,0 +1,15 @@
language: go
sudo: false
go:
- "1.14"
before_script:
- make build
- make test
deploy:
- provider: script
api_key: $GITHUB_TOKEN
skip_cleanup: true
script: curl -sL https://git.io/goreleaser | bash
on:
tags: true
verbose: true

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org>

10
Makefile Normal file
View File

@@ -0,0 +1,10 @@
BIN ?= dist/srv
build: ## Builds the binary
go build -o $(BIN)
.PHONY: build
test: ## Run unit tests
go test -v ./...
.PHONY: test

218
cmd/start.go Normal file
View File

@@ -0,0 +1,218 @@
package cmd
import (
"fmt"
"log"
"os/exec"
"os/user"
"github.com/davegallant/srv/internal"
"github.com/jroimartin/gocui"
)
// Controller can access Feeds and Config
var Controller *internal.Controller
var (
viewArr = []string{"feeds", "Items"}
active = 0
currentFeed = 0 // TODO: move to Controller
)
func cursorDown(g *gocui.Gui, v *gocui.View) error {
if v != nil {
cx, cy := v.Cursor()
if err := v.SetCursor(cx, cy+1); err != nil {
v.SetCursor(cx, cy-1)
}
}
return nil
}
func cursorUp(g *gocui.Gui, v *gocui.View) error {
if v != nil {
ox, oy := v.Origin()
cx, cy := v.Cursor()
if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 {
if err := v.SetOrigin(ox, oy-1); err != nil {
return err
}
}
}
return nil
}
// openFeed opens all items in the feed
func openFeed(g *gocui.Gui, v *gocui.View) error {
_, cy := v.Cursor()
currentFeed = cy
feed := Controller.Rss.Feeds[currentFeed]
ov, _ := g.View("Items")
ov.Clear()
for _, item := range feed.Items {
fmt.Fprintln(ov, "-", item.Title)
}
nextView(g, ov)
return nil
}
// openItem opens the feed in an external browser
func openItem(g *gocui.Gui, v *gocui.View) error {
_, cy := v.Cursor()
item := Controller.Rss.Feeds[currentFeed].Items[cy]
viewer := Controller.Config.ExternalViewer
err := exec.Command(viewer, item.Link).Start()
if err != nil {
log.Fatal(err)
}
return nil
}
func showLoading(g *gocui.Gui) error {
maxX, maxY := g.Size()
if v, err := g.SetView("loading", maxX/2-4, maxY/2-1, maxX/2+4, maxY/2+1); err != nil {
if err != gocui.ErrUnknownView {
return err
}
fmt.Fprintln(v, "Loading")
}
return nil
}
func hideLoading(g *gocui.Gui) error {
if err := g.DeleteView("loading"); err != nil {
if err != gocui.ErrUnknownView {
return err
}
}
return nil
}
func refreshFeeds(g *gocui.Gui, v *gocui.View) error {
showLoading(g)
Controller.Rss.Update()
//hideLoading(g)
return nil
}
// statically map all of the keys
func keybindings(g *gocui.Gui) error {
if err := g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, nextView); err != nil {
return err
}
if err := g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, cursorDown); err != nil {
return err
}
if err := g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, cursorUp); err != nil {
return err
}
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
return err
}
if err := g.SetKeybinding("feeds", gocui.KeyEnter, gocui.ModNone, openFeed); err != nil {
return err
}
if err := g.SetKeybinding("Items", gocui.KeyEnter, gocui.ModNone, openItem); err != nil {
return err
}
if err := g.SetKeybinding("", gocui.KeyF5, gocui.ModNone, refreshFeeds); err != nil {
return err
}
return nil
}
func setCurrentViewOnTop(g *gocui.Gui, name string) (*gocui.View, error) {
if _, err := g.SetCurrentView(name); err != nil {
return nil, err
}
return g.SetViewOnTop(name)
}
func nextView(g *gocui.Gui, v *gocui.View) error {
nextIndex := (active + 1) % len(viewArr)
name := viewArr[nextIndex]
_, err := g.View("Items")
if err != nil {
return err
}
if _, err := setCurrentViewOnTop(g, name); err != nil {
return err
}
if nextIndex == 0 || nextIndex == 3 {
g.Cursor = true
} else {
g.Cursor = false
}
active = nextIndex
return nil
}
func layout(g *gocui.Gui) error {
maxX, maxY := g.Size()
if v, err := g.SetView("feeds", 0, 0, maxX-1, maxY/4-1); err != nil {
if err != gocui.ErrUnknownView {
return err
}
v.Title = "Feeds"
v.Highlight = true
v.SelBgColor = gocui.ColorGreen
v.SelFgColor = gocui.ColorBlack
if _, err = setCurrentViewOnTop(g, "feeds"); err != nil {
return err
}
for _, f := range Controller.Rss.Feeds {
fmt.Fprintln(v, "-", f.Title)
}
}
if v, err := g.SetView("Items", 0, maxY/4, maxX-1, maxY-1); err != nil {
if err != gocui.ErrUnknownView {
return err
}
v.Title = "Items"
v.Highlight = true
v.SelBgColor = gocui.ColorGreen
v.SelFgColor = gocui.ColorBlack
}
return nil
}
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}
// Start initializes the application
func Start() {
usr, err := user.Current()
if err != nil {
log.Fatal(err)
}
configPath := usr.HomeDir + "/.config/srv/config.yaml"
Controller = &internal.Controller{}
Controller.Init(configPath)
g, err := gocui.NewGui(gocui.OutputNormal)
if err != nil {
log.Panicln(err)
}
defer g.Close()
g.SetManagerFunc(layout)
if err := keybindings(g); err != nil {
log.Panicln(err)
}
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
log.Panicln(err)
}
}

11
config-example.yaml Normal file
View File

@@ -0,0 +1,11 @@
---
feeds:
- https://news.ycombinator.com/rss
- https://www.reddit.com/r/golang/.rss
- https://www.reddit.com/r/linux/.rss
- https://www.zdnet.com/topic/security/rss.xml
- https://aws.amazon.com/blogs/security/feed/
- https://www.archlinux.org/feeds/news/
# Optionally define an an external application viewer
#externalViewer: firefox

View File

@@ -1,9 +0,0 @@
{
"feeds": [
"https://news.ycombinator.com/rss",
"https://www.reddit.com/r/golang/.rss",
"https://www.reddit.com/r/linux/.rss",
"https://www.zdnet.com/topic/security/rss.xml",
"https://aws.amazon.com/blogs/security/feed/"
]
}

11
go.mod
View File

@@ -1,15 +1,20 @@
module github.com/davegallant/rssgo
module github.com/davegallant/srv
go 1.14
require (
github.com/EDDYCJY/fake-useragent v0.2.0
github.com/PuerkitoBio/goquery v1.5.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jroimartin/gocui v0.4.0
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-runewidth v0.0.8 // indirect
github.com/mmcdole/gofeed v1.0.0-beta2
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
github.com/nsf/termbox-go v0.0.0-20200204031403-4d2b513ad8be // indirect
github.com/stretchr/testify v1.5.1 // indirect
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect
github.com/stretchr/testify v1.5.1
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect
golang.org/x/text v0.3.2 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v2 v2.2.8
)

18
go.sum
View File

@@ -1,11 +1,20 @@
github.com/EDDYCJY/fake-useragent v0.2.0 h1:Jcnkk2bgXmDpX0z+ELlUErTkoLb/mxFBNd2YdcpvJBs=
github.com/EDDYCJY/fake-useragent v0.2.0/go.mod h1:5wn3zzlDxhKW6NYknushqinPcAqZcAPHy8lLczCdJdc=
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jroimartin/gocui v0.4.0 h1:52jnalstgmc25FmtGcWqa0tcbMEWS6RpFLsOIO+I+E8=
github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mmcdole/gofeed v1.0.0-beta2 h1:CjQ0ADhAwNSb08zknAkGOEYqr8zfZKfrzgk9BxpWP2E=
@@ -22,14 +31,19 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

85
internal/config.go Normal file
View File

@@ -0,0 +1,85 @@
package internal
import (
"errors"
"io/ioutil"
"log"
"os"
"runtime"
"gopkg.in/yaml.v2"
)
// Configuration stores the global config
type Configuration struct {
Feeds []string `yaml:"feeds"`
ExternalViewer string `yaml:"externalViewer,omitempty"`
}
// DefaultConfiguration can be used if a config is missing
var DefaultConfiguration = Configuration{
Feeds: []string{
"https://news.ycombinator.com/rss",
"https://www.reddit.com/r/golang/.rss",
"https://www.zdnet.com/topic/security/rss.xml",
},
}
// Determines the default viewer
func DetermineExternalViewer() (string, error) {
switch os := runtime.GOOS; os {
case "linux":
return "xdg-open", nil
case "darwin":
return "open", nil
}
return "", errors.New("Unable to determine a default external viewer")
}
// LoadConfiguration takes a filename (configuration) and loads it.
func LoadConfiguration(file string) Configuration {
var config Configuration
// If the configuration file does not exist,
// write a default config
_, err := os.Stat(file)
if os.IsNotExist(err) {
WriteConfig(DefaultConfiguration, file)
}
data, err := ioutil.ReadFile(file)
if err != nil {
log.Println(err)
os.Exit(1)
}
if config.ExternalViewer == "" {
config.ExternalViewer, err = DetermineExternalViewer()
if err != nil {
log.Println(err)
os.Exit(1)
}
}
err = yaml.Unmarshal(data, &config)
if err != nil {
log.Panicln(err)
}
return config
}
// WriteConfig writes a config to disk
func WriteConfig(config Configuration, file string) error {
c, err := yaml.Marshal(&config)
if err != nil {
log.Fatalf("Unable to marshal default config: %v", err)
}
err = ioutil.WriteFile(file, c, 0644)
if err != nil {
log.Fatalf("Unable to write default config: %v", err)
}
return nil
}

36
internal/config_test.go Normal file
View File

@@ -0,0 +1,36 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestLoadConfiguration tests loading the example config
func TestLoadConfiguration(t *testing.T) {
exampleConfig := LoadConfiguration("../config-example.yaml")
expectedFeeds := []string{
"https://news.ycombinator.com/rss",
"https://www.reddit.com/r/golang/.rss",
"https://www.reddit.com/r/linux/.rss",
"https://www.zdnet.com/topic/security/rss.xml",
"https://aws.amazon.com/blogs/security/feed/",
"https://www.archlinux.org/feeds/news/",
}
assert.Equal(
t,
expectedFeeds,
exampleConfig.Feeds,
"Expected configuration does not match.",
)
// ExternalViewer should default to either 'xdg-open' on Linux,
// or 'open' on macOS
assert.Contains(
t,
[]string{"xdg-open", "open"},
exampleConfig.ExternalViewer,
)
}

18
internal/controller.go Normal file
View File

@@ -0,0 +1,18 @@
package internal
import "time"
// Controller keeps everything together
type Controller struct {
Config Configuration
lastUpdate time.Time
Rss *RSS
}
// Init initiates the controller with config
func (c *Controller) Init(config string) {
c.Config = LoadConfiguration(config)
c.Rss = &RSS{}
c.Rss.New(c)
c.Rss.Update()
}

79
internal/rss.go Normal file
View File

@@ -0,0 +1,79 @@
package internal
import (
"fmt"
"log"
"net/http"
"sync"
browser "github.com/EDDYCJY/fake-useragent"
"github.com/mmcdole/gofeed"
)
type RSS struct {
Feeds []*gofeed.Feed
c *Controller
}
func (r *RSS) New(c *Controller) {
r.c = c
}
// Update fetches all articles for all feeds
func (r *RSS) Update() {
fp := gofeed.NewParser()
var wg sync.WaitGroup
var mux sync.Mutex
r.Feeds = []*gofeed.Feed{}
for _, f := range r.c.Config.Feeds {
f := f
wg.Add(1)
go func() {
defer wg.Done()
feed, err := r.FetchURL(fp, f)
if err != nil {
log.Printf("error fetching url: %s, err: %v", f, err)
}
mux.Lock()
if feed != nil {
r.Feeds = append(r.Feeds, feed)
}
mux.Unlock()
}()
}
wg.Wait()
}
// FetchURL fetches the feed URL and parses it
func (r *RSS) FetchURL(fp *gofeed.Parser, url string) (feed *gofeed.Feed, err error) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
userAgent := browser.Firefox()
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp != nil {
defer func() {
ce := resp.Body.Close()
if ce != nil {
err = ce
}
}()
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Failed to get url %v, %v", resp.StatusCode, resp.Status)
}
return fp.Parse(resp.Body)
}

69
main.go
View File

@@ -1,74 +1,9 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"github.com/jroimartin/gocui"
"github.com/mmcdole/gofeed"
)
var Configuration struct {
Feeds []string
}
var filename = flag.String("config", "config.json", "Location of the config file.")
import "github.com/davegallant/srv/cmd"
func main() {
flag.Parse()
data, err := ioutil.ReadFile(*filename)
if err != nil {
log.Panicln(err)
}
if err != nil {
log.Panicln(err)
}
err = json.Unmarshal(data, &Configuration)
if err != nil {
log.Panicln(err)
}
cmd.Start()
g, err := gocui.NewGui(gocui.OutputNormal)
if err != nil {
log.Panicln(err)
}
defer g.Close()
g.SetManagerFunc(layout)
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
log.Panicln(err)
}
}
func layout(g *gocui.Gui) error {
maxX, maxY := g.Size()
if v, err := g.SetView("feeds", maxX/2-26, maxY/5, maxX/2+8, maxY/2+6); err != nil {
if err != gocui.ErrUnknownView {
return err
}
fp := gofeed.NewParser()
for _, f := range Configuration.Feeds {
feed, err := fp.ParseURL(f)
if err != nil {
fmt.Println(err)
}
if feed != nil {
fmt.Fprintln(v, feed.Title)
}
}
}
return nil
}
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}