1
0
mirror of https://github.com/makew0rld/amfora.git synced 2024-12-04 14:46:29 -05:00

🎉 Initial commit, full featured

This commit is contained in:
makeworld 2020-06-18 16:54:48 -04:00
commit 4951ffa9fe
30 changed files with 2465 additions and 0 deletions

107
.gitignore vendored Normal file
View File

@ -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

21
NOTES.md Normal file
View File

@ -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?

58
README.md Normal file
View File

@ -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 <url> <other-url>` on the terminal. On Windows it might be `amfora.exe` instead.
The project keeps many standard terminal keybindings and is intuitive. Press <kbd>?</kbd> inside the application to pull up the help menu with a list of all the keybindings, and <kbd>Esc</kbd> 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 <kbd>Ctrl-F</kbd>
- [ ] 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\<username>\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.

35
amfora.go Normal file
View File

@ -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)
}
}

125
cache/cache.go vendored Normal file
View File

@ -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
}

82
cache/cache_test.go vendored Normal file
View File

@ -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")
}
}

22
client/client.go Normal file
View File

@ -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
}

98
client/tofu.go Normal file
View File

@ -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
}

122
config/config.go Normal file
View File

@ -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
}

33
config/default.go Normal file
View File

@ -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
`)

6
config/default.sh Executable file
View File

@ -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

29
default-config.toml Normal file
View File

@ -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

431
display/display.go Normal file
View File

@ -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()
}

24
display/help.go Normal file
View File

@ -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.
`)

44
display/history.go Normal file
View File

@ -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()
}()
}

156
display/modals.go Normal file
View File

@ -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
}

13
display/newtab.go Normal file
View File

@ -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]
`

281
display/private.go Normal file
View File

@ -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()
}

21
go.mod Normal file
View File

@ -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
)

389
go.sum Normal file
View File

@ -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=

20
logger/logger.go Normal file
View File

@ -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
}

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

238
renderer/renderer.go Normal file
View File

@ -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")
}

19
structs/structs.go Normal file
View File

@ -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
}

16
structs/structs_test.go Normal file
View File

@ -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")
}

5
webbrowser/README.md Normal file
View File

@ -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.

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}