~chrisppy/beagles

33f5788d7230cb8a0ebbba38949e359250016f52 — Chris Palmer 30 days ago 969e2d6
Fix default config and finish adding gemini support
M CHANGELOG.md => CHANGELOG.md +3 -0
@@ 7,6 7,7 @@
    better file validation and a cleaner format (~chrisppy)

### Added
  - Added support for feeds using the gemini protocol (~chrisppy)

### Changed
  - Update to cview 1.5.0 (~chrisppy)


@@ 17,6 18,8 @@

### Fixed
  - man pages have been cleaned up (~chrisppy)
  - Stop locking the application when a webpage is opened (~chrisppy)
  - Fix typos in default config (~chrisppy)

### Removed


M config/config.go => config/config.go +21 -24
@@ 682,14 682,14 @@ func getChildTheme(name string, bg string, fg string, c scfg.Block) (string, str

	bga := ""
	if v, err := getValue(backgroundColor, e, true); err != nil {
		errors[err] = false
		errors[fmt.Errorf("%s: %s", name, err.Error())] = false
	} else {
		bga = v
	}

	fga := ""
	if v, err := getValue(foregroundColor, e, true); err != nil {
		errors[err] = false
		errors[fmt.Errorf("%s: %s", name, err.Error())] = false
	} else {
		fga = v
	}


@@ 792,32 792,29 @@ func defaultConfig() *Config {
}

func defCfgBytes() []byte {
	return []byte(`auto-update-interval "0"

key-commands {
	down "j"
	download "d"
	favorite "F"
	left "h"
	list "L"
	mark-favorite "f"
	mark-read "m"
	mark-unfavorite "c"
	mark-unread "u"
	open-url "n"
	page-down "PgDn"
	page-up "PgUp"
	play "p"
	right "l"
	subscription "S"
	up "k"
	return []byte(`key-commands {
	down j
	download d
	favorite F
	left h
	list L
	mark-favorite f
	mark-read m
	unmark-favorite c
	mark-unread u
	open-url n
	page-down PgDn
	page-up PgUp
	play p
	right l
	subscription S
	up k
}

podcast {
	auto-download "false"
	auto-download false

	external-player "mpv" "--speed=1.5" "[FILE]"
	}
	external-player mpv --speed=1.5 [FILE]
}

theme {

M db/db.go => db/db.go +15 -13
@@ 26,11 26,12 @@ import (

// Storage contains the main data needed to run the app.
type Storage struct {
	Feeds     Feeds
	Items     Items
	Queue     Queue
	Favorites Favorites
	Path      string
	Feeds      Feeds
	Items      Items
	Queue      Queue
	Favorites  Favorites
	Path       string
	GeminiPath string
}

func openDB(path string) (*bolt.DB, error) {


@@ 42,18 43,19 @@ func openDB(path string) (*bolt.DB, error) {
}

// ReadDB will grab all data from the database to use while running the app.
func ReadDB(path string) (*Storage, error) {
func ReadDB(path string, gmniPath string) (*Storage, error) {
	db, err := openDB(path)
	if err != nil {
		return nil, err
	}

	s := &Storage{
		Feeds:     make(Feeds),
		Items:     make(Items),
		Queue:     make(Queue),
		Favorites: make(Favorites),
		Path:      path,
		Feeds:      make(Feeds),
		Items:      make(Items),
		Queue:      make(Queue),
		Favorites:  make(Favorites),
		Path:       path,
		GeminiPath: gmniPath,
	}

	if err := s.Feeds.Read(db); err != nil {


@@ 95,7 97,7 @@ func (s *Storage) CreateFeed(url string) (map[string]*Item, error) {
		return nil, err
	}

	items, err := s.Feeds.Insert(db, url)
	items, err := s.Feeds.Insert(db, url, s.GeminiPath)
	if err != nil {
		if err := db.Close(); err != nil {
			return nil, err


@@ 192,7 194,7 @@ func (s *Storage) Update() (map[string]*Item, error) {
	for _, f := range s.Feeds {
		updateURL := f.UpdateURL

		items, err := f.FindNewItems(s.Items)
		items, err := f.FindNewItems(s.Items, s.GeminiPath)
		if err != nil {
			if err := db.Close(); err != nil {
				return nil, err

M db/feed.go => db/feed.go +4 -4
@@ 69,8 69,8 @@ func processFeed(feed *gofeed.Feed, url string) *Feed {

// FindNewItems will process the items under the feed and add return any new
// ones since last update
func (f *Feed) FindNewItems(citems map[string]*Item) (map[string]*Item, error) {
	feed, err := util.ParseFeed(f.UpdateURL)
func (f *Feed) FindNewItems(citems map[string]*Item, gmniPath string) (map[string]*Item, error) {
	feed, err := util.ParseFeed(f.UpdateURL, gmniPath)
	if err != nil {
		return nil, fmt.Errorf("unable to fetch url: %s", err.Error())
	}


@@ 137,12 137,12 @@ func (f Feeds) Read(db *bolt.DB) error {
}

// Insert feed into database by url
func (f Feeds) Insert(db *bolt.DB, url string) ([]*gofeed.Item, error) {
func (f Feeds) Insert(db *bolt.DB, url string, gmniPath string) ([]*gofeed.Item, error) {
	if _, ok := f[url]; ok {
		return nil, fmt.Errorf("already subscribed to feed: %s", url)
	}

	pfeed, err := util.ParseFeed(url)
	pfeed, err := util.ParseFeed(url, gmniPath)
	if err != nil {
		return nil, fmt.Errorf("unable to fetch url: %s", err.Error())
	}

M doc/beagles-config.5.scd => doc/beagles-config.5.scd +44 -44
@@ 13,42 13,42 @@ created.  The configuration file follows the scfg standard

# OPTIONS

*auto-update-interval* "<duration>"
*auto-update-interval* <duration>
	Time between automatic feed update checks, specified by++
numbers with suffixes to denote the units, e.g. "90m",++
"1h30m45s".  Setting this to "0" disables the feature.
numbers with suffixes to denote the units, e.g. 90m, 1h30m45s.++
Setting this to 0 or removing the line will disable the feature.

## THEME

Theme is defined within a _theme_ _{_ _}_ block.  Colors must be specified++
either as W3C color names or RGB hexadecimal strings(e.g. #88c0d0).++
Also Note that if the object colors are not defined, it will inherit++
the colors from *background-color* and *foreground-color*.  Note this++
is a required block.
either as W3C color names(e.g. Blue) or RGB hexadecimal++
strings(e.g. "#88c0d0").  Also Note that if the object colors are not++
defined, it will inherit the colors from *background-color* and++
*foreground-color*.  Note this is a required block.

*background-color* "<color>"
*background-color* <color>
	Background color of the application when not set in sub blocks.

*foreground-color* "<color>"
*foreground-color* <color>
	Foreground color of the application when not set in sub blocks.

*error-color* "<color>"
*error-color* <color>
	Text color of error messages.

*separator-color* "<color>"
*separator-color* <color>
	Color of separator objects.

*read-color* "<color>"
*read-color* <color>
	Text color of items marked as read.

*COMMAND LINE*
	Command line is defined within a _command-line_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the command line.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the command line.




@@ 56,20 56,20 @@ _theme_ _{_ _}_ block.
	Status line is defined within a _status-line_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the status line.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the status line.

*TITLE BAR*
	Title bar is defined within a _title-bar_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the title bar.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the title bar.

*LIST*


@@ 77,10 77,10 @@ _theme_ _{_ _}_ block.
_theme_ _{_ _}_ block.  This will theme the LIST and++
FAVORITES pages.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the list.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the list.

*TREE*


@@ 88,10 88,10 @@ FAVORITES pages.
_theme_ _{_ _}_ block.  This will theme the SUBSCRIPTION++
page.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the tree.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the tree.

*CONTENT*


@@ 99,10 99,10 @@ page.
_theme_ _{_ _}_ block.  This will theme the reading panes++
with the LIST, SUBSCRIPTIONS, FAVORITES, and HELP pages.

	*background-color* "<color>"
	*background-color* <color>
		Background color of the content.

	*foreground-color* "<color>"
	*foreground-color* <color>
		Foreground color of the content.

## KEY COMMANDS


@@ 112,65 112,65 @@ can be either a single character or defined by the KeyNames in++
the tcell library (e.g. PgUp).  Note this is a required block.


*list* "<key>"
*list* <key>
	Key used to switch the list page.

*subscription* "<key>"
*subscription* <key>
	Key used to switch the subscription page.

*favorite* "<key>"
*favorite* <key>
	Key used to switch the favorite page.

*mark-read* "<key>"
*mark-read* <key>
	Key used to mark a post as read.

*mark-unread* "<key>"
*mark-unread* <key>
	Key used to mark a post as unread.

*mark-favorite* "<key>"
*mark-favorite* <key>
	Key used to mark a post as favorite.

*unmark-favorite* "<key>"
*unmark-favorite* <key>
	Key used to unmark a post as favorite.

*open-url* "<key>"
*open-url* <key>
	Key used to open the url of the post.

*download* "<key>"
*download* <key>
	Key used to download a single podcast.

*play* "<key>"
*play* <key>
	Key used to play a podcast.

*left* "<key>"
*left* <key>
	Key, other than the left arrow, to move to the++
left section.

*up* "<key>"
*up* <key>
	Key, other than the up arrow, to move up in the++
current	section.

*down* "<key>"
*down* <key>
	Key, other than the down arrow, to move down in++
the current section.

*right* "<key>"
*right* <key>
	Key, other than the right arrow, to move to the++
right section.

*page-up* "<key>"
*page-up* <key>
	Key to navigate upward quicker in the current++
section.

*page-down* "<key>"
*page-down* <key>
	Key to navigate downward quicker in the current++
section.

## BROWSER

Browser is defined within _browser_ "http"|"gemini" _{_ _}_ blocks.
Browser is defined within _browser_ http|gemini _{_ _}_ blocks.

*navigate* "<command>"
*navigate* <command>
	The command to open the browser, must have [URL]++
as one of the arguments as that will substitute the++
url to open.++


@@ 180,11 180,11 @@ Example: navigate "xdg-open" "[URL]"

Podcast is defined within a _podcast_ _{_ _}_ block.

*auto-download* "true"|"false"
*auto-download* true|false
	New podcasts that are found during an update will++
be downloaded.

*external-player* "<command>"
*external-player* <command>
	The command to open the podcast with the media player,++
must have [FILE] as one of the arguments as that will++
substitute the podcast file to play.++

M doc/beagles.1.scd => doc/beagles.1.scd +4 -0
@@ 25,6 25,10 @@ or even play the podcasts available on the feed.
*-D*
	Print debug lines.

*-g [directory]*
	Set the directory for your gemini known_hosts.  If not set, use++
*XDG_DATA_HOME*/gemini.

*-h*
	Print basic usage options.


M go.mod => go.mod +1 -0
@@ 3,6 3,7 @@ module git.sr.ht/~chrisppy/beagles
go 1.14

require (
	git.sr.ht/~adnano/go-gemini v0.1.3
	git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc
	git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3
	github.com/DataDrake/waterlog v1.0.5

M go.sum => go.sum +2 -0
@@ 1,3 1,5 @@
git.sr.ht/~adnano/go-gemini v0.1.3 h1:uClB4mzTkHBMKBde63/EzrsIhRuHxxaNHVRf1/gApXU=
git.sr.ht/~adnano/go-gemini v0.1.3/go.mod h1:If1VxEWcZDrRt5FeAFnGTcM2Ud1E3BXs3VJ5rnZWKq0=
git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc h1:51BD67xFX+bozd3ZRuOUfalrhx4/nQSh6A9lI08rYOk=
git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc/go.mod h1:t+Ww6SR24yYnXzEWiNlOY0AFo5E9B73X++10lrSpp4U=
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3 h1:4wDp4BKF7NQqoh73VXpZsB/t1OEhDpz/zEpmdQfbjDk=

M main.go => main.go +34 -9
@@ 42,12 42,12 @@ func main() {
	logger.SetLevel(level.Info)
	logger.SetFormat(format.Un)

	opts, optind, err := getopt.Getopts(os.Args, "c:d:Dhl:s:V")
	opts, optind, err := getopt.Getopts(os.Args, "c:d:Dg:hl:s:V")
	if err != nil {
		logger.Fatal(err)
	}

	var configDir, dbDir, dlDir, logDir string
	var configDir, dbDir, dlDir, logDir, gmniDir string
	for _, opt := range opts {
		switch opt.Option {
		case 'c':


@@ 56,6 56,8 @@ func main() {
			dbDir = opt.Value
		case 'D':
			logger.SetFormat(level.Debug)
		case 'g':
			gmniDir = opt.Value
		case 'h':
			printHelp(logger)
		case 'l':


@@ 71,12 73,17 @@ func main() {
		logger.Debugln(arg)
	}

	gmniPath, err := getGeminiPath(gmniDir)
	if err != nil {
		logger.Fatal(err)
	}

	cfg, err := getConfig(configDir)
	if err != nil {
		logger.Fatal(err)
	}

	db, err := getDatabase(dbDir)
	db, err := getDatabase(dbDir, gmniPath)
	if err != nil {
		logger.Fatal(err)
	}


@@ 97,6 104,7 @@ func main() {
		DB:           db,
		Config:       cfg,
		DownloadPath: dlDir,
		GeminiPath:   gmniPath,
		Logger:       logger,
	}



@@ 111,7 119,7 @@ func main() {
	}
}

func getDatabase(dir string) (*db.Storage, error) {
func getDatabase(dir string, gmniPath string) (*db.Storage, error) {
	if dir == "" {
		d, err := os.UserHomeDir()
		if err != nil {


@@ 127,7 135,7 @@ func getDatabase(dir string) (*db.Storage, error) {
		}
	}

	return db.ReadDB(filepath.Clean(filepath.Join(dir, "beagles.db")))
	return db.ReadDB(filepath.Clean(filepath.Join(dir, "beagles.db")), gmniPath)
}

func getConfig(dir string) (*config.Config, error) {


@@ 192,19 200,36 @@ func getDownloadDir(dir string) (string, error) {
	return dir, nil
}

func getGeminiPath(dir string) (string, error) {
	if dir == "" {
		d, err := os.UserHomeDir()
		if err != nil {
			return "", err
		}

		dir = filepath.Join(d, ".local", "share", "gemini")
	}

	path := filepath.Join(dir, "known_hosts")

	return path, nil
}

func printHelp(logger *wlog.WaterLog) {
	usage := `beagles version %s Copyright (c) 2020 the beagles developers
Usage: beagles [options]...

	-c[dir]		Set the config directory.  If not set, use
	-c [dir]	Set the config directory.  If not set, use
			XDG_CONFIG_HOME/beagles.
	-d[dir]		Set the database directory.  If not set, use
	-d [dir]	Set the database directory.  If not set, use
			XDG_DATA_HOME/beagles.
	-D		Print debug lines.
	-g [dir]	Set the directory for your gemini known_hosts.
			If not set, use XDG_DATA_HOME/gemini.
 	-h		Print basic options
	-l[dir]		Set the logging directory.  If not set, use
	-l [dir]	Set the logging directory.  If not set, use
			XDG_CACHE_HOME/beagles.
	-s[dir]		Set the download directory.  If not set, use
	-s [dir]	Set the download directory.  If not set, use
			XDG_DATA_HOME/beagles.
	-V		Print version information


M ui/content.go => ui/content.go +6 -1
@@ 19,6 19,7 @@ package ui

import (
	"fmt"
	"strings"

	"git.sr.ht/~chrisppy/beagles/config"
	"git.sr.ht/~chrisppy/beagles/db"


@@ 49,6 50,7 @@ func newContent(config *config.Config) *content {
}

func (w *content) setDescription(text string, isHTML bool) {
	// TODO: Handle gemini content to text
	w.Widget.SetTextColor(w.TxtColor)
	if isHTML {
		options := ht.Options{


@@ 71,6 73,7 @@ func (w *content) setDescription(text string, isHTML bool) {
}

func (w *content) setText(item *db.Item) {
	// TODO: Handle gemini content to text
	w.Widget.SetTextColor(w.TxtColor)

	if item == nil {


@@ 98,13 101,15 @@ func (w *content) setText(item *db.Item) {
	var text string
	if c == "" {
		text = "No Content"
	} else {
	} else if strings.HasPrefix(item.Link, "http") {
		t, err := ht.FromString(c, options)
		if err != nil {
			w.Widget.SetTextColor(w.ErrColor)
			t = err.Error()
		}
		text = t
	} else if strings.HasPrefix(item.Link, "gemini") {
		text = c
	}
	content = fmt.Sprintf("%s%s\n", content, text)


M ui/ui.go => ui/ui.go +7 -4
@@ 37,6 37,7 @@ type UI struct {
	DB           *db.Storage
	Config       *config.Config
	DownloadPath string
	GeminiPath   string
	Logger       *wlog.WaterLog
	inCmdMode    bool
	hideRead     bool


@@ 532,10 533,12 @@ func (i *UI) openLink(event *tcell.EventKey) *tcell.EventKey {
	}

	i.app.draw(func() {
		if err := util.OpenBrowser(key, i.Config.Browser); err != nil {
			i.commandLine.setError(err.Error())
			i.setStatus(i.status)
		}
		go func() {
			if err := util.OpenBrowser(key, i.Config.Browser); err != nil {
				i.commandLine.setError(err.Error())
				i.setStatus(i.status)
			}
		}()
	})

	return nil

M util/util.go => util/util.go +43 -9
@@ 19,6 19,7 @@ package util

import (
	"bytes"
	"crypto/x509"
	"fmt"
	"io"
	"io/ioutil"


@@ 29,13 30,14 @@ import (
	"runtime"
	"strings"

	"git.sr.ht/~adnano/go-gemini"
	"git.sr.ht/~chrisppy/beagles/config"
	"github.com/mmcdole/gofeed"
)

// ParseFeed will parse the url given.  It will recover from any panic present
// and return the proper error
func ParseFeed(path string) (f *gofeed.Feed, err error) {
func ParseFeed(path string, gmniPath string) (f *gofeed.Feed, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic occurred in gofeed: %v", r)


@@ 77,8 79,44 @@ func ParseFeed(path string) (f *gofeed.Feed, err error) {

		r = rsp.Body
	case strings.HasPrefix(path, "gemini"):
		err = fmt.Errorf("gemini is not yet supported")
		return
		if _, err = os.Stat(gmniPath); os.IsNotExist(err) {
			err = fmt.Errorf("either gemini is not setup on your computer or the path is incorrect")
			return
		}

		client := &gemini.Client{}

		if err = client.KnownHosts.Load(filepath.Clean(gmniPath)); err != nil {
			return
		}

		client.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *gemini.KnownHosts) error {
			return knownHosts.Lookup(hostname, cert)
		}

		var rsp *gemini.Response
		rsp, err = client.Get(path)
		if err != nil {
			return
		}

		if rsp == nil {
			err = fmt.Errorf("no response for %s", path)
			return
		}

		defer func() {
			if err = rsp.Body.Close(); err != nil {
				return
			}
		}()

		if rsp.Status != gemini.StatusSuccess {
			err = fmt.Errorf("%d for %s", rsp.Status, path)
			return
		}

		r = rsp.Body
	default:
		var b []byte
		b, err = ioutil.ReadFile(filepath.Clean(path))


@@ 112,9 150,7 @@ func OpenBrowser(url string, cfg *config.Browser) error {
				nargs[i] = arg
			}

			cmd := exec.Command(cfg.HTTP.Bin, nargs...)

			return cmd.Run()
			return exec.Command(cfg.HTTP.Bin, nargs...).Run()
		} else if cfg.Gemini != nil && strings.HasPrefix(url, "gemini") {
			nargs := make([]string, len(cfg.Gemini.Args))
			for i, arg := range cfg.Gemini.Args {


@@ 125,9 161,7 @@ func OpenBrowser(url string, cfg *config.Browser) error {
				nargs[i] = arg
			}

			cmd := exec.Command(cfg.Gemini.Bin, nargs...)

			return cmd.Run()
			return exec.Command(cfg.Gemini.Bin, nargs...).Run()
		}
	}