From 4951ffa9fec337bca9a247067b1c51eb492ae8c9 Mon Sep 17 00:00:00 2001 From: makeworld Date: Thu, 18 Jun 2020 16:54:48 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=20commit,=20full=20featu?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 107 +++++++ NOTES.md | 21 ++ README.md | 58 ++++ amfora.go | 35 +++ cache/cache.go | 125 +++++++++ cache/cache_test.go | 82 ++++++ client/client.go | 22 ++ client/tofu.go | 98 +++++++ config/config.go | 122 ++++++++ config/default.go | 33 +++ config/default.sh | 6 + default-config.toml | 29 ++ display/display.go | 431 +++++++++++++++++++++++++++++ display/help.go | 24 ++ display/history.go | 44 +++ display/modals.go | 156 +++++++++++ display/newtab.go | 13 + display/private.go | 281 +++++++++++++++++++ go.mod | 21 ++ go.sum | 389 ++++++++++++++++++++++++++ logger/logger.go | 20 ++ logo.png | Bin 0 -> 20606 bytes renderer/renderer.go | 238 ++++++++++++++++ structs/structs.go | 19 ++ structs/structs_test.go | 16 ++ webbrowser/README.md | 5 + webbrowser/open_browser_darwin.go | 13 + webbrowser/open_browser_other.go | 9 + webbrowser/open_browser_unix.go | 34 +++ webbrowser/open_browser_windows.go | 14 + 30 files changed, 2465 insertions(+) create mode 100644 .gitignore create mode 100644 NOTES.md create mode 100644 README.md create mode 100644 amfora.go create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 client/client.go create mode 100644 client/tofu.go create mode 100644 config/config.go create mode 100644 config/default.go create mode 100755 config/default.sh create mode 100644 default-config.toml create mode 100644 display/display.go create mode 100644 display/help.go create mode 100644 display/history.go create mode 100644 display/modals.go create mode 100644 display/newtab.go create mode 100644 display/private.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 logo.png create mode 100644 renderer/renderer.go create mode 100644 structs/structs.go create mode 100644 structs/structs_test.go create mode 100644 webbrowser/README.md create mode 100644 webbrowser/open_browser_darwin.go create mode 100644 webbrowser/open_browser_other.go create mode 100644 webbrowser/open_browser_unix.go create mode 100644 webbrowser/open_browser_windows.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4eb553c --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Binary +amfora +build/ + +# Test logs +*.log + +# GIMP files +*.xcf + +# Created by https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=code,go,linux,macos,windows + +### Code ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +### Go Patch ### +/vendor/ +/Godeps/ + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..9997437 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,21 @@ +# Notes + +- All the maps and stuff could be replaced with a `tab` struct +- And then just one single map of tab number to `tab` + +## Bugs +- Wrapping is messed up on CHAZ post, but nothing else + - Filed [issue 23](https://gitlab.com/tslocum/cview/-/issues/23) +- Error modal doesn't show the title + - Filed [issue 24](https://gitlab.com/tslocum/cview/-/issues/24) +- Text background not reset on ANSI pages + - Filed [issue 25](https://gitlab.com/tslocum/cview/-/issues/25) +- Inputfield isn't repeatedly in focus + - Tried multiple focus options with App and Form funcs, but nothing worked + +## Small todos +- Look at other todos in code +- Add "Why the name amfora" thing to README +- Add GIF to README +- Pass `gemini://egsam.pitr.ca/` test + - Timeout for server not closing connection? diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbd3241 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Amfora + +![Amfora logo](logo.png) +##### Modified from: amphora by Alvaro Cabrera from the Noun Project + +Amfora aims to be the best looking [Gemini](https://gemini.circumlunar.space/) client with the most features... all in the terminal. It does not support Gopher or other non-Web protocols - check out [Bombadillo](http://bombadillo.colorfield.space/) for that. It also aims to be completely cross platform, with full Windows support. + +It fully passes Sean Conman's client torture test, with exception of the alternate encodings section, as only UTF-8 and ASCII are supported. It mostly passes the Egsam test. + +## Usage + +Just call `amfora` or `amfora ` on the terminal. On Windows it might be `amfora.exe` instead. + +The project keeps many standard terminal keybindings and is intuitive. Press ? inside the application to pull up the help menu with a list of all the keybindings, and Esc to leave it. If you have used Bombadillo you will find it similar. + +It is designed with large or fullscreen terminals in mind. For optimal usage, make your terminal fullscreen. It was also designed with a dark background terminal in mind, but please file an issue if the colour choices look bad on your terminal setup. + +## Features / Roadmap + +- [x] URL browsing with TOFU and error handling +- [x] Tabbed browsing +- [x] Support ANSI color codes on pages, even for Windows +- [x] Styled page content (headings, links) +- [x] Basic forward/backward history, for each tab +- [x] Input (Status Code 10 & 11) +- [ ] Built-in search using GUS +- [ ] Bookmarks +- [ ] Search in pages with Ctrl-F +- [ ] Download pages and arbitrary data +- [ ] Full mouse support +- [ ] Emoji favicons + - See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details +- [ ] Table of contents for pages +- [ ] ~~Collapsing of gemini site sections (as determined by headers)~~ +- [ ] Full client certificate UX within the client + - *I will be waiting for some spec changes to happen before implementing this* + - Create transient and permanent certs within the client, per domain + - Manage and browse them + - https://lists.orbitalfox.eu/archives/gemini/2020/001400.html +- [ ] Subscribe to RSS and Atom feeds and display them +- [ ] Support Markdown rendering + +## Configuration +The config file is written in the intuitive [TOML](https://github.com/toml-lang/toml) file format. See [default-config.toml](./default-config.toml) for details. By default this file is available at `~/.config/amfora/config.toml`. + +On Windows, the file is in `%APPDATA%\amfora\config.toml`, which usually expands to `C:\Users\\AppData\Roaming\amfora\config.toml`. + +## Libraries +Amfora ❤️ open source! + +- [cview](https://gitlab.com/tslocum/cview/) for the TUI + - It's a fork of [tview](https://github.com/rivo/tview) with PRs merged and active support + - It uses [tcell](https://github.com/gdamore/tcell) for low level terminal operations +- [Viper](https://github.com/spf13/viper) for configuration and TOFU storing +- [go-gemini](https://github.com/makeworld-the-better-one/go-gemini), my forked and updated Gemini client/server library + +## License +This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details. diff --git a/amfora.go b/amfora.go new file mode 100644 index 0000000..81118cc --- /dev/null +++ b/amfora.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + "github.com/makeworld-the-better-one/amfora/config" + "github.com/makeworld-the-better-one/amfora/display" +) + +func main() { + // err := logger.Init() + // if err != nil { + // panic(err) + // } + + err := config.Init() + if err != nil { + panic(err) + } + display.Init() + + for _, url := range os.Args[1:] { + display.NewTab() + display.URL(url) + } + + if len(os.Args[1:]) == 0 { + // There should always be a tab + display.NewTab() + } + + if err = display.App.Run(); err != nil { + panic(err) + } +} diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..c16c826 --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,125 @@ +// Package cache provides an interface for a cache of strings, aka text/gemini pages. +// It is fully thread safe. +package cache + +import ( + "net/url" + "sync" + + "github.com/makeworld-the-better-one/amfora/structs" +) + +var pages = make(map[string]*structs.Page) // The actual cache +var urls = make([]string, 0) // Duplicate of the keys in the `pages` map, but in order of being added +var maxPages = 0 // Max allowed number of pages in cache +var maxSize = 0 // Max allowed cache size in bytes +var lock = sync.RWMutex{} + +// SetMaxPages sets the max number of pages the cache can hold. +// A value <= 0 means infinite pages. +func SetMaxPages(max int) { + maxPages = max +} + +// SetMaxSize sets the max size the cache can be, in bytes. +// A value <= 0 means infinite size. +func SetMaxSize(max int) { + maxSize = max +} + +func removeIndex(s []string, i int) []string { + s[len(s)-1], s[i] = s[i], s[len(s)-1] + return s[:len(s)-1] +} + +func removeUrl(url string) { + for i := range urls { + if urls[i] == url { + urls = removeIndex(urls, i) + return + } + } +} + +// Add adds a page to the cache, removing earlier pages as needed +// to keep the cache inside its limits. +// +// If your page is larger than the max cache size, the provided page +// will silently not be added to the cache. +func Add(p *structs.Page) { + if p.Url == "" { + // Just in case, don't waste cache on new tab page + return + } + // Never cache pages with query strings, to reduce unexpected behaviour + parsed, err := url.Parse(p.Url) + if err == nil && parsed.RawQuery != "" { + return + } + + if p.Size() > maxSize && maxSize > 0 { + // This page can never be added + return + } + + // Remove earlier pages to make room for this one + // There should only ever be 1 page to remove at most, + // but this handles more just in case. + for NumPages() >= maxPages && maxPages > 0 { + Remove(urls[0]) + } + // Do the same but for cache size + for Size()+p.Size() > maxSize && maxSize > 0 { + Remove(urls[0]) + } + + lock.Lock() + defer lock.Unlock() + pages[p.Url] = p + // Remove the URL if it was already there, then add it to the end + removeUrl(p.Url) + urls = append(urls, p.Url) +} + +// Remove will remove a page from the cache. +// Even if the page doesn't exist there will be no error. +func Remove(url string) { + lock.Lock() + defer lock.Unlock() + delete(pages, url) + removeUrl(url) +} + +// Clear removes all pages from the cache. +func Clear() { + lock.Lock() + defer lock.Unlock() + pages = make(map[string]*structs.Page) + urls = make([]string, 0) +} + +// Size returns the approx. current size of the cache in bytes. +func Size() int { + lock.RLock() + defer lock.RUnlock() + n := 0 + for _, page := range pages { + n += page.Size() + } + return n +} + +func NumPages() int { + lock.RLock() + defer lock.RUnlock() + return len(pages) +} + +// Get returns the page struct, and a bool indicating if the page was in the cache or not. +// An empty page struct is returned if the page isn't in the cache +func Get(url string) (*structs.Page, bool) { + lock.RLock() + defer lock.RUnlock() + p, ok := pages[url] + return p, ok +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..c0542c1 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,82 @@ +package cache + +import ( + "testing" + + "github.com/makeworld-the-better-one/amfora/structs" + "github.com/stretchr/testify/assert" +) + +var p = structs.Page{Url: "example.com"} +var p2 = structs.Page{Url: "example.org"} +var queryPage = structs.Page{Url: "gemini://example.com/test?query"} + +func reset() { + Clear() + SetMaxPages(0) + SetMaxSize(0) +} + +func TestMaxPages(t *testing.T) { + reset() + SetMaxPages(1) + Add(&p) + Add(&p2) + assert.Equal(t, 1, NumPages(), "there should only be one page") +} + +func TestMaxSize(t *testing.T) { + reset() + assert := assert.New(t) + SetMaxSize(p.Size()) + Add(&p) + assert.Equal(1, NumPages(), "one page should be added") + Add(&p2) + assert.Equal(1, NumPages(), "there should still be just one page due to cache size limits") + assert.Equal(p2.Url, urls[0], "the only page url should be the second page one") +} + +func TestRemove(t *testing.T) { + reset() + Add(&p) + Remove(p.Url) + assert.Equal(t, 0, NumPages(), "there shouldn't be any pages after the removal") +} + +func TestClearAndNumPages(t *testing.T) { + reset() + Add(&p) + Clear() + assert.Equal(t, 0, len(pages), "map should be empty") + assert.Equal(t, 0, len(urls), "urls slice shoulde be empty") + assert.Equal(t, 0, NumPages(), "NumPages should report empty too") +} + +func TestSize(t *testing.T) { + reset() + Add(&p) + assert.Equal(t, p.Size(), Size(), "sizes should match") +} + +func TestGet(t *testing.T) { + reset() + Add(&p) + Add(&p2) + page, ok := Get(p.Url) + if !ok { + t.Fatal("Get should say that the page was found") + } + if page.Url != p.Url { + t.Error("page urls don't match") + } +} + +func TestQueryString(t *testing.T) { + // Pages with URLs with query strings don't get added + reset() + Add(&queryPage) + _, ok := Get(queryPage.Url) + if ok { + t.Fatal("Get should not find the page, because it had query string") + } +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..bd91881 --- /dev/null +++ b/client/client.go @@ -0,0 +1,22 @@ +// Package client retrieves data over Gemini and implements a TOFU system. +package client + +import ( + "fmt" + + "github.com/makeworld-the-better-one/go-gemini" +) + +// Fetch returns response data and an error. +// The error text is human friendly and should be displayed. +func Fetch(url string) (*gemini.Response, error) { + resp, err := gemini.Fetch(url) + if err != nil { + return nil, fmt.Errorf("URL could not be fetched: %v", err) + } + ok := handleTofu(resp.Cert) + if !ok { + return nil, ErrTofu + } + return resp, err +} diff --git a/client/tofu.go b/client/tofu.go new file mode 100644 index 0000000..79f77b8 --- /dev/null +++ b/client/tofu.go @@ -0,0 +1,98 @@ +package client + +import ( + "crypto/sha256" + "crypto/x509" + "errors" + "fmt" + "strings" + "time" + + "github.com/makeworld-the-better-one/amfora/config" +) + +// TOFU implementation. +// Stores cert hash and expiry for now, like Bombadillo. +// There is ongoing TOFU discussiong on the mailing list about better +// ways to do this, and I will update this file once those are decided on. + +var ErrTofu = errors.New("server cert does not match TOFU database") + +var tofuStore = config.TofuStore + +// idKey returns the config/viper key needed to retrieve +// a cert's ID / fingerprint. +func idKey(domain string) string { + return strings.ReplaceAll(domain, ".", "/") +} + +func expiryKey(domain string) string { + return strings.ReplaceAll(strings.TrimSuffix(domain, "."), ".", "/") + "/expiry" +} + +func loadTofuEntry(domain string) (string, time.Time, error) { + id := tofuStore.GetString(idKey(domain)) // Fingerprint + if len(id) != 64 { + // Not set, or invalid + return "", time.Time{}, errors.New("not found") + } + + expiry := tofuStore.GetTime(expiryKey(domain)) + if expiry.IsZero() { + // Not set + return id, time.Time{}, errors.New("not found") + } + return id, expiry, nil +} + +// certID returns a generic string representing a cert or domain. +func certID(cert *x509.Certificate) string { + h := sha256.New() + h.Write(cert.Raw) + return fmt.Sprintf("%X", h.Sum(nil)) + + // The old way that uses the cert public key: + // b, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + // h := sha256.New() + // if err != nil { + // // Unsupported key type - try to store a hash of the struct instead + // h.Write([]byte(fmt.Sprint(cert.PublicKey))) + // return fmt.Sprintf("%X", h.Sum(nil)) + // } + // h.Write(b) + // return fmt.Sprintf("%X", h.Sum(nil)) +} + +func saveTofuEntry(cert *x509.Certificate) { + tofuStore.Set(idKey(cert.Subject.CommonName), certID(cert)) + tofuStore.Set(expiryKey(cert.Subject.CommonName), cert.NotAfter.UTC()) + err := tofuStore.WriteConfig() + if err != nil { + panic(err) + } +} + +// handleTofu is the abstracted interface for taking care of TOFU. +// A cert is provided and storage, checking, etc, are taken care of. +// It returns a bool indicating if the cert is valid according to +// the TOFU database. +// If false is returned, the connection should not go ahead. +func handleTofu(cert *x509.Certificate) bool { + id, expiry, err := loadTofuEntry(cert.Subject.CommonName) + if err != nil { + // Cert isn't in database or data is malformed + // So it can't be checked and anything is valid + saveTofuEntry(cert) + return true + } + if certID(cert) == id { + // Save cert as the one stored + return true + } + if time.Now().After(expiry) { + // Old cert expired, so anything is valid + saveTofuEntry(cert) + return true + } + return false +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..58b6c26 --- /dev/null +++ b/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/makeworld-the-better-one/amfora/cache" + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" +) + +var amforaAppData string // Where amfora files are stored on Windows - cached here + +func configDir() string { + home, err := homedir.Dir() + if err != nil { + panic(err) + } + if runtime.GOOS == "windows" { + return amforaAppData + } + + // Unix / POSIX system + return filepath.Join(home, ".config", "amfora") +} + +func configPath() string { + return filepath.Join(configDir(), "config.toml") +} + +var TofuStore = viper.New() + +func tofuDBDir() string { + home, err := homedir.Dir() + if err != nil { + panic(err) + } + // Windows just stores it in APPDATA along with other stuff + if runtime.GOOS == "windows" { + return amforaAppData + } + // XDG cache dir on POSIX systems + return filepath.Join(home, ".cache", "amfora") +} + +func tofuDBPath() string { + return filepath.Join(tofuDBDir(), "tofu.toml") +} + +func Init() error { + home, err := homedir.Dir() + if err != nil { + panic(err) + } + if runtime.GOOS == "windows" { + appdata, ok := os.LookupEnv("APPDATA") + if ok { + amforaAppData = filepath.Join(appdata, "amfora") + } else { + amforaAppData = filepath.Join(home, filepath.FromSlash("AppData/Roaming/amfora/")) + } + } + + err = os.MkdirAll(configDir(), 0755) + if err != nil { + return err + } + f, err := os.OpenFile(configPath(), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) + if err == nil { + // Config file doesn't exist yet, write the default one + _, err = f.Write(defaultConf) + if err != nil { + f.Close() + return err + } + f.Close() + } + + err = os.MkdirAll(tofuDBDir(), 0755) + if err != nil { + return err + } + os.OpenFile(tofuDBPath(), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) + + TofuStore.SetConfigFile(tofuDBPath()) + TofuStore.SetConfigType("toml") + err = TofuStore.ReadInConfig() + if err != nil { + return err + } + + viper.SetDefault("a-general.home", "gemini.circumlunar.space") + viper.SetDefault("a-general.http", "default") + viper.SetDefault("a-general.search", "gus.guru/search") + viper.SetDefault("a-general.color", true) + viper.SetDefault("a-general.bullets", true) + viper.SetDefault("a-general.wrap_width", 100) + viper.SetDefault("cache.max_size", 0) + viper.SetDefault("cache.max_pages", 20) + + viper.SetConfigFile(configPath()) + viper.SetConfigType("toml") + err = viper.ReadInConfig() + if err != nil { + return err + } + + // Setup cache from config + cache.SetMaxSize(viper.GetInt("cache.max_size")) + cache.SetMaxPages(viper.GetInt("cache.max_pages")) + + return nil +} + +func GetWrapWidth() int { + i := viper.GetInt("a-general.wrap_width") + if i <= 0 { + return 100 // The default + } + return i +} diff --git a/config/default.go b/config/default.go new file mode 100644 index 0000000..3abf1a3 --- /dev/null +++ b/config/default.go @@ -0,0 +1,33 @@ +package config + +//go:generate ./default.sh +var defaultConf = []byte(`# This is the default config file. +# It also shows all the default values, if you don't create the file. + +# All URL values may omit the scheme and/or port. + +[a-general] +home = "gemini://gemini.circumlunar.space" +# What command to run to open a HTTP URL. Set to "default" to try to guess the browser, +# or set to "off" to not open HTTP URLs. +# If a command is set, than the URL will be added (in quotes) to the end of the command. +# A space will be prepended if necessary. +http = "default" + +search = "gemini://gus.guru/search" # Any URL that will accept a query string can be put here +color = true # Whether colors will be used in the terminal +bullets = true # Whether to replace list asterisks with unicode bullets +wrap_width = 100 # How many columns to wrap a page's text to. Preformatted blocks are not wrapped. + +[bookmarks] +# Make sure to quote the key names if you edit this part yourself +# Example: +# "CAPCOM" = "gemini://gemini.circumlunar.space/capcom/" + +# Options for page cache - which is only for text/gemini pages +# Increase the cache size to speed up browsing at the expense of memory +[cache] +# Zero values mean there is no limit +max_size = 0 # Size in bytes +max_pages = 30 # The maximum number of pages the cache can store +`) diff --git a/config/default.sh b/config/default.sh new file mode 100755 index 0000000..3de772f --- /dev/null +++ b/config/default.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +head -n 3 default.go | tee default.go > /dev/null +echo -n 'var defaultConf = []byte(`' >> default.go +cat ../default-config.toml >> default.go +echo '`)' >> default.go \ No newline at end of file diff --git a/default-config.toml b/default-config.toml new file mode 100644 index 0000000..70263d7 --- /dev/null +++ b/default-config.toml @@ -0,0 +1,29 @@ +# This is the default config file. +# It also shows all the default values, if you don't create the file. + +# All URL values may omit the scheme and/or port. + +[a-general] +home = "gemini://gemini.circumlunar.space" +# What command to run to open a HTTP URL. Set to "default" to try to guess the browser, +# or set to "off" to not open HTTP URLs. +# If a command is set, than the URL will be added (in quotes) to the end of the command. +# A space will be prepended if necessary. +http = "default" + +search = "gemini://gus.guru/search" # Any URL that will accept a query string can be put here +color = true # Whether colors will be used in the terminal +bullets = true # Whether to replace list asterisks with unicode bullets +wrap_width = 100 # How many columns to wrap a page's text to. Preformatted blocks are not wrapped. + +[bookmarks] +# Make sure to quote the key names if you edit this part yourself +# Example: +# "CAPCOM" = "gemini://gemini.circumlunar.space/capcom/" + +# Options for page cache - which is only for text/gemini pages +# Increase the cache size to speed up browsing at the expense of memory +[cache] +# Zero values mean there is no limit +max_size = 0 # Size in bytes +max_pages = 30 # The maximum number of pages the cache can store diff --git a/display/display.go b/display/display.go new file mode 100644 index 0000000..c8b0742 --- /dev/null +++ b/display/display.go @@ -0,0 +1,431 @@ +package display + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/viper" + + "github.com/makeworld-the-better-one/amfora/renderer" + + "github.com/gdamore/tcell" + "github.com/makeworld-the-better-one/amfora/cache" + "github.com/makeworld-the-better-one/amfora/structs" + "gitlab.com/tslocum/cview" + //"github.com/makeworld-the-better-one/amfora/cview" +) + +var curTab = -1 // What number tab is currently visible, -1 means there are no tabs at all +var tabMap = make(map[int]*structs.Page) // Map of tab number to page +// Holds the actual tab primitives +var tabViews = make(map[int]*cview.TextView) + +// The user input and URL display bar at the bottom +var bottomBar = cview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorWhite). + SetFieldTextColor(tcell.ColorBlack). + SetLabelColor(tcell.ColorGreen) + +var helpTable = cview.NewTable(). + SetSelectable(false, false). + SetFixed(1, 2). + SetBorders(true). + SetBordersColor(tcell.ColorGray) + +// Viewer for the tab primitives +// Pages are named as strings of tab numbers - so the textview for the first tab +// is held in the page named "0". +// The only pages that don't confine to this scheme named after the modals above, +// which is used to draw modals on top the current tab. +// Ex: "info", "error", "input", "yesno" +var tabPages = cview.NewPages(). + AddPage("help", helpTable, true, false). + AddPage("info", infoModal, false, false). + AddPage("error", errorModal, false, false). + AddPage("input", inputModal, false, false). + AddPage("yesno", yesNoModal, false, false) + +// The tabs at the top with titles +var tabRow = cview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetScrollable(true). + SetWrap(false). + SetHighlightedFunc(func(added, removed, remaining []string) { + // There will always only be one string in added - never multiple highlights + // Remaining should always be empty + i, _ := strconv.Atoi(added[0]) + tabPages.SwitchToPage(strconv.Itoa(i)) // Tab names are just numbers, zero-indexed + }) + +// Root layout +var layout = cview.NewFlex(). + SetDirection(cview.FlexRow). + AddItem(tabRow, 1, 1, false). + AddItem(nil, 1, 1, false). // One line of empty space above the page + //AddItem(tabPages, 0, 1, true). + AddItem(cview.NewFlex(). // The page text in the middle is held in another flex, to center it + SetDirection(cview.FlexColumn). + AddItem(nil, 0, 1, false). + AddItem(tabPages, 0, 7, true). // The text occupies 5/6 of the screen horizontally + AddItem(nil, 0, 1, false), + 0, 1, true). + AddItem(nil, 1, 1, false). // One line of empty space before bottomBar + AddItem(bottomBar, 1, 1, false) + +var App = cview.NewApplication().EnableMouse(false).SetRoot(layout, true) + +var renderedNewTabContent string +var newTabLinks []string +var newTabPage structs.Page + +func Init() { + tabRow.SetChangedFunc(func() { + App.Draw() + }) + + // Populate help table + helpTable.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc { + tabPages.SwitchToPage(strconv.Itoa(curTab)) + } + }) + rows := strings.Count(helpCells, "\n") + 1 + cells := strings.Split( + strings.ReplaceAll(helpCells, "\n", "|"), + "|") + cell := 0 + for r := 0; r < rows; r++ { + for c := 0; c < 2; c++ { + var tableCell *cview.TableCell + if c == 0 { + tableCell = cview.NewTableCell(cells[cell]). + SetAttributes(tcell.AttrBold). + SetExpansion(1) + } else { + tableCell = cview.NewTableCell(cells[cell]). + SetExpansion(2) + } + helpTable.SetCell(r, c, tableCell) + cell++ + } + } + + bottomBar.SetBackgroundColor(tcell.ColorWhite) + bottomBar.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + // TODO: Support search + // Send the URL/number typed in + + if strings.TrimSpace(bottomBar.GetText()) == "" { + // Ignore + bottomBar.SetLabel("") + bottomBar.SetText(tabMap[curTab].Url) + App.SetFocus(tabViews[curTab]) + return + } + + i, err := strconv.Atoi(bottomBar.GetText()) + if err != nil { + // It's a full URL + URL(bottomBar.GetText()) + bottomBar.SetLabel("") + return + } + if i <= len(tabMap[curTab].Links) && i > 0 { + // Valid link number + followLink(tabMap[curTab].Url, tabMap[curTab].Links[i-1]) + bottomBar.SetLabel("") + return + } + // Invalid link number + bottomBar.SetLabel("") + bottomBar.SetText(tabMap[curTab].Url) + App.SetFocus(tabViews[curTab]) + + case tcell.KeyEscape: + // Set back to what it was + bottomBar.SetLabel("") + bottomBar.SetText(tabMap[curTab].Url) + App.SetFocus(tabViews[curTab]) + } + // Other potential keys are Tab and Backtab, they are ignored + }) + + // Render the default new tab content ONCE and store it for later + renderedNewTabContent, newTabLinks = renderer.RenderGemini(newTabContent) + newTabPage = structs.Page{Content: renderedNewTabContent, Links: newTabLinks} + + modalInit() + + // Setup map of keys to functions here + // Changing tabs, new tab, etc + App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + _, ok := App.GetFocus().(*cview.Button) + if ok { + // It's focused on a modal right now, nothing should interrupt + return event + } + _, ok = App.GetFocus().(*cview.InputField) + if ok { + // An InputField is in focus, nothing should interrupt + return event + } + + switch event.Key() { + case tcell.KeyCtrlT: + NewTab() + return nil + case tcell.KeyCtrlW: + CloseTab() + return nil + case tcell.KeyCtrlR: + Reload() + return nil + case tcell.KeyCtrlH: + URL(viper.GetString("a-general.home")) + return nil + case tcell.KeyCtrlQ: + Stop() + return nil + case tcell.KeyRune: + // Regular key was sent + switch string(event.Rune()) { + case " ": + // Space starts typing, like Bombadillo + bottomBar.SetLabel("[::b]URL: [::-]") + bottomBar.SetText("") + App.SetFocus(bottomBar) + return nil + case "q": + Stop() + return nil + case "R": + Reload() + return nil + case "b": + histBack() + return nil + case "f": + histForward() + return nil + case "?": + Help() + return nil + // Shift+NUMBER keys, for switching to a specific tab + case "!": + SwitchTab(0) + return nil + case "@": + SwitchTab(1) + return nil + case "#": + SwitchTab(2) + return nil + case "$": + SwitchTab(3) + return nil + case "%": + SwitchTab(4) + return nil + case "^": + SwitchTab(5) + return nil + case "&": + SwitchTab(6) + return nil + case "*": + SwitchTab(7) + return nil + case "(": + SwitchTab(8) + return nil + case ")": // Zero key goes to the last tab + SwitchTab(NumTabs() - 1) + return nil + } + } + return event + }) +} + +// Stop stops the app gracefully. +// In the future it will handle things like ongoing downloads, etc +func Stop() { + App.Stop() +} + +// NewTab opens a new tab and switches to it, displaying the +// the default empty content because there's no URL. +func NewTab() { + // Create TextView in tabViews and change curTab + // Set the textView options, and the changed func to App.Draw() + // SetDoneFunc to do link highlighting + // Add view to pages and switch to it + + curTab = NumTabs() + tabMap[curTab] = &newTabPage + tabViews[curTab] = cview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetScrollable(true). + SetWrap(false). + SetText(renderedNewTabContent). + SetChangedFunc(func() { + App.Draw() + }). + SetDoneFunc(func(key tcell.Key) { + // Altered from: https://gitlab.com/tslocum/cview/-/blob/master/demos/textview/main.go + // Handles being able to select and "click" links with the enter and tab keys + + currentSelection := tabViews[curTab].GetHighlights() + numSelections := len(tabMap[curTab].Links) + if key == tcell.KeyEnter { + if len(currentSelection) > 0 && len(tabMap[curTab].Links) > 0 { + // A link was selected, "click" it and load the page it's for + linkN, _ := strconv.Atoi(currentSelection[0]) + followLink(tabMap[curTab].Url, tabMap[curTab].Links[linkN]) + return + } else { + tabViews[curTab].Highlight("0").ScrollToHighlight() + } + } else if len(currentSelection) > 0 { + index, _ := strconv.Atoi(currentSelection[0]) + if key == tcell.KeyTab { + index = (index + 1) % numSelections + } else if key == tcell.KeyBacktab { + index = (index - 1 + numSelections) % numSelections + } else { + return + } + tabViews[curTab].Highlight(strconv.Itoa(index)).ScrollToHighlight() + } + }) + + tabHist[curTab] = []string{} + // Can't go backwards, but this isn't the first page either. + // The first page will be the next one the user goes to. + tabHistPos[curTab] = -1 + + tabPages.AddAndSwitchToPage(strconv.Itoa(curTab), tabViews[curTab], true) + App.SetFocus(tabViews[curTab]) + + // Add tab number to the actual place where tabs are show on the screen + // Tab regions are 0-indexed but text displayed on the screen starts at 1 + fmt.Fprintf(tabRow, `["%d"][darkcyan] %d [white][""]|`, curTab, curTab+1) + tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() + + bottomBar.SetLabel("") + bottomBar.SetText("") + + // Force a draw, just in case + App.Draw() +} + +// CloseTab closes the current tab and switches to the one to its left. +func CloseTab() { + // Basically the NewTab() func inverted + + // TODO: Support closing middle tabs, by renumbering all the maps + // So that tabs to the right of the closed tabs point to the right places + // For now you can only close the right-most tab + if curTab != NumTabs()-1 { + return + } + + if NumTabs() <= 1 { + // There's only one tab open, close the app instead + Stop() + return + } + + delete(tabMap, curTab) + tabPages.RemovePage(strconv.Itoa(curTab)) + delete(tabViews, curTab) + + delete(tabHist, curTab) + delete(tabHistPos, curTab) + + if curTab <= 0 { + curTab = NumTabs() - 1 + } else { + curTab-- + } + + tabPages.SwitchToPage(strconv.Itoa(curTab)) // Go to previous page + // Rewrite the tab display + tabRow.Clear() + for i := 0; i < NumTabs(); i++ { + fmt.Fprintf(tabRow, `["%d"][darkcyan] %d [white][""]|`, i, i+1) + } + tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() + + bottomBar.SetLabel("") + bottomBar.SetText(tabMap[curTab].Url) + + // Just in case + App.Draw() +} + +// SwitchTab switches to a specific tab, using its number, 0-indexed. +// The tab numbers are clamped to the end, so for example numbers like -5 and 1000 are still valid. +// This means that calling something like SwitchTab(curTab - 1) will never cause an error. +func SwitchTab(tab int) { + if tab < 0 { + tab = 0 + } + if tab > NumTabs()-1 { + tab = NumTabs() - 1 + } + + curTab = tab % NumTabs() + tabPages.SwitchToPage(strconv.Itoa(curTab)) + tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() + + bottomBar.SetLabel("") + bottomBar.SetText(tabMap[curTab].Url) + + // Just in case + App.Draw() +} + +func Reload() { + cache.Remove(tabMap[curTab].Url) + go handleURL(tabMap[curTab].Url) +} + +// URL loads and handles the provided URL for the current tab. +// It should be an absolute URL. +func URL(u string) { + // Old relative URL handling stuff: + // parsed, err := url.Parse(u) + // if err != nil { + // Error("Bad URL", err.Error()) + // return + // } + // if tabHasContent() && parsed.Host == "" { + // // Relative link + // followLink(tabMap[curTab].Url, u) + // return + // } + + go func() { + final, displayed := handleURL(u) + if displayed { + addToHist(final) + } + }() +} + +func NumTabs() int { + return len(tabViews) +} + +// Help displays the help and keybindings. +func Help() { + helpTable.ScrollToBeginning() + tabPages.SwitchToPage("help") + App.Draw() +} diff --git a/display/help.go b/display/help.go new file mode 100644 index 0000000..1509d3d --- /dev/null +++ b/display/help.go @@ -0,0 +1,24 @@ +package display + +import "strings" + +var helpCells = strings.TrimSpace(` +?|Bring up this help. +Esc|Leave the help +Arrow keys, h/j/k/l|Scroll and move a page. +Tab|Navigate to the next item in a popup. +Shift-Tab|Navigate to the previous item in a popup. +Ctrl-H|Go home +Ctrl-T|New tab +Ctrl-W|Close tab. For now, only the right-most tab can be closed. +b|Go back a page +f|Go forward a page +g|Go to top of document +G|Go to bottom of document +spacebar|Open bar at the bottom - type a URL or link number +Enter|On a page this will start link highlighting. Press Tab and Shift-Tab to pick different links. Press enter again to go to one. +Ctrl-R|Reload a page. This also clears the cache. +q, Ctrl-Q|Quit +Shift-NUMBER|Go to a specific tab. +Shift-0, )|Go to the last tab. +`) diff --git a/display/history.go b/display/history.go new file mode 100644 index 0000000..4f21296 --- /dev/null +++ b/display/history.go @@ -0,0 +1,44 @@ +package display + +// Tab number mapped to list of URLs ordered from first to most recent. +var tabHist = make(map[int][]string) + +// Tab number mapped to where in its history you are. +// The value is a valid index of the string slice above. +var tabHistPos = make(map[int]int) + +// addToHist adds the given URL to history. +// It assumes the URL is currently being loaded and displayed on the page. +func addToHist(u string) { + if tabHistPos[curTab] < len(tabHist[curTab])-1 { + // We're somewhere in the middle of the history instead, with URLs ahead and behind. + // The URLs ahead need to be removed so this new URL is the most recent item in the history + tabHist[curTab] = tabHist[curTab][:tabHistPos[curTab]+1] + } + tabHist[curTab] = append(tabHist[curTab], u) + tabHistPos[curTab]++ +} + +func histForward() { + if tabHistPos[curTab] >= len(tabHist[curTab])-1 { + // Already on the most recent URL in the history + return + } + tabHistPos[curTab]++ + go func() { + handleURL(tabHist[curTab][tabHistPos[curTab]]) + applyScroll() + }() +} + +func histBack() { + if tabHistPos[curTab] <= 0 { + // First tab in history + return + } + tabHistPos[curTab]-- + go func() { + handleURL(tabHist[curTab][tabHistPos[curTab]]) + applyScroll() + }() +} diff --git a/display/modals.go b/display/modals.go new file mode 100644 index 0000000..49cb5e8 --- /dev/null +++ b/display/modals.go @@ -0,0 +1,156 @@ +package display + +import ( + "strconv" + "strings" + + "github.com/gdamore/tcell" + "gitlab.com/tslocum/cview" +) + +// This file contains code for all the popups / modals used in the display + +var infoModal = cview.NewModal(). + SetBackgroundColor(tcell.ColorGray). + SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetTextColor(tcell.ColorWhite). + AddButtons([]string{"Ok"}) + +var errorModal = cview.NewModal(). + SetBackgroundColor(tcell.ColorMaroon). + SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetTextColor(tcell.ColorWhite). + AddButtons([]string{"Ok"}) + +// TODO: Support input +var inputModal = cview.NewModal(). + SetBackgroundColor(tcell.ColorGreen). + SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetTextColor(tcell.ColorWhite). + AddButtons([]string{"Send", "Cancel"}) + +var inputCh = make(chan string) +var inputModalText string // The current text of the input field in the modal + +var yesNoModal = cview.NewModal(). + SetBackgroundColor(tcell.ColorPurple). + SetButtonBackgroundColor(tcell.ColorNavy). + SetButtonTextColor(tcell.ColorWhite). + SetTextColor(tcell.ColorWhite). + AddButtons([]string{"Yes", "No"}) + +// Channel to recieve yesNo answer on +var yesNoCh = make(chan bool) + +func modalInit() { + // Modal functions that can't be added up above, because they return the wrong type + infoModal.SetBorder(true) + infoModal.SetBorderColor(tcell.ColorWhite) + infoModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + tabPages.SwitchToPage(strconv.Itoa(curTab)) + }) + + errorModal.SetBorder(true) + errorModal.SetBorderColor(tcell.ColorWhite) + errorModal.SetTitleColor(tcell.ColorWhite) + errorModal.SetTitleAlign(cview.AlignCenter) + errorModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + tabPages.SwitchToPage(strconv.Itoa(curTab)) + }) + + inputModal.SetBorder(true) + inputModal.SetBorderColor(tcell.ColorWhite) + inputModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Send" { + inputCh <- inputModalText + return + } + // Empty string indicates no input + inputCh <- "" + + //tabPages.SwitchToPage(strconv.Itoa(curTab)) - handled in Input() + }) + + yesNoModal.SetBorder(true) + yesNoModal.SetBorderColor(tcell.ColorWhite) + yesNoModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + yesNoCh <- true + return + } + yesNoCh <- false + + //tabPages.SwitchToPage(strconv.Itoa(curTab)) - Handled in YesNo() + }) +} + +// Error displays an error on the screen in a modal. +func Error(title, text string) { + // Capitalize and add period if necessary - because most errors don't do that + text = strings.ToUpper(string([]rune(text)[0])) + text[1:] + if !strings.HasSuffix(text, ".") && !strings.HasSuffix(text, "!") && !strings.HasSuffix(text, "?") { + text += "." + } + // Add spaces to title for aesthetic reasons + title = " " + strings.TrimSpace(title) + " " + + errorModal.SetTitle(title) + errorModal.SetText(text) + tabPages.ShowPage("error") + tabPages.SendToFront("error") + App.SetFocus(errorModal) + App.Draw() +} + +// Info displays some info on the screen in a modal. +func Info(s string) { + infoModal.SetText(s) + tabPages.ShowPage("info") + tabPages.SendToFront("info") + App.SetFocus(infoModal) + App.Draw() +} + +// Input pulls up a modal that asks for input, and returns the user's input. +// It returns an bool indicating if the user chose to send input or not. +func Input(prompt string) (string, bool) { + // Remove and re-add input field - to clear the old text + if inputModal.GetForm().GetFormItemCount() > 0 { + inputModal.GetForm().RemoveFormItem(0) + } + inputModalText = "" + inputModal.GetForm().AddInputField("", "", 0, nil, + func(text string) { + // Store for use later + inputModalText = text + }) + + inputModal.SetText(prompt) + tabPages.ShowPage("input") + tabPages.SendToFront("input") + App.SetFocus(inputModal) + App.Draw() + + resp := <-inputCh + tabPages.SwitchToPage(strconv.Itoa(curTab)) + if resp == "" { + return "", false + } + return resp, true +} + +// YesNo displays a modal asking a yes-or-no question. +func YesNo(prompt string) bool { + yesNoModal.SetText(prompt) + tabPages.ShowPage("yesno") + tabPages.SendToFront("yesno") + App.SetFocus(yesNoModal) + App.Draw() + + resp := <-yesNoCh + tabPages.SwitchToPage(strconv.Itoa(curTab)) + return resp +} diff --git a/display/newtab.go b/display/newtab.go new file mode 100644 index 0000000..1b6230e --- /dev/null +++ b/display/newtab.go @@ -0,0 +1,13 @@ +package display + +var newTabContent = `# New Tab + +You've opened a new tab. Use the bar at the bottom to browse around. You can start typing in it by pressing the space key, or clicking the bottom bar. + +Press the ? key at any time to bring up the help, and see other keybindings. Most are what you expect, you can use h/j/k/l and the arrow keys to move around text, as well as scrolling with the mouse. Common browsers shortcuts like Ctrl-T, Ctrl-W, and Ctrl-F are also supported. + +Happy browsing! + +=> //gemini.circumlunar.space Gemini homepage +=> https://github.com/makeworld-the-better-one/amfora Amfora homepage [HTTPS] +` diff --git a/display/private.go b/display/private.go new file mode 100644 index 0000000..13e45dd --- /dev/null +++ b/display/private.go @@ -0,0 +1,281 @@ +package display + +import ( + "net/url" + "os/exec" + "strings" + + "github.com/makeworld-the-better-one/amfora/cache" + "github.com/makeworld-the-better-one/amfora/client" + "github.com/makeworld-the-better-one/amfora/renderer" + "github.com/makeworld-the-better-one/amfora/structs" + "github.com/makeworld-the-better-one/amfora/webbrowser" + "github.com/makeworld-the-better-one/go-gemini" + "github.com/spf13/viper" + "gitlab.com/tslocum/cview" + //"github.com/makeworld-the-better-one/amfora/cview" +) + +// This file contains the functions that aren't part of the public API. + +// tabHasContent returns true when the current tab has a page being displayed. +// The most likely situation where false would be returned is when the default +// new tab content is being displayed. +func tabHasContent() bool { + if curTab < 0 { + return false + } + if len(tabViews) < curTab { + // There isn't a TextView for the current tab number + return false + } + if tabMap[curTab].Url == "" { + // Likely the default content page + return false + } + _, ok := tabMap[curTab] + return ok // If there's a page, return true +} + +// saveScroll saves where in the page the user was. +// It should be used whenever moving from one page to another. +func saveScroll() { + // It will also be saved in the cache because the cache uses the same pointer + row, col := tabViews[curTab].GetScrollOffset() + tabMap[curTab].Row = row + tabMap[curTab].Column = col +} + +// applyScroll applies the saved scroll values to the current page and tab. +// It should only be used when going backward and forward, not when +// loading a new page (that might have scroll vals cached anyway). +func applyScroll() { + tabViews[curTab].ScrollTo(tabMap[curTab].Row, tabMap[curTab].Column) +} + +// followLink should be used when the user "clicks" a link on a page. +// Not when a URL is opened on a new tab for the first time. +func followLink(prev, next string) { + saveScroll() // Likely called later on anyway, here just in case + prevParsed, _ := url.Parse(prev) + nextParsed, err := url.Parse(next) + if err != nil { + Error("Error", "Link URL could not be parsed") + return + } + nextURL := prevParsed.ResolveReference(nextParsed).String() + go func() { + final, displayed := handleURL(nextURL) + if displayed { + addToHist(final) + } + }() +} + +// setPage displays a Page on the current tab. +func setPage(p *structs.Page) { + saveScroll() // Save the scroll of the previous page + + // Change page + tabMap[curTab] = p + tabViews[curTab].SetText(p.Content) + tabViews[curTab].Highlight("") // Turn off highlights + tabViews[curTab].ScrollToBeginning() + + // Setup display + App.SetFocus(tabViews[curTab]) + bottomBar.SetLabel("") + bottomBar.SetText(p.Url) +} + +// handleURL displays whatever action is needed for the provided URL, +// and applies it to the current tab. +// It loads documents, handles errors, brings up a download prompt, etc. +// +// The string returned is the final URL, if redirects were involved. +// In most cases it will be the same as the passed URL. +// If there is some error, it will return "". +// The second returned item is a bool indicating if page content was displayed. +// It returns false for Errors, other protocols, etc. +func handleURL(u string) (string, bool) { + defer App.Draw() // Make sure modals get displayed + + //logger.Log.Printf("Sent: %s", u) + + u = normalizeURL(u) + + //logger.Log.Printf("Normalized: %s", u) + + parsed, err := url.Parse(u) + if err != nil { + Error("URL Error", err.Error()) + return "", false + } + + if strings.HasPrefix(u, "http") { + switch strings.TrimSpace(viper.GetString("a-general.http")) { + case "", "off": + Error("Error", "Opening HTTP URLs is turned off.") + case "default": + s, err := webbrowser.Open(u) + if err != nil { + Error("Error", err.Error()) + } else { + Info(s) + } + default: + // The config has a custom command to execute for HTTP URLs + fields := strings.Fields(viper.GetString("a-general.http")) + err := exec.Command(fields[0], append(fields[1:], u)...).Start() + if err != nil { + Error("Error", err.Error()) + } + } + return "", false + } + if !strings.HasPrefix(u, "gemini") { + // TODO: Replace it with with displaying the URL, once modal titles work + Error("Error", "Unsupported protocol, only [::b]gemini[::-] and [::b]http[::-] are supported.") + return "", false + } + // Gemini URL + + // Load page from cache if possible + page, ok := cache.Get(u) + if ok { + setPage(page) + return u, true + } + // Otherwise download it + bottomBar.SetText("Loading...") + App.Draw() + + res, err := client.Fetch(u) + if err != nil { + Error("Error", err.Error()) + // Set the bar back to original URL + bottomBar.SetText(tabMap[curTab].Url) + return "", false + } + if renderer.CanDisplay(res) { + page, err := renderer.MakePage(u, res) + if err != nil { + Error("Error", "Issuing creating page: "+err.Error()) + // Set the bar back to original URL + bottomBar.SetText(tabMap[curTab].Url) + return "", false + } + cache.Add(page) + setPage(page) + return u, true + } + // Not displayable + // Could be a non 20 (or 21) status code, or a different kind of document + + // Set the bar back to original URL + bottomBar.SetText(tabMap[curTab].Url) + App.Draw() + + // Handle each status code + switch gemini.SimplifyStatus(res.Status) { + case 10: + userInput, ok := Input(res.Meta) + if ok { + // Make another request with the query string added + // + chars are replaced because PathEscape doesn't do that + parsed.RawQuery = strings.ReplaceAll(url.PathEscape(userInput), "+", "%2B") + return handleURL(parsed.String()) + } + return "", false + case 30: + parsedMeta, err := url.Parse(res.Meta) + if err != nil { + Error("Redirect Error", "Invalid URL: "+err.Error()) + return "", false + } + redir := parsed.ResolveReference(parsedMeta).String() + + if YesNo("Follow redirect?\n" + redir) { + return handleURL(redir) + } + return "", false + case 40: + Error("Temporary Failure", cview.Escape(res.Meta)) // Escaped just in case, to not allow malicious meta strings + return "", false + case 50: + Error("Permanent Failure", cview.Escape(res.Meta)) + return "", false + case 60: + Info("The server requested a certificate. Cert handling is coming to Amfora soon!") + return "", false + } + // Status code 20, but not a document that can be displayed + yes := YesNo("This type of file can't be displayed. Downloading will be implemented soon. Would like to open the file in a HTTPS proxy for now?") + if yes { + // Open in mozz's proxy + portalURL := u + if parsed.RawQuery != "" { + // Remove query and add encoded version on the end + query := parsed.RawQuery + parsed.RawQuery = "" + portalURL = parsed.String() + "%3F" + query + } + portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1" + + s, err := webbrowser.Open("https://portal.mozz.us/gemini/" + portalURL) + if err != nil { + Error("Error", err.Error()) + } else { + Info(s) + } + App.Draw() + } + return "", false +} + +// normalizeURL attempts to make URLs that are different strings +// but point to the same place all look the same. +// +// Example: gemini://gus.guru:1965/ and //gus.guru/. +// This function will take both output the same URL each time. +// +// The string passed must already be confirmed to be a URL. +// Detection of a search string vs. a URL must happen elsewhere. +// +// It only works with absolute URLs. +func normalizeURL(u string) string { + parsed, err := url.Parse(u) + if err != nil { + return u + } + + if !strings.Contains(u, "://") && !strings.HasPrefix(u, "//") { + // No scheme at all in the URL + parsed, err = url.Parse("gemini://" + u) + if err != nil { + return u + } + } + if parsed.Scheme == "" { + // Always add scheme + parsed.Scheme = "gemini" + } else if parsed.Scheme != "gemini" { + // Not a gemini URL, nothing to do + return u + } + + parsed.User = nil // No passwords in Gemini + parsed.Fragment = "" // No fragments either + if parsed.Port() == "1965" { + // Always remove default port + parsed.Host = parsed.Hostname() + } + + // Add slash to the end of a URL with just a domain + // gemini://example.com -> gemini://example.com/ + if parsed.Path == "" { + parsed.Path = "/" + } + + return parsed.String() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..625ac26 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/makeworld-the-better-one/amfora + +go 1.14 + +require ( + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606 + github.com/makeworld-the-better-one/go-gemini v0.5.0 + github.com/mitchellh/go-homedir v1.1.0 + github.com/mitchellh/mapstructure v1.3.1 // indirect + github.com/pelletier/go-toml v1.8.0 // indirect + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.7.0 + github.com/stretchr/testify v1.6.0 + gitlab.com/tslocum/cview v1.4.8-0.20200614211415-f477be8ba472 + gopkg.in/ini.v1 v1.57.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9c8d4d7 --- /dev/null +++ b/go.sum @@ -0,0 +1,389 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606 h1:Y00kKKKYVyn7InlCMRcnZbwcjHFIsgkjU0Bn1F5re4o= +github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +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/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/makeworld-the-better-one/go-gemini v0.3.1 h1:Vx1+loZ2MfWPC0qd+vEFaIcxmWJoagz+rFxX5uZxeBw= +github.com/makeworld-the-better-one/go-gemini v0.3.1/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.3.2-0.20200614183147-32dd6eceb1d0 h1:Kz6VOurTGz4lDLBy3Vd5Gak+e0khPEwSDDj4x0SegvI= +github.com/makeworld-the-better-one/go-gemini v0.3.2-0.20200614183147-32dd6eceb1d0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.0 h1:UYGqvCMedgV74Qro8pLVGYJ3LN7gK6uC6GNhw1bqRiM= +github.com/makeworld-the-better-one/go-gemini v0.4.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616003826-382c5f0c0ef3 h1:H2kArUN0jFSQ/3RI3KhE10tOIQAkKtLrI4kMK3sPccM= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616003826-382c5f0c0ef3/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616230531-9d7ac4323c2b h1:JmlaXeOwDaV0S5eBGaUjt647H+9KT274EruXuZ00ceM= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616230531-9d7ac4323c2b/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616232915-ed61139d1eee h1:a17hCyK0fDzsXetLcoNRJpITbDewevIMziE9AEdiqC0= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616232915-ed61139d1eee/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616233000-90c00ecba439 h1:lGpF/Y98k54eewpHabpFiXNNUyLZXhzKNf8ENIPE9XE= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200616233000-90c00ecba439/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200617040701-61e0b380d100 h1:QBeer60NxocMspaK15+DWZCIzIJ7qjyRAcCIZ629bVc= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200617040701-61e0b380d100/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200617041401-3154e68a3755 h1:A19eOhFAZ/RvVmSTkpghF34vsvUGDIENkFKjcfQ0e8w= +github.com/makeworld-the-better-one/go-gemini v0.4.1-0.20200617041401-3154e68a3755/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.1 h1:JZPllyrKY0IE0Ts88rMEpvqmnjAb77xhlYnafbunTH0= +github.com/makeworld-the-better-one/go-gemini v0.4.1/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.4.2-0.20200617144014-0b132b84d585 h1:qvr3wgkbgcbxStfNb49B+CDFtiTiUCBLGvyuWHfTSQ0= +github.com/makeworld-the-better-one/go-gemini v0.4.2-0.20200617144014-0b132b84d585/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.5.0 h1:M/Adz5Xf7rsL59tgD7+uFIpLvVspuEhwgVyr2EmhrxQ= +github.com/makeworld-the-better-one/go-gemini v0.5.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.5.1-0.20200618180334-440c780d8473 h1:7zgnlrKbhVUE06053XzA72zO1/rE9hBlXMAg48D8p90= +github.com/makeworld-the-better-one/go-gemini v0.5.1-0.20200618180334-440c780d8473/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-gemini v0.5.1-0.20200618180814-9209e8a23cf3 h1:+JZQRDQeNT5yOFAemYmNeck8a32gBKA9S/iYVqD611M= +github.com/makeworld-the-better-one/go-gemini v0.5.1-0.20200618180814-9209e8a23cf3/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5Y5bqfLA= +github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +gitlab.com/tslocum/cbind v0.1.1 h1:JXXtxMWHgWLvoF+QkrvcNvOQ59juy7OE1RhT7hZfdt0= +gitlab.com/tslocum/cbind v0.1.1/go.mod h1:rX7vkl0pUSg/yy427MmD1FZAf99S7WwpUlxF/qTpPqk= +gitlab.com/tslocum/cview v1.4.8-0.20200609225128-e0fafcdb01b7 h1:gHN0ESC32n+Qd1gs0pwHeNCB8o4ETHTd06zt2wh/wyo= +gitlab.com/tslocum/cview v1.4.8-0.20200609225128-e0fafcdb01b7/go.mod h1:eHzQzCnul21ODOS/wTnYJs6d3lkVYAv2/KRo+5wPsh0= +gitlab.com/tslocum/cview v1.4.8-0.20200614211415-f477be8ba472 h1:lvLn/TWWgqG1gJAd1a8DOSPgkrQEWaMg+AQhM5/PdzY= +gitlab.com/tslocum/cview v1.4.8-0.20200614211415-f477be8ba472/go.mod h1:QctoEJaR2AqZTy0KKo12P1ZjHgQJyVkAXaeDanBBhlE= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200610111108-226ff32320da h1:bGb80FudwxpeucJUjPYJXuJ8Hk91vNtfvrymzwiei38= +golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww= +gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8 h1:jL/vaozO53FMfZLySWM+4nulF3gQEC6q5jH90LPomDo= +gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..6ef41b4 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,20 @@ +package logger + +// For debugging + +import ( + "log" + "os" +) + +var Log *log.Logger + +func Init() error { + f, err := os.Create("debug.log") + if err != nil { + return err + } + Log = log.New(f, "", log.LstdFlags) + Log.Println("Started Log") + return nil +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8c3b2333b5b6237db145795a1812c667ceecf3ec GIT binary patch literal 20606 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 H=-mGSsJ372 literal 0 HcmV?d00001 diff --git a/renderer/renderer.go b/renderer/renderer.go new file mode 100644 index 0000000..aefaeca --- /dev/null +++ b/renderer/renderer.go @@ -0,0 +1,238 @@ +// Package renderer provides functions to convert various data into a cview primitive. +// Example objects include a Gemini response, and an error. +// +// Rendered lines always end with \r\n, in an effort to be Window compatible. +package renderer + +import ( + "errors" + "io/ioutil" + "mime" + urlPkg "net/url" + "strconv" + "strings" + + "github.com/makeworld-the-better-one/amfora/config" + "github.com/makeworld-the-better-one/amfora/structs" + "github.com/makeworld-the-better-one/go-gemini" + "github.com/spf13/viper" + "gitlab.com/tslocum/cview" +) + +// CanDisplay returns true if the response is supported by Amfora +// for displaying on the screen. +// It also doubles as a function to detect whether something can be stored in a Page struct. +func CanDisplay(res *gemini.Response) bool { + if gemini.SimplifyStatus(res.Status) != 20 { + // No content + return false + } + mediatype, params, err := mime.ParseMediaType(res.Meta) + if err != nil { + return false + } + if strings.ToLower(params["charset"]) != "utf-8" && strings.ToLower(params["charset"]) != "us-ascii" && params["charset"] != "" { + // Amfora doesn't support other charsets + return false + } + if mediatype != "text/gemini" && mediatype != "text/plain" { + // Amfora doesn't support other filetypes + return false + } + return true +} + +// convertRegularGemini converts non-preformatted blocks of text/gemini +// into a cview-compatible format. +// It also returns a slice of link URLs. +// numLinks is the number of links that exist so far. +// +// Since this only works on non-preformatted blocks, renderGemini +// should always be used instead. +// +// TODO: Style cross-protocol links differently +// +func convertRegularGemini(s string, numLinks int) (string, []string) { + links := make([]string, 0) + lines := strings.Split(s, "\n") + wrappedLines := make([]string, 0) // Final result + + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " \r\t\n") + + if strings.HasPrefix(lines[i], "#") && viper.GetBool("a-general.color") { + + // Headings + if strings.HasPrefix(lines[i], "###") { + lines[i] = "[fuchsia::b]" + lines[i] + "[-::-]" + } + if strings.HasPrefix(lines[i], "##") { + lines[i] = "[lime::b]" + lines[i] + "[-::-]" + } + if strings.HasPrefix(lines[i], "#") { + lines[i] = "[red::b]" + lines[i] + "[-::-]" + } + + // Links + } else if strings.HasPrefix(lines[i], "=>") && len([]rune(lines[i])) >= 3 { + // Trim whitespace and separate link from link text + + lines[i] = strings.Trim(lines[i][2:], " \t") // Remove `=>` part too + delim := strings.IndexAny(lines[i], " \t") // Whitespace between link and link text + + var url string + var linkText string + if delim == -1 { + // No link text + url = lines[i] + linkText = url + } else { + // There is link text + url = lines[i][:delim] + linkText = strings.Trim(lines[i][delim:], " \t") + } + + if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" { + // Link was just whitespace, reset it and move on + lines[i] = "=>" + wrappedLines = append(wrappedLines, lines[i]) + continue + } + + links = append(links, url) + + if viper.GetBool("a-general.color") { + if pU, _ := urlPkg.Parse(url); pU.Scheme == "" || pU.Scheme == "gemini" { + // A gemini link + // Add the link text in blue (in a region), and a gray link number to the left of it + lines[i] = `[silver::b][` + strconv.Itoa(numLinks+len(links)) + "[]" + "[-::-] " + + `[dodgerblue]["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]` + } else { + // Not a gemini link, use purple instead + lines[i] = `[silver::b][` + strconv.Itoa(numLinks+len(links)) + "[]" + "[-::-] " + + `[#8700d7]["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]` + } + } else { + // No colours allowed + lines[i] = `[::b][` + strconv.Itoa(numLinks+len(links)) + "[] " + + `["` + strconv.Itoa(numLinks+len(links)-1) + `"]` + linkText + `[""][-]` + } + + // Lists + } else if strings.HasPrefix(lines[i], "* ") { + if viper.GetBool("a-general.bullets") { + lines[i] = " 🞄" + lines[i][1:] + } + // Optionally list lines could be colored here too, if color is enabled + } + + // Final processing of each line: wrapping + + if strings.TrimSpace(lines[i]) == "" { + // Just add empty line without processing + wrappedLines = append(wrappedLines, "") + } else { + if (strings.HasPrefix(lines[i], "[silver::b]") && viper.GetBool("a-general.color")) || strings.HasPrefix(lines[i], "[::b]") { + // It's a link line, so don't wrap it + wrappedLines = append(wrappedLines, lines[i]) + } else if strings.HasPrefix(lines[i], ">") { + // It's a quote line, add extra quote symbols to the start of each wrapped line + + // Remove beginning quote and maybe space + lines[i] = strings.TrimPrefix(lines[i], ">") + lines[i] = strings.TrimPrefix(lines[i], " ") + + temp := cview.WordWrap(lines[i], config.GetWrapWidth()) + for i := range temp { + temp[i] = "> " + temp[i] + } + wrappedLines = append(wrappedLines, temp...) + } else { + wrappedLines = append(wrappedLines, cview.WordWrap(lines[i], config.GetWrapWidth())...) + } + } + } + + return strings.Join(wrappedLines, "\r\n"), links +} + +// renderGemini converts text/gemini into a cview displayable format. +// It also returns a slice of link URLs. +func RenderGemini(s string) (string, []string) { + s = cview.Escape(s) + if viper.GetBool("a-general.color") { + s = cview.TranslateANSI(s) + } + lines := strings.Split(s, "\n") + + links := make([]string, 0) + + // Process and wrap non preformatted lines + rendered := "" // Final result + pre := false + buf := "" // Block of regular or preformatted lines + for i := range lines { + if strings.HasPrefix(lines[i], "```") { + if pre { + // In a preformatted block, so add the text as is + // Don't add the current line with backticks + rendered += buf + } else { + // Not preformatted, regular text + ren, lks := convertRegularGemini(buf, len(links)) + links = append(links, lks...) + rendered += ren + } + buf = "" // Clear buffer for next block + pre = !pre + continue + } + // Lines always end with \r\n for Windows compatibility + buf += strings.TrimSuffix(lines[i], "\r") + "\r\n" + } + // Gone through all the lines, but there still is likely a block in the buffer + if pre { + // File ended without closing the preformatted block + rendered += buf + } else { + // Not preformatted, regular text + // Same code as in the loop above + ren, lks := convertRegularGemini(buf, len(links)) + links = append(links, lks...) + rendered += ren + } + + return rendered, links +} + +func MakePage(url string, res *gemini.Response) (*structs.Page, error) { + if !CanDisplay(res) { + return nil, errors.New("not valid content for a Page") + } + + content, err := ioutil.ReadAll(res.Body) // TODO: Don't use all memory on large pages + if err != nil { + return nil, err + } + res.Body.Close() + + mediatype, _, _ := mime.ParseMediaType(res.Meta) + + if mediatype == "text/plain" { + return &structs.Page{ + Url: url, + Content: string(content), + Links: []string{}, // Plaintext has no links + }, nil + } + if mediatype == "text/gemini" { + rendered, links := RenderGemini(string(content)) + return &structs.Page{ + Url: url, + Content: rendered, + Links: links, + }, nil + } + + return nil, errors.New("displayable mediatype is not handled in the code, implementation error") +} diff --git a/structs/structs.go b/structs/structs.go new file mode 100644 index 0000000..29ec89a --- /dev/null +++ b/structs/structs.go @@ -0,0 +1,19 @@ +package structs + +// Page is for storing UTF-8 text/gemini pages, as well as text/plain pages. +type Page struct { + Url string + Content string // The processed content, NOT raw. Uses cview colour tags. All link/link texts must have region tags. + Links []string // URLs, for each region in the content. + Row int // Scroll position + Column int +} + +// Size returns an approx. size of a Page in bytes. +func (p *Page) Size() int { + b := len(p.Content) + len(p.Url) + for i := range p.Links { + b += len(p.Links[i]) + } + return b +} diff --git a/structs/structs_test.go b/structs/structs_test.go new file mode 100644 index 0000000..3290a56 --- /dev/null +++ b/structs/structs_test.go @@ -0,0 +1,16 @@ +package structs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSize(t *testing.T) { + p := Page{ + Url: "12345", + Content: "12345", + Links: []string{"1", "2", "3", "4", "5"}, + } + assert.Equal(t, 15, p.Size(), "sizes should be equal") +} diff --git a/webbrowser/README.md b/webbrowser/README.md new file mode 100644 index 0000000..adfef33 --- /dev/null +++ b/webbrowser/README.md @@ -0,0 +1,5 @@ +# `package webbrowser` + +The code in this folder is adapted from Bombadillo, you can see the original [here](https://tildegit.org/sloum/bombadillo/src/branch/master/http). Many thanks to Sloum and the rest of the team! + +The code is simple, and I have changed it, but in any case there should be no licensing issues because both repos are under GPL v3. diff --git a/webbrowser/open_browser_darwin.go b/webbrowser/open_browser_darwin.go new file mode 100644 index 0000000..e9992b7 --- /dev/null +++ b/webbrowser/open_browser_darwin.go @@ -0,0 +1,13 @@ +// +build darwin + +package webbrowser + +import "os/exec" + +func Open(url string) (string, error) { + err := exec.Command("open", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +} diff --git a/webbrowser/open_browser_other.go b/webbrowser/open_browser_other.go new file mode 100644 index 0000000..3c64bf2 --- /dev/null +++ b/webbrowser/open_browser_other.go @@ -0,0 +1,9 @@ +// +build !linux,!darwin,!windows,!freebsd,!netbsd,!openbsd + +package webbrowser + +import "fmt" + +func Open(url string) (string, error) { + return "", fmt.Errorf("unsupported os for default HTTP handling. Set a command in the config") +} diff --git a/webbrowser/open_browser_unix.go b/webbrowser/open_browser_unix.go new file mode 100644 index 0000000..298abaf --- /dev/null +++ b/webbrowser/open_browser_unix.go @@ -0,0 +1,34 @@ +// +build linux freebsd netbsd openbsd + +package webbrowser + +import ( + "fmt" + "os" + "os/exec" +) + +// OpenInBrowser checks for the presence of a display server +// and environment variables indicating a gui is present. If found +// then xdg-open is called on a url to open said url in the default +// gui web browser for the system +func Open(url string) (string, error) { + disp := os.Getenv("DISPLAY") + wayland := os.Getenv("WAYLAND_DISPLAY") + _, err := exec.LookPath("Xorg") + if disp == "" && wayland == "" && err != nil { + return "", fmt.Errorf("no gui is available") + } + + _, err = exec.LookPath("xdg-open") + if err != nil { + return "", fmt.Errorf("xdg-open command not found, cannot open in web browser") + } + // Use start rather than run or output in order + // to release the process and not block + err = exec.Command("xdg-open", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +} diff --git a/webbrowser/open_browser_windows.go b/webbrowser/open_browser_windows.go new file mode 100644 index 0000000..61b4bb4 --- /dev/null +++ b/webbrowser/open_browser_windows.go @@ -0,0 +1,14 @@ +// +build windows +// +build !linux !darwin !freebsd !netbsd !openbsd + +package webbrowser + +import "os/exec" + +func Open(url string) (string, error) { + err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + if err != nil { + return "", err + } + return "Opened in system default web browser", nil +}