diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index a080e79..617302d 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -1,5 +1,19 @@
name: golangci-lint
-on: [push, pull_request]
+
+on:
+ push:
+ paths-ignore:
+ - '**.md'
+ - '**.toml'
+ - '**.desktop'
+ - 'LICENSE'
+ pull_request:
+ paths-ignore:
+ - '**.md'
+ - '**.toml'
+ - '**.desktop'
+ - 'LICENSE'
+
jobs:
golangci:
name: lint
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1b02251..51d50d8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,9 +1,23 @@
-on: [push, pull_request]
name: Test
+on:
+ push:
+ paths-ignore:
+ - '**.md'
+ - '**.toml'
+ - '**.desktop'
+ - 'LICENSE'
+ pull_request:
+ paths-ignore:
+ - '**.md'
+ - '**.toml'
+ - '**.desktop'
+ - 'LICENSE'
+
jobs:
test:
strategy:
+ fail-fast: false
matrix:
go-version: ['1.13', '1.14', '1.15']
os: [ubuntu-latest, macos-latest, windows-latest]
diff --git a/.gitignore b/.gitignore
index f184910..dceeb46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,15 +14,15 @@ rec.yml
# 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
+
+# Created by https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows,python
+# Edit at https://www.toptal.com/developers/gitignore?templates=code,go,linux,macos,windows,python
### Code ###
.vscode/*
-!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
-!.vscode/extensions.json
+*.code-workspace
### Go ###
# Binaries for programs and plugins
@@ -38,6 +38,9 @@ rec.yml
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
### Go Patch ###
/vendor/
/Godeps/
@@ -66,6 +69,7 @@ rec.yml
# Icon must end with two \r
Icon
+
# Thumbnails
._*
@@ -85,9 +89,149 @@ Network Trash Folder
Temporary Items
.apdisk
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# profiling data
+.prof
+
### Windows ###
# Windows thumbnail cache files
Thumbs.db
+Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
@@ -110,4 +254,4 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
-# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
+# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows,python
diff --git a/.golangci.yml b/.golangci.yml
index ea23ce9..80cc6f1 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -15,7 +15,6 @@ linters:
- dupl
- exhaustive
- exportloopref
- - goconst
- gocritic
- goerr113
- gofmt
@@ -26,7 +25,6 @@ linters:
- lll
- maligned
- misspell
- - nakedret
- nolintlint
- prealloc
- scopelint
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c619c1..7703e51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,15 +5,59 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+- **Media type handlers** - open non-text files in another application (#121, #134)
+- Ability to set custom keybindings in config (#135)
+
### Changed
-- Update cview to `36671ba7d31c2287748e22966a92c5e94ff850cc` for large perf and feature updates (#107)
-- Update to tcell v2 (depencency of cview)
+- Update cview to `1af0da7606b8476944b5740bb4f0b711aaf2a1df` for large perf and feature updates (#107)
+- Update to tcell v2 (dependency of cview)
### Fixed
+- Don't use cache when URL is typed in bottom bar (#159)
+- Fix downloading of pages that are too large or timed out
- More reliable start, no more flash of unindented text, or text that stays unindented (#107)
-## [v1.6.0] - 2020-11-04
+## [1.7.2] - 2020-12-21
+### Fixed
+- Viewing subscriptions after subscribing to a certain user page won't crash Amfora (#157)
+
+
+## [1.7.1] - 2020-12-21
+### Fixed
+- Fixed bug that caused Amfora to crash when subscribing to a page (#151)
+
+
+## [1.7.0] - 2020-12-20
+### Added
+- **Subscriptions** to feeds and page changes (#61)
+- Opening local files with `file://` URIs (#103, #117)
+- `show_link` option added in config to optionally see the URL (#133)
+- Support for Unicode in domain names (IDNs)
+- Unnecessarily encoded characters in URLs will be decoded (#138)
+- URLs are NFC-normalized before any processing (#138)
+- Links to the wiki in the new tab
+- Cache times out after 30 minutes by default (#110)
+- `about:version` page (#126)
+
+### Changed
+- Updated [go-gemini](https://github.com/makeworld-the-better-one/go-gemini) to v0.11.0
+ - Supports CN-only wildcard certs
+ - Time out when header takes too long
+- Preformatted text is now light yellow by default
+- Downloading a file no longer uses a second request
+- You can go back to the new tab page in history (#96)
+
+### Fixed
+- Single quotes are used in the default config for commands and paths so that Windows paths with backslashes will be parsed correctly
+- Downloading now uses proxies when appropriate
+- User-entered URLs with invalid characters will be percent-encoded (#138)
+- Custom downloads dir is actually used (#148)
+- Empty quote lines no longer disappear
+
+
+## [1.6.0] - 2020-11-04
### Added
- **Support client certificates** through config (#112)
- `ansi` config setting, to disable ANSI colors in pages (#79, #86)
@@ -39,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Make the `..` command work lke it used to in v1.4.0
-## [v1.5.0] - 2020-09-01
+## [1.5.0] - 2020-09-01
### Added
- **Proxy support** - see the `[proxies]` section in the config (#66, #80)
- **Emoji favicons** can now be seen if `emoji_favicons` is enabled in the config (#62)
diff --git a/Makefile b/Makefile
index 5bd59ef..3738545 100644
--- a/Makefile
+++ b/Makefile
@@ -21,8 +21,8 @@ clean:
.PHONY: install
install: amfora amfora.desktop
- install -Dm 755 amfora $(PREFIX)/bin/amfora
- install -Dm 644 amfora.desktop $(PREFIX)/share/applications/amfora.desktop
+ install -m 755 amfora $(PREFIX)/bin/amfora
+ install -m 644 amfora.desktop $(PREFIX)/share/applications/amfora.desktop
.PHONY: uninstall
uninstall:
diff --git a/NOTES.md b/NOTES.md
index d876f63..c90cb17 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -3,10 +3,6 @@
## Issues
- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL
-
-## Regressions
-
-
## Upstream Bugs
- Wrapping messes up on brackets
- Filed [issue 23](https://gitlab.com/tslocum/cview/-/issues/23)
diff --git a/README.md b/README.md
index 84890c0..a144138 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@
Image modified from: amphora by Alvaro Cabrera from the Noun Project
-[![travis build status](https://img.shields.io/travis/com/makeworld-the-better-one/amfora/master?label=master)](https://travis-ci.com/github/makeworld-the-better-one/amfora)
[![go reportcard](https://goreportcard.com/badge/github.com/makeworld-the-better-one/amfora)](https://goreportcard.com/report/github.com/makeworld-the-better-one/amfora)
[![license GPLv3](https://img.shields.io/github/license/makeworld-the-better-one/amfora)](https://www.gnu.org/licenses/gpl-3.0.en.html)
@@ -21,13 +20,13 @@ Amfora aims to be the best looking [Gemini](https://gemini.circumlunar.space/) c
It also aims to be completely cross platform, with full Windows support. If you're on Windows, I would not recommend using the default terminal software. Use [Windows Terminal](https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701) instead, and make sure it [works with UTF-8](https://akr.am/blog/posts/using-utf-8-in-the-windows-terminal). Note that some of the application colors might not display correctly on Windows, but all functionality will still work.
-It fully passes Sean Conman's client torture test, including the new Unicode tests. It mostly passes the Egsam test.
+It fully passes Sean Conman's client torture test, as well as the Egsam one.
## Installation
### Binary
-Download a binary from the [releases](https://github.com/makeworld-the-better-one/amfora/releases) page. On Unix-based systems you might have to make the file executable with `chmod +x `. You can rename the file to just `amfora` for easy access, and move it to `/usr/local/bin/`.
+Download a binary from the [releases](https://github.com/makeworld-the-better-one/amfora/releases) page. On Unix-based systems you will have to make the file executable with `chmod +x `. You can rename the file to just `amfora` for easy access, and move it to `/usr/local/bin/`.
On Windows, make sure you click "Advanced > Run anyway" after double-clicking, or something like that.
@@ -37,7 +36,7 @@ curl -sSL https://raw.githubusercontent.com/makeworld-the-better-one/amfora/mast
update-desktop-database ~/.local/share/applications
```
-Make sure to click "Watch" > "Releases only" in the top right to get notified about new releases!
+Make sure to click "Watch" in the top right, then "Custom" > "Releases" to get notified about new releases!
### Arch Linux
@@ -50,7 +49,7 @@ sudo pacman -S amfora
### Homebrew
-If you use [Homebrew](https://brew.sh/), you can install Amfora through the official tap.
+If you use [Homebrew](https://brew.sh/), you can install Amfora through the my personal tap.
```
brew tap makeworld-the-better-one/tap
brew install amfora
@@ -60,9 +59,29 @@ You can update it with:
brew upgrade amfora
```
+### KISS Linux
+
+[KISS](k1ss.org) Linux users can install Amfora from jedahan's repository.
+
+Add jedahan's kiss repository:
+```
+git clone https://github.com/jedahan/kiss-repo.git repo-jedahan
+export KISS_PATH="$KISS_PATH:$PWD/repo-jedahan"
+```
+
+Build and install Amfora:
+```
+kiss build amfora
+kiss install amfora
+```
+
### From Source
+
This section is for advanced users who want to install the latest (possibly unstable) version of Amfora.
+
+Click to expand
+
**Requirements:**
- Go 1.13 or later
- GNU Make
@@ -79,6 +98,12 @@ sudo make install # If you want to install the binary for all users
Because you installed with the Makefile, running `amfora -v` will tell you exactly what commit the binary was built from.
+Arch Linux users can also install the latest commit of Amfora from the AUR. It has the package name `amfora-git`, and is maintained by @lovetocode999
+
+```
+yay -S amfora-git
+```
+
MacOS users can also use [Homebrew](https://brew.sh/) to install the latest commit of Amfora:
```
@@ -90,17 +115,8 @@ You can update it with:
brew upgrade --fetch-HEAD amfora
```
-## Usage
+
-Just call `amfora` or `amfora ` on the terminal. On Windows it might be `amfora.exe` instead.
-
-To determine the version, you can run `amfora --version` or `amfora -v`.
-
-The project keeps many standard terminal keybindings and is intuitive. Press ? inside the application to pull up the help menu with a list of all the keybindings, and Esc to leave it. If you have used Bombadillo you will find it similar.
-
-It is designed with large terminals in mind, but should look and work well at any reasonable terminal size.
-
-It was tested with left-to-right languages, and will likely not work as well with right-to-left languages like Arabic.
## Features / Roadmap
Features in *italics* are in the master branch, but not in the latest release.
@@ -116,6 +132,7 @@ Features in *italics* are in the master branch, but not in the latest release.
- [x] Bookmarks
- [x] Download pages and arbitrary data
- [x] Theming
+ - Check out the [user contributed themes](https://github.com/makeworld-the-better-one/amfora/tree/master/contrib/themes)!
- [x] Emoji favicons
- See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details
- Disabled by default, enable in config
@@ -127,29 +144,21 @@ Features in *italics* are in the master branch, but not in the latest release.
- Manage and browse them
- Similar to [Kristall](https://github.com/MasterQ32/kristall)
- https://lists.orbitalfox.eu/archives/gemini/2020/001400.html
-- [ ] Subscribe to RSS and Atom feeds and display them
- - Subscribing to page changes, similar to how Spacewalk works, will also be supported
- - *In progress on `feeds` branch*
+- [x] Subscriptions
+ - Subscribing to RSS, Atom, and [JSON Feeds](https://jsonfeed.org/) are all supported
+ - So is subscribing to a page, to know when it changes
+- [x] *Open non-text files in another application*
+ - [x] *Ability to stream content instead of downloading it first*
- [ ] Stream support
- [ ] Table of contents for pages
- [ ] Search in pages with Ctrl-F
- [ ] Support Markdown rendering
-- [ ] History browser
+- [ ] Persistent history
-## 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`, or `$XDG_CONFIG_HOME/amfora/config.toml`, if that variable is set.
-On Windows, the file is in `%APPDATA%\amfora\config.toml`, which usually expands to `C:\Users\\AppData\Roaming\amfora\config.toml`.
+## Usage & Configuration
+Please see [the wiki](https://github.com/makeworld-the-better-one/amfora/wiki) for an introduction on how to use Amfora and configure it.
-## Client Certificates
-
-Amfora has early support for client certs. Eventually Amfora will be able to generate them itself, but for you can do it by using OpenSSL (not Windows friendly):
-
-```shell
-openssl req -new -subj "/CN=username" -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -out cert.pem -keyout key.pem
-```
-
-This will create a certificate and key file, that can be renamed and moved as you like. See the configuration section above for how to edit your config file to tell Amfora about them.
## Known Bugs
@@ -166,8 +175,9 @@ Amfora ❤️ open source!
- 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
-- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar)
+- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar) - pull request [here](https://github.com/schollz/progressbar/pull/69)
- [go-humanize](https://github.com/dustin/go-humanize)
+- My [gofeed fork](https://github.com/makeworld-the-better-one/gofeed) - pull request [here](https://github.com/mmcdole/gofeed/pull/164)
## License
This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details.
diff --git a/THANKS.md b/THANKS.md
index 0e093f4..498411e 100644
--- a/THANKS.md
+++ b/THANKS.md
@@ -10,4 +10,8 @@ Thank you to the following contributors, who have helped make Amfora great. FOSS
- Timur Ismagilov (@bouncepaw)
- Matt Caroll (@ohiolab)
- Patryk Niedźwiedziński (@pniedzwiedzinski)
-- Trevor Slocum (@tsclocum)
\ No newline at end of file
+- Trevor Slocum (@tsclocum)
+- Mattias Jadelius (@jedthehumanoid)
+- Lokesh Krishna (@lokesh-krishna)
+- Jeff (@phaedrus-jaf)
+- Stephen Robinson (@sudobash1)
diff --git a/amfora.go b/amfora.go
index 6beb285..179e0bd 100644
--- a/amfora.go
+++ b/amfora.go
@@ -4,12 +4,14 @@ import (
"fmt"
"os"
+ "github.com/makeworld-the-better-one/amfora/client"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/display"
+ "github.com/makeworld-the-better-one/amfora/subscriptions"
)
var (
- version = "v1.6.0"
+ version = "v1.7.2"
commit = "unknown"
builtBy = "unknown"
)
@@ -39,9 +41,16 @@ func main() {
err := config.Init()
if err != nil {
- fmt.Printf("Config error: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}
+ err = subscriptions.Init()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "subscriptions.json error: %v\n", err)
+ os.Exit(1)
+ }
+
+ client.Init()
// Initalize lower-level cview app
if err = display.App.Init(); err != nil {
@@ -49,7 +58,7 @@ func main() {
}
// Initialize Amfora's settings
- display.Init()
+ display.Init(version, commit, builtBy)
display.NewTab()
if len(os.Args[1:]) > 0 {
display.URL(os.Args[1])
diff --git a/cache/favicons.go b/cache/favicon.go
similarity index 100%
rename from cache/favicons.go
rename to cache/favicon.go
diff --git a/cache/cache.go b/cache/page.go
similarity index 84%
rename from cache/cache.go
rename to cache/page.go
index fa0bc3d..4e80b89 100644
--- a/cache/cache.go
+++ b/cache/page.go
@@ -1,11 +1,10 @@
// Package cache provides an interface for a cache of strings, aka text/gemini pages, and redirects.
// It is fully thread safe.
-// The redirect cache is not limited.
package cache
import (
- "strings"
"sync"
+ "time"
"github.com/makeworld-the-better-one/amfora/structs"
)
@@ -15,6 +14,7 @@ var urls = make([]string, 0) // Duplicate of the keys in the `page
var maxPages = 0 // Max allowed number of pages in cache
var maxSize = 0 // Max allowed cache size in bytes
var lock = sync.RWMutex{}
+var timeout = time.Duration(0)
// SetMaxPages sets the max number of pages the cache can hold.
// A value <= 0 means infinite pages.
@@ -22,12 +22,22 @@ func SetMaxPages(max int) {
maxPages = max
}
-// SetMaxSize sets the max size the cache can be, in bytes.
+// SetMaxSize sets the max size the page cache can be, in bytes.
// A value <= 0 means infinite size.
func SetMaxSize(max int) {
maxSize = max
}
+// SetTimeout sets the max number of a seconds a page can still
+// be valid for. A value <= 0 means forever.
+func SetTimeout(t int) {
+ if t <= 0 {
+ timeout = time.Duration(0)
+ return
+ }
+ timeout = time.Duration(t) * time.Second
+}
+
func removeIndex(s []string, i int) []string {
s[len(s)-1], s[i] = s[i], s[len(s)-1]
return s[:len(s)-1]
@@ -48,7 +58,7 @@ func removeURL(url string) {
// If your page is larger than the max cache size, the provided page
// will silently not be added to the cache.
func AddPage(p *structs.Page) {
- if p.URL == "" || strings.HasPrefix(p.URL, "about:") {
+ if p.URL == "" {
// Just in case, these pages shouldn't be cached
return
}
@@ -112,10 +122,14 @@ func NumPages() int {
}
// GetPage 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.
+// (nil, false) is returned if the page isn't in the cache.
func GetPage(url string) (*structs.Page, bool) {
lock.RLock()
defer lock.RUnlock()
+
p, ok := pages[url]
- return p, ok
+ if ok && (timeout == 0 || time.Since(p.MadeAt) < timeout) {
+ return p, ok
+ }
+ return nil, false
}
diff --git a/cache/cache_test.go b/cache/page_test.go
similarity index 100%
rename from cache/cache_test.go
rename to cache/page_test.go
diff --git a/client/client.go b/client/client.go
index 2ec65d5..f216fcc 100644
--- a/client/client.go
+++ b/client/client.go
@@ -5,17 +5,34 @@ import (
"io/ioutil"
"net"
"net/url"
+ "sync"
+ "time"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
-var certCache = make(map[string][][]byte)
+var (
+ certCache = make(map[string][][]byte)
+ certCacheMu = &sync.RWMutex{}
+
+ fetchClient *gemini.Client
+)
+
+func Init() {
+ fetchClient = &gemini.Client{
+ ConnectTimeout: 10 * time.Second, // Default is 15
+ ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second,
+ }
+}
func clientCert(host string) ([]byte, []byte) {
- if cert := certCache[host]; cert != nil {
- return cert[0], cert[1]
+ certCacheMu.RLock()
+ pair, ok := certCache[host]
+ certCacheMu.RUnlock()
+ if ok {
+ return pair[0], pair[1]
}
// Expand paths starting with ~/
@@ -28,22 +45,30 @@ func clientCert(host string) ([]byte, []byte) {
keyPath = viper.GetString("auth.keys." + host)
}
if certPath == "" && keyPath == "" {
+ certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
+ certCacheMu.Unlock()
return nil, nil
}
cert, err := ioutil.ReadFile(certPath)
if err != nil {
+ certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
+ certCacheMu.Unlock()
return nil, nil
}
key, err := ioutil.ReadFile(keyPath)
if err != nil {
+ certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
+ certCacheMu.Unlock()
return nil, nil
}
+ certCacheMu.Lock()
certCache[host] = [][]byte{cert, key}
+ certCacheMu.Unlock()
return cert, key
}
@@ -53,18 +78,16 @@ func HasClientCert(host string) bool {
return cert != nil
}
-// Fetch returns response data and an error.
-// The error text is human friendly and should be displayed.
-func Fetch(u string) (*gemini.Response, error) {
+func fetch(u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host)
var res *gemini.Response
var err error
if cert != nil {
- res, err = gemini.FetchWithCert(u, cert, key)
+ res, err = c.FetchWithCert(u, cert, key)
} else {
- res, err = gemini.Fetch(u)
+ res, err = c.Fetch(u)
}
if err != nil {
return nil, err
@@ -78,17 +101,22 @@ func Fetch(u string) (*gemini.Response, error) {
return res, err
}
-// FetchWithProxy is the same as Fetch, but uses a proxy.
-func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) {
+// Fetch returns response data and an error.
+// The error text is human friendly and should be displayed.
+func Fetch(u string) (*gemini.Response, error) {
+ return fetch(u, fetchClient)
+}
+
+func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host)
var res *gemini.Response
var err error
if cert != nil {
- res, err = gemini.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key)
+ res, err = c.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key)
} else {
- res, err = gemini.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u)
+ res, err = c.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u)
}
if err != nil {
return nil, err
@@ -102,3 +130,8 @@ func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error
return res, nil
}
+
+// FetchWithProxy is the same as Fetch, but uses a proxy.
+func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) {
+ return fetchWithProxy(proxyHostname, proxyPort, u, fetchClient)
+}
diff --git a/client/tofu.go b/client/tofu.go
index 7af273e..479be52 100644
--- a/client/tofu.go
+++ b/client/tofu.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
+ "sync"
"time"
"github.com/makeworld-the-better-one/amfora/config"
@@ -21,6 +22,12 @@ var ErrTofu = errors.New("server cert does not match TOFU database")
var tofuStore = config.TofuStore
+// tofuStoreMu protects tofuStore, since viper is not thread-safe.
+// See this issue for details: https://github.com/spf13/viper/issues/268
+// This is needed because Gemini requests may happen concurrently and
+// call on the funcs on this file.
+var tofuStoreMu = sync.RWMutex{}
+
// idKey returns the config/viper key needed to retrieve
// a cert's ID / fingerprint.
func idKey(domain string, port string) string {
@@ -38,6 +45,9 @@ func expiryKey(domain string, port string) string {
}
func loadTofuEntry(domain string, port string) (string, time.Time, error) {
+ tofuStoreMu.RLock()
+ defer tofuStoreMu.RUnlock()
+
id := tofuStore.GetString(idKey(domain, port)) // Fingerprint
if len(id) != sha256.Size*2 {
// Not set, or invalid
@@ -68,6 +78,9 @@ func origCertID(cert *x509.Certificate) string {
}
func saveTofuEntry(domain, port string, cert *x509.Certificate) {
+ tofuStoreMu.Lock()
+ defer tofuStoreMu.Unlock()
+
tofuStore.Set(idKey(domain, port), certID(cert))
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
tofuStore.WriteConfig() //nolint:errcheck // Not an issue if it's not saved, only cached data
@@ -90,9 +103,10 @@ func handleTofu(domain, port string, cert *x509.Certificate) bool {
// Same cert as the one stored
// Store expiry again in case it changed
+ tofuStoreMu.Lock()
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
tofuStore.WriteConfig() //nolint:errcheck
-
+ tofuStoreMu.Unlock()
return true
}
if origCertID(cert) == id {
@@ -117,5 +131,8 @@ func ResetTofuEntry(domain, port string, cert *x509.Certificate) {
// GetExpiry returns the stored expiry date for the given host.
// The time will be empty (zero) if there is not expiry date stored for that host.
func GetExpiry(domain, port string) time.Time {
+ tofuStoreMu.RLock()
+ defer tofuStoreMu.RUnlock()
+
return tofuStore.GetTime(expiryKey(domain, port))
}
diff --git a/config/config.go b/config/config.go
index 9da4c55..55e3126 100644
--- a/config/config.go
+++ b/config/config.go
@@ -38,10 +38,23 @@ var bkmkDir string
var bkmkPath string
var DownloadsDir string
+var TempDownloadsDir string
+
+// Subscriptions
+var subscriptionDir string
+var SubscriptionPath string
// Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config.
var HTTPCommand []string
+type MediaHandler struct {
+ Cmd []string
+ NoPrompt bool
+ Stream bool
+}
+
+var MediaHandlers = make(map[string]MediaHandler)
+
func Init() error {
// *** Set paths ***
@@ -96,6 +109,22 @@ func Init() error {
}
bkmkPath = filepath.Join(bkmkDir, "bookmarks.toml")
+ // Feeds dir and path
+ if runtime.GOOS == "windows" {
+ // In APPDATA beside other Amfora files
+ subscriptionDir = amforaAppData
+ } else {
+ // XDG data dir on POSIX systems
+ xdg_data, ok := os.LookupEnv("XDG_DATA_HOME")
+ if ok && strings.TrimSpace(xdg_data) != "" {
+ subscriptionDir = filepath.Join(xdg_data, "amfora")
+ } else {
+ // Default to ~/.local/share/amfora
+ subscriptionDir = filepath.Join(home, ".local", "share", "amfora")
+ }
+ }
+ SubscriptionPath = filepath.Join(subscriptionDir, "subscriptions.json")
+
// *** Create necessary files and folders ***
// Config
@@ -131,13 +160,114 @@ func Init() error {
if err == nil {
f.Close()
}
+ // Feeds
+ err = os.MkdirAll(subscriptionDir, 0755)
+ if err != nil {
+ return err
+ }
+
+ // *** Setup vipers ***
+
+ TofuStore.SetConfigFile(tofuDBPath)
+ TofuStore.SetConfigType("toml")
+ err = TofuStore.ReadInConfig()
+ if err != nil {
+ return err
+ }
+
+ BkmkStore.SetConfigFile(bkmkPath)
+ BkmkStore.SetConfigType("toml")
+ err = BkmkStore.ReadInConfig()
+ if err != nil {
+ return err
+ }
+ BkmkStore.Set("DO NOT TOUCH", true)
+ err = BkmkStore.WriteConfig()
+ if err != nil {
+ return err
+ }
+
+ // Setup main config
+
+ viper.SetDefault("a-general.home", "gemini://gemini.circumlunar.space")
+ viper.SetDefault("a-general.auto_redirect", false)
+ viper.SetDefault("a-general.http", "default")
+ viper.SetDefault("a-general.search", "gemini://gus.guru/search")
+ viper.SetDefault("a-general.color", true)
+ viper.SetDefault("a-general.ansi", true)
+ viper.SetDefault("a-general.bullets", true)
+ viper.SetDefault("a-general.show_link", false)
+ viper.SetDefault("a-general.left_margin", 0.15)
+ viper.SetDefault("a-general.max_width", 100)
+ viper.SetDefault("a-general.downloads", "")
+ viper.SetDefault("a-general.temp_downloads", "")
+ viper.SetDefault("a-general.page_max_size", 2097152)
+ viper.SetDefault("a-general.page_max_time", 10)
+ viper.SetDefault("a-general.emoji_favicons", false)
+ viper.SetDefault("keybindings.bind_reload", []string{"R", "Ctrl-R"})
+ viper.SetDefault("keybindings.bind_home", "Backspace")
+ viper.SetDefault("keybindings.bind_bookmarks", "Ctrl-B")
+ viper.SetDefault("keybindings.bind_add_bookmark", "Ctrl-D")
+ viper.SetDefault("keybindings.bind_sub", "Ctrl-A")
+ viper.SetDefault("keybindings.bind_add_sub", "Ctrl-X")
+ viper.SetDefault("keybindings.bind_save", "Ctrl-S")
+ viper.SetDefault("keybindings.bind_pgup", []string{"PgUp", "u"})
+ viper.SetDefault("keybindings.bind_pgdn", []string{"PgDn", "d"})
+ viper.SetDefault("keybindings.bind_bottom", "Space")
+ viper.SetDefault("keybindings.bind_edit", "e")
+ viper.SetDefault("keybindings.bind_back", []string{"b", "Alt-Left"})
+ viper.SetDefault("keybindings.bind_forward", []string{"f", "Alt-Right"})
+ viper.SetDefault("keybindings.bind_new_tab", "Ctrl-T")
+ viper.SetDefault("keybindings.bind_close_tab", "Ctrl-W")
+ viper.SetDefault("keybindings.bind_next_tab", "F2")
+ viper.SetDefault("keybindings.bind_prev_tab", "F1")
+ viper.SetDefault("keybindings.bind_quit", []string{"Ctrl-C", "Ctrl-Q", "q"})
+ viper.SetDefault("keybindings.bind_help", "?")
+ viper.SetDefault("keybindings.bind_link1", "1")
+ viper.SetDefault("keybindings.bind_link2", "2")
+ viper.SetDefault("keybindings.bind_link3", "3")
+ viper.SetDefault("keybindings.bind_link4", "4")
+ viper.SetDefault("keybindings.bind_link5", "5")
+ viper.SetDefault("keybindings.bind_link6", "6")
+ viper.SetDefault("keybindings.bind_link7", "7")
+ viper.SetDefault("keybindings.bind_link8", "8")
+ viper.SetDefault("keybindings.bind_link9", "9")
+ viper.SetDefault("keybindings.bind_link0", "0")
+ viper.SetDefault("keybindings.bind_tab1", "!")
+ viper.SetDefault("keybindings.bind_tab2", "@")
+ viper.SetDefault("keybindings.bind_tab3", "#")
+ viper.SetDefault("keybindings.bind_tab4", "$")
+ viper.SetDefault("keybindings.bind_tab5", "%")
+ viper.SetDefault("keybindings.bind_tab6", "^")
+ viper.SetDefault("keybindings.bind_tab7", "&")
+ viper.SetDefault("keybindings.bind_tab8", "*")
+ viper.SetDefault("keybindings.bind_tab9", "(")
+ viper.SetDefault("keybindings.bind_tab0", ")")
+ viper.SetDefault("keybindings.shift_numbers", "")
+ viper.SetDefault("url-handlers.other", "off")
+ viper.SetDefault("cache.max_size", 0)
+ viper.SetDefault("cache.max_pages", 20)
+ viper.SetDefault("cache.timeout", 1800)
+ viper.SetDefault("subscriptions.popup", true)
+ viper.SetDefault("subscriptions.update_interval", 1800)
+ viper.SetDefault("subscriptions.workers", 3)
+ viper.SetDefault("subscriptions.entries_per_page", 20)
+
+ viper.SetConfigFile(configPath)
+ viper.SetConfigType("toml")
+ err = viper.ReadInConfig()
+ if err != nil {
+ return err
+ }
+
+ // Setup the key bindings
+ KeyInit()
// *** Downloads paths, setup, and creation ***
// Setup downloads dir
if viper.GetString("a-general.downloads") == "" {
// Find default Downloads dir
- // This seems to work for all OSes?
if userdirs.Download == "" {
DownloadsDir = filepath.Join(home, "Downloads")
} else {
@@ -169,57 +299,40 @@ func Init() error {
DownloadsDir = dDir
}
- // *** Setup vipers ***
+ // Setup temporary downloads dir
+ if viper.GetString("a-general.temp_downloads") == "" {
+ TempDownloadsDir = filepath.Join(os.TempDir(), "amfora_temp")
- TofuStore.SetConfigFile(tofuDBPath)
- TofuStore.SetConfigType("toml")
- err = TofuStore.ReadInConfig()
- if err != nil {
- return err
- }
-
- BkmkStore.SetConfigFile(bkmkPath)
- BkmkStore.SetConfigType("toml")
- err = BkmkStore.ReadInConfig()
- if err != nil {
- return err
- }
- BkmkStore.Set("DO NOT TOUCH", true)
- err = BkmkStore.WriteConfig()
- if err != nil {
- return err
- }
-
- // Setup main config
-
- viper.SetDefault("a-general.home", "gemini.circumlunar.space")
- viper.SetDefault("a-general.auto_redirect", false)
- viper.SetDefault("a-general.http", "default")
- viper.SetDefault("a-general.search", "gus.guru/search")
- viper.SetDefault("a-general.color", true)
- viper.SetDefault("a-general.ansi", true)
- viper.SetDefault("a-general.bullets", true)
- viper.SetDefault("a-general.left_margin", 0.15)
- viper.SetDefault("a-general.max_width", 100)
- viper.SetDefault("a-general.downloads", "")
- viper.SetDefault("a-general.page_max_size", 2097152)
- viper.SetDefault("a-general.page_max_time", 10)
- viper.SetDefault("a-general.emoji_favicons", false)
- viper.SetDefault("keybindings.shift_numbers", "!@#$%^&*()")
- viper.SetDefault("url-handlers.other", "off")
- 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
+ // Make sure it exists
+ err = os.MkdirAll(TempDownloadsDir, 0755)
+ if err != nil {
+ return fmt.Errorf("temp downloads path could not be created: %s", TempDownloadsDir)
+ }
+ } else {
+ // Validate path
+ dDir := viper.GetString("a-general.temp_downloads")
+ di, err := os.Stat(dDir)
+ if err == nil {
+ if !di.IsDir() {
+ return fmt.Errorf("temp downloads path specified is not a directory: %s", dDir)
+ }
+ } else if os.IsNotExist(err) {
+ // Try to create path
+ err = os.MkdirAll(dDir, 0755)
+ if err != nil {
+ return fmt.Errorf("temp downloads path could not be created: %s", dDir)
+ }
+ } else {
+ // Some other error
+ return fmt.Errorf("couldn't access temp downloads directory: %s", dDir)
+ }
+ TempDownloadsDir = dDir
}
// Setup cache from config
cache.SetMaxSize(viper.GetInt("cache.max_size"))
cache.SetMaxPages(viper.GetInt("cache.max_pages"))
+ cache.SetTimeout(viper.GetInt("cache.timeout"))
// Setup theme
configTheme := viper.Sub("theme")
@@ -249,5 +362,35 @@ func Init() error {
HTTPCommand = strings.Fields(viper.GetString("a-general.http"))
}
+ var rawMediaHandlers []struct {
+ Cmd []string `mapstructure:"cmd"`
+ Types []string `mapstructure:"types"`
+ NoPrompt bool `mapstructure:"no_prompt"`
+ Stream bool `mapstructure:"stream"`
+ }
+ err = viper.UnmarshalKey("mediatype-handlers", &rawMediaHandlers)
+ if err != nil {
+ return fmt.Errorf("couldn't parse mediatype-handlers section in config: %w", err)
+ }
+ for _, rawMediaHandler := range rawMediaHandlers {
+ if len(rawMediaHandler.Cmd) == 0 {
+ return fmt.Errorf("empty cmd array in mediatype-handlers section")
+ }
+ if len(rawMediaHandler.Types) == 0 {
+ return fmt.Errorf("empty types array in mediatype-handlers section")
+ }
+
+ for _, typ := range rawMediaHandler.Types {
+ if _, ok := MediaHandlers[typ]; ok {
+ return fmt.Errorf("multiple mediatype-handlers defined for %v", typ)
+ }
+ MediaHandlers[typ] = MediaHandler{
+ Cmd: rawMediaHandler.Cmd,
+ NoPrompt: rawMediaHandler.NoPrompt,
+ Stream: rawMediaHandler.Stream,
+ }
+ }
+ }
+
return nil
}
diff --git a/config/default.go b/config/default.go
index 6329539..1dc3729 100644
--- a/config/default.go
+++ b/config/default.go
@@ -28,14 +28,15 @@ auto_redirect = false
#
# The best to define a command is using a string array.
# Examples:
-# http = ["firefox"]
-# http = ["custom-browser", "--flag", "--option=2"]
-# http = ["/path/with spaces/in it/firefox"]
+# http = ['firefox']
+# http = ['custom-browser', '--flag', '--option=2']
+# http = ['/path/with spaces/in it/firefox']
#
-# Using just a string will also work, but it is deprecated,
-# and will degrade if you use paths with spaces.
+# Note the use of single quotes, so that backslashes will not be escaped.
+# Using just a string will also work, but it is deprecated, and will degrade if
+# you use paths with spaces.
-http = "default"
+http = 'default'
# Any URL that will accept a query string can be put here
search = "gemini://gus.guru/search"
@@ -49,6 +50,9 @@ ansi = true
# Whether to replace list asterisks with unicode bullets
bullets = true
+# Whether to show link after link text
+show_link = false
+
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
left_margin = 0.15
@@ -58,7 +62,8 @@ max_width = 100
# 'downloads' is the path to a downloads folder.
# An empty value means the code will find the default downloads folder for your system.
# If the path does not exist it will be created.
-downloads = ""
+# Note the use of single quotes, so that backslashes will not be escaped.
+downloads = ''
# Max size for displayable content in bytes - after that size a download window pops up
page_max_size = 2097152 # 2 MiB
@@ -71,51 +76,163 @@ emoji_favicons = false
[auth]
# Authentication settings
+# Note the use of single quotes for values, so that backslashes will not be escaped.
[auth.certs]
# Client certificates
# Set domain name equal to path to client cert
-# "example.com" = "mycert.crt"
+# "example.com" = 'mycert.crt'
[auth.keys]
# Client certificate keys
# Set domain name equal to path to key for the client cert above
-# "example.com" = "mycert.key"
+# "example.com" = 'mycert.key'
[keybindings]
-# In the future there will be more settings here.
+# If you have a non-US keyboard, use bind_tab1 through bind_tab0 to
+# setup the shift-number bindings: Eg, for US keyboards (the default):
+# bind_tab1 = "!"
+# bind_tab2 = "@"
+# bind_tab3 = "#"
+# bind_tab4 = "$"
+# bind_tab5 = "%"
+# bind_tab6 = "^"
+# bind_tab7 = "&"
+# bind_tab8 = "*"
+# bind_tab9 = "("
+# bind_tab0 = ")"
-# Hold down shift and press the numbers on your keyboard (1,2,3,4,5,6,7,8,9,0) to set this up.
-# It is default set to be accurate for US keyboards.
-shift_numbers = "!@#$%^&*()"
+# Whitespace is not allowed in any of the keybindings! Use 'Space' and 'Tab' to bind to those keys.
+# Multiple keys can be bound to one command, just use a TOML array.
+# To add the Alt modifier, the binding must start with Alt-, should be reasonably universal
+# Ctrl- won't work on all keys, see this for a list:
+# https://github.com/gdamore/tcell/blob/cb1e5d6fa606/key.go#L83
+# An example of a TOML array for multiple keys being bound to one command is the default
+# binding for reload:
+# bind_reload = ["R","Ctrl-R"]
+# One thing to note here is that "R" is capitalization sensitive, so it means shift-r.
+# "Ctrl-R" means both ctrl-r and ctrl-shift-R (this is a quirk of what ctrl-r means on
+# an ANSI terminal)
+
+# The default binding for opening the bottom bar for entering a URL or link number is:
+# bind_bottom = "Space"
+# This is how to get the Spacebar as a keybinding, if you try to use " ", it won't work.
+# And, finally, an example of a simple, unmodified character is:
+# bind_edit = "e"
+# This binds the "e" key to the command to edit the current URL.
+
+# The bind_link[1-90] options are for the commands to go to the first 10 links on a page,
+# typically these are bound to the number keys:
+# bind_link1 = "1"
+# bind_link2 = "2"
+# bind_link3 = "3"
+# bind_link4 = "4"
+# bind_link5 = "5"
+# bind_link6 = "6"
+# bind_link7 = "7"
+# bind_link8 = "8"
+# bind_link9 = "9"
+# bind_link0 = "0"
[url-handlers]
# Allows setting the commands to run for various URL schemes.
# E.g. to open FTP URLs with FileZilla set the following key:
-# ftp = "filezilla"
+# ftp = 'filezilla'
# You can set any scheme to "off" or "" to disable handling it, or
# just leave the key unset.
#
# DO NOT use this for setting the HTTP command.
# Use the http setting in the "a-general" section above.
#
-# NOTE: These settings are override by the ones in the proxies section.
+# NOTE: These settings are overrided by the ones in the proxies section.
+# Note the use of single quotes, so that backslashes will not be escaped.
# This is a special key that defines the handler for all URL schemes for which
# no handler is defined.
-other = "off"
+other = 'off'
+
+
+# [[mediatype-handlers]] section
+# ---------------------------------
+#
+# Specify what applications will open certain media types.
+# By default your default application will be used to open the file when you select "Open".
+# You only need to configure this section if you want to override your default application,
+# or do special things like streaming.
+#
+# Note the use of single quotes for commands, so that backslashes will not be escaped.
+#
+#
+# To open jpeg files with the feh command:
+#
+# [[mediatype-handlers]]
+# cmd = ['feh']
+# types = ["image/jpeg"]
+#
+# Each command that you specify must come under its own [[mediatype-handlers]]. You may
+# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
+#
+# If the subtype is omitted then the specified command will be used for the
+# entire type:
+#
+# [[mediatype-handlers]]
+# command = ['vlc', '--flag']
+# types = ["audio", "video"]
+#
+# A catch-all handler can by specified with "*".
+# Note that there are already catch-all handlers in place for all OSes,
+# that open the file using your default application. This is only if you
+# want to override that.
+#
+# [[mediatype-handlers]]
+# cmd = ['some-command']
+# types = [
+# "application/pdf",
+# "*",
+# ]
+#
+# You can also choose to stream the data instead of downloading it all before
+# opening it. This is especially useful for large video or audio files, as
+# well as radio streams, which will never complete. You can do this like so:
+#
+# [[mediatype-handlers]]
+# cmd = ['vlc', '-']
+# types = ["audio", "video"]
+# stream = true
+#
+# This uses vlc to stream all video and audio content.
+# By default stream is set to off for all handlers
+#
+#
+# If you want to always open a type in its viewer without the download or open
+# prompt appearing, you can add no_prompt = true
+#
+# [[mediatype-handlers]]
+# cmd = ['feh']
+# types = ["image"]
+# no_prompt = true
+#
+# Note: Multiple handlers cannot be defined for the same full media type, but
+# still there needs to be an order for which handlers are used. The following
+# order applies regardless of the order written in the config:
+#
+# 1. Full media type: "image/jpeg"
+# 2. Just type: "image"
+# 3. Catch-all: "*"
[cache]
-# Options for page cache - which is only for text/gemini pages
+# Options for page cache - which is only for text pages
# Increase the cache size to speed up browsing at the expense of memory
-
# Zero values mean there is no limit
+
max_size = 0 # Size in bytes
max_pages = 30 # The maximum number of pages the cache will store
+# How long a page will stay in cache, in seconds.
+timeout = 1800 # 30 mins
[proxies]
# Allows setting a Gemini proxy for different schemes.
@@ -131,6 +248,28 @@ max_pages = 30 # The maximum number of pages the cache will store
# Note that HTTP and HTTPS are treated as separate protocols here.
+[subscriptions]
+# For tracking feeds and pages
+
+# Whether a pop-up appears when viewing a potential feed
+popup = true
+
+# How often to check for updates to subscriptions in the background, in seconds.
+# Set it to 0 to disable this feature. You can still update individual feeds
+# manually, or restart the browser.
+#
+# Note Amfora will check for updates on browser start no matter what this setting is.
+update_interval = 1800 # 30 mins
+
+# How many subscriptions can be checked at the same time when updating.
+# If you have many subscriptions you may want to increase this for faster
+# update times. Any value below 1 will be corrected to 1.
+workers = 3
+
+# The number of subscription updates displayed per page.
+entries_per_page = 20
+
+
[theme]
# This section is for changing the COLORS used in Amfora.
# These colors only apply if 'color' is enabled above.
@@ -189,6 +328,8 @@ max_pages = 30 # The maximum number of pages the cache will store
# yesno_modal_text
# tofu_modal_bg
# tofu_modal_text
+# subscription_modal_bg
+# subscription_modal_text
# input_modal_bg
# input_modal_text
diff --git a/config/default.sh b/config/default.sh
index 3de772f..b7f96bb 100755
--- a/config/default.sh
+++ b/config/default.sh
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
-head -n 3 default.go | tee default.go > /dev/null
+cat > default.go <<-EOF
+package config
+
+//go:generate ./default.sh
+EOF
echo -n 'var defaultConf = []byte(`' >> default.go
cat ../default-config.toml >> default.go
-echo '`)' >> default.go
\ No newline at end of file
+echo '`)' >> default.go
diff --git a/config/keybindings.go b/config/keybindings.go
index e3cd06e..8afd2e3 100644
--- a/config/keybindings.go
+++ b/config/keybindings.go
@@ -1,24 +1,237 @@
package config
import (
- "errors"
+ "strings"
+ "github.com/gdamore/tcell"
"github.com/spf13/viper"
)
-// KeyToNum returns the number on the user's keyboard they pressed,
-// using the rune returned when when they press Shift+Num.
-// The error is not nil if the provided key is invalid.
-func KeyToNum(key rune) (int, error) {
- runes := []rune(viper.GetString("keybindings.shift_numbers"))
- for i := range runes {
- if key == runes[i] {
- if i == len(runes)-1 {
- // Last key is 0, not 10
- return 0, nil
+// NOTE: CmdLink[1-90] and CmdTab[1-90] need to be in-order and consecutive
+// This property is used to simplify key handling in display/display.go
+type Command int
+
+const (
+ CmdInvalid Command = 0
+ CmdLink1 = 1
+ CmdLink2 = 2
+ CmdLink3 = 3
+ CmdLink4 = 4
+ CmdLink5 = 5
+ CmdLink6 = 6
+ CmdLink7 = 7
+ CmdLink8 = 8
+ CmdLink9 = 9
+ CmdLink0 = 10
+ CmdTab1 = 11
+ CmdTab2 = 12
+ CmdTab3 = 13
+ CmdTab4 = 14
+ CmdTab5 = 15
+ CmdTab6 = 16
+ CmdTab7 = 17
+ CmdTab8 = 18
+ CmdTab9 = 19
+ CmdTab0 = 20
+ CmdBottom = iota
+ CmdEdit
+ CmdHome
+ CmdBookmarks
+ CmdAddBookmark
+ CmdSave
+ CmdReload
+ CmdBack
+ CmdForward
+ CmdPgup
+ CmdPgdn
+ CmdNewTab
+ CmdCloseTab
+ CmdNextTab
+ CmdPrevTab
+ CmdQuit
+ CmdHelp
+ CmdSub
+ CmdAddSub
+)
+
+type keyBinding struct {
+ key tcell.Key
+ mod tcell.ModMask
+ r rune
+}
+
+// Map of active keybindings to commands.
+var bindings map[keyBinding]Command
+
+// inversion of tcell.KeyNames, used to simplify config parsing.
+// used by parseBinding() below.
+var tcellKeys map[string]tcell.Key
+
+// helper function that takes a single keyBinding object and returns
+// a string in the format used by the configuration file. Support
+// function for GetKeyBinding(), used to make the help panel helpful.
+func keyBindingToString(kb keyBinding) (string, bool) {
+ var prefix string = ""
+
+ if kb.mod&tcell.ModAlt == tcell.ModAlt {
+ prefix = "Alt-"
+ }
+
+ if kb.key == tcell.KeyRune {
+ if kb.r == ' ' {
+ return prefix + "Space", true
+ }
+ return prefix + string(kb.r), true
+ }
+ s, ok := tcell.KeyNames[kb.key]
+ if ok {
+ return prefix + s, true
+ }
+ return "", false
+}
+
+// Get all keybindings for a Command as a string.
+// Used by the help panel so bindable keys display with their
+// bound values rather than hardcoded defaults.
+func GetKeyBinding(cmd Command) string {
+ var s string = ""
+ for kb, c := range bindings {
+ if c == cmd {
+ t, ok := keyBindingToString(kb)
+ if ok {
+ s += t + ", "
}
- return i + 1, nil
}
}
- return -1, errors.New("provided key is invalid") //nolint:goerr113
+
+ if len(s) > 0 {
+ return s[:len(s)-2]
+ }
+ return s
+}
+
+// Parse a single keybinding string and add it to the binding map
+func parseBinding(cmd Command, binding string) {
+ var k tcell.Key
+ var m tcell.ModMask = 0
+ var r rune = 0
+
+ if strings.HasPrefix(binding, "Alt-") {
+ m = tcell.ModAlt
+ binding = binding[4:]
+ }
+
+ if len(binding) == 1 {
+ k = tcell.KeyRune
+ r = []rune(binding)[0]
+ } else if len(binding) == 0 {
+ return
+ } else if binding == "Space" {
+ k = tcell.KeyRune
+ r = ' '
+ } else {
+ var ok bool
+ k, ok = tcellKeys[binding]
+ if !ok { // Bad keybinding! Quietly ignore...
+ return
+ }
+ if strings.HasPrefix(binding, "Ctrl") {
+ m += tcell.ModCtrl
+ }
+ }
+
+ bindings[keyBinding{k, m, r}] = cmd
+}
+
+// Generate the bindings map from the TOML configuration file.
+// Called by config.Init()
+func KeyInit() {
+ configBindings := map[Command]string{
+ CmdLink1: "keybindings.bind_link1",
+ CmdLink2: "keybindings.bind_link2",
+ CmdLink3: "keybindings.bind_link3",
+ CmdLink4: "keybindings.bind_link4",
+ CmdLink5: "keybindings.bind_link5",
+ CmdLink6: "keybindings.bind_link6",
+ CmdLink7: "keybindings.bind_link7",
+ CmdLink8: "keybindings.bind_link8",
+ CmdLink9: "keybindings.bind_link9",
+ CmdLink0: "keybindings.bind_link0",
+ CmdBottom: "keybindings.bind_bottom",
+ CmdEdit: "keybindings.bind_edit",
+ CmdHome: "keybindings.bind_home",
+ CmdBookmarks: "keybindings.bind_bookmarks",
+ CmdAddBookmark: "keybindings.bind_add_bookmark",
+ CmdSave: "keybindings.bind_save",
+ CmdReload: "keybindings.bind_reload",
+ CmdBack: "keybindings.bind_back",
+ CmdForward: "keybindings.bind_forward",
+ CmdPgup: "keybindings.bind_pgup",
+ CmdPgdn: "keybindings.bind_pgdn",
+ CmdNewTab: "keybindings.bind_new_tab",
+ CmdCloseTab: "keybindings.bind_close_tab",
+ CmdNextTab: "keybindings.bind_next_tab",
+ CmdPrevTab: "keybindings.bind_prev_tab",
+ CmdQuit: "keybindings.bind_quit",
+ CmdHelp: "keybindings.bind_help",
+ CmdSub: "keybindings.bind_sub",
+ CmdAddSub: "keybindings.bind_add_sub",
+ }
+ // This is split off to allow shift_numbers to override bind_tab[1-90]
+ // (This is needed for older configs so that the default bind_tab values
+ // aren't used)
+ configTabNBindings := map[Command]string{
+ CmdTab1: "keybindings.bind_tab1",
+ CmdTab2: "keybindings.bind_tab2",
+ CmdTab3: "keybindings.bind_tab3",
+ CmdTab4: "keybindings.bind_tab4",
+ CmdTab5: "keybindings.bind_tab5",
+ CmdTab6: "keybindings.bind_tab6",
+ CmdTab7: "keybindings.bind_tab7",
+ CmdTab8: "keybindings.bind_tab8",
+ CmdTab9: "keybindings.bind_tab9",
+ CmdTab0: "keybindings.bind_tab0",
+ }
+ tcellKeys = make(map[string]tcell.Key)
+ bindings = make(map[keyBinding]Command)
+
+ for k, kname := range tcell.KeyNames {
+ tcellKeys[kname] = k
+ }
+
+ for c, allb := range configBindings {
+ for _, b := range viper.GetStringSlice(allb) {
+ parseBinding(c, b)
+ }
+ }
+
+ // Backwards compatibility with the old shift_numbers config line.
+ shiftNumbers := []rune(viper.GetString("keybindings.shift_numbers"))
+ if len(shiftNumbers) > 0 && len(shiftNumbers) <= 10 {
+ for i, r := range shiftNumbers {
+ bindings[keyBinding{tcell.KeyRune, 0, r}] = CmdTab1 + Command(i)
+ }
+ } else {
+ for c, allb := range configTabNBindings {
+ for _, b := range viper.GetStringSlice(allb) {
+ parseBinding(c, b)
+ }
+ }
+ }
+}
+
+// Used by the display package to turn a tcell.EventKey into a Command
+func TranslateKeyEvent(e *tcell.EventKey) Command {
+ var ok bool
+ var cmd Command
+ k := e.Key()
+ if k == tcell.KeyRune {
+ cmd, ok = bindings[keyBinding{k, e.Modifiers(), e.Rune()}]
+ } else { // Sometimes tcell sets e.Rune() on non-KeyRune events.
+ cmd, ok = bindings[keyBinding{k, e.Modifiers(), 0}]
+ }
+ if ok {
+ return cmd
+ }
+ return CmdInvalid
}
diff --git a/config/theme.go b/config/theme.go
index 436c7c8..13f5cd2 100644
--- a/config/theme.go
+++ b/config/theme.go
@@ -8,7 +8,7 @@ import (
)
// Functions to allow themeing configuration.
-// UI element colors are mapped to a string key, such as "error" or "tab_background"
+// UI element colors are mapped to a string key, such as "error" or "tab_bg"
// These are the same keys used in the config file.
var themeMu = sync.RWMutex{}
@@ -26,18 +26,20 @@ var theme = map[string]tcell.Color{
"btn_bg": tcell.ColorNavy, // All modal buttons
"btn_text": tcell.ColorWhite,
- "dl_choice_modal_bg": tcell.ColorPurple,
- "dl_choice_modal_text": tcell.ColorWhite,
- "dl_modal_bg": tcell.Color130, // xterm:DarkOrange3, #af5f00
- "dl_modal_text": tcell.ColorWhite,
- "info_modal_bg": tcell.ColorGray,
- "info_modal_text": tcell.ColorWhite,
- "error_modal_bg": tcell.ColorMaroon,
- "error_modal_text": tcell.ColorWhite,
- "yesno_modal_bg": tcell.ColorPurple,
- "yesno_modal_text": tcell.ColorWhite,
- "tofu_modal_bg": tcell.ColorMaroon,
- "tofu_modal_text": tcell.ColorWhite,
+ "dl_choice_modal_bg": tcell.ColorPurple,
+ "dl_choice_modal_text": tcell.ColorWhite,
+ "dl_modal_bg": tcell.Color130, // xterm:DarkOrange3, #af5f00
+ "dl_modal_text": tcell.ColorWhite,
+ "info_modal_bg": tcell.ColorGray,
+ "info_modal_text": tcell.ColorWhite,
+ "error_modal_bg": tcell.ColorMaroon,
+ "error_modal_text": tcell.ColorWhite,
+ "yesno_modal_bg": tcell.ColorPurple,
+ "yesno_modal_text": tcell.ColorWhite,
+ "tofu_modal_bg": tcell.ColorMaroon,
+ "tofu_modal_text": tcell.ColorWhite,
+ "subscription_modal_bg": tcell.Color61, // xterm:SlateBlue3, #5f5faf
+ "subscription_modal_text": tcell.ColorWhite,
"input_modal_bg": tcell.ColorGreen,
"input_modal_text": tcell.ColorWhite,
@@ -58,14 +60,14 @@ var theme = map[string]tcell.Color{
"link_number": tcell.ColorSilver,
"regular_text": tcell.ColorWhite,
"quote_text": tcell.ColorWhite,
- "preformatted_text": tcell.ColorWhite,
+ "preformatted_text": tcell.Color229, // xterm:Wheat1, #ffffaf
"list_text": tcell.ColorWhite,
}
func SetColor(key string, color tcell.Color) {
themeMu.Lock()
- defer themeMu.Unlock()
theme[key] = color
+ themeMu.Unlock()
}
// GetColor will return tcell.ColorBlack if there is no color for the provided key.
diff --git a/contrib/gemini-wiki/README.md b/contrib/gemini-wiki/README.md
new file mode 100644
index 0000000..78f868f
--- /dev/null
+++ b/contrib/gemini-wiki/README.md
@@ -0,0 +1,8 @@
+# gemini-wiki
+
+This folder contains a Python script that downloads the Amfora [wiki](https://github.com/makeworld-the-better-one/amfora/wiki)
+and converts it to gemtext, incorporating the sidebar and footer as well.
+
+The script expects to be run inside the folder where the Gemini version of the wiki should be.
+
+The output of this script can be viewed at `gemini://makeworld.gq/amfora-wiki/`.
\ No newline at end of file
diff --git a/contrib/gemini-wiki/main.py b/contrib/gemini-wiki/main.py
new file mode 100644
index 0000000..d6d0616
--- /dev/null
+++ b/contrib/gemini-wiki/main.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+# Formatted with black.
+
+import shutil
+import subprocess
+import sys
+import os
+import md2gemini
+
+TMP_WIKI_CLONE = "/tmp/amfora.wiki"
+
+
+def md2gem(markdown):
+ return md2gemini.md2gemini(
+ markdown,
+ links="copy",
+ plain=False,
+ strip_html=True,
+ md_links=True,
+ link_func=link_func,
+ )
+
+
+def link_func(link):
+ if "://" in link:
+ # Absolute URL
+ return link
+
+ # Link to other wiki page
+ return link + ".gmi"
+
+
+def run_cmd(*args):
+ proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ if proc.returncode != 0:
+ print(
+ "Command "
+ + " ".join(args)
+ + "failed with exit code "
+ + str(proc.returncode)
+ )
+ print("Output was:")
+ print()
+ print(proc.stdout.decode())
+ sys.exit(1)
+
+
+# Delete leftover git repo
+try:
+ shutil.rmtree(TMP_WIKI_CLONE)
+except FileNotFoundError:
+ pass
+
+os.mkdir(TMP_WIKI_CLONE)
+
+run_cmd(
+ "git",
+ "clone",
+ "--depth",
+ "1",
+ "https://github.com/makeworld-the-better-one/amfora.wiki.git",
+ TMP_WIKI_CLONE,
+)
+
+# Save special files
+
+with open(os.path.join(TMP_WIKI_CLONE, "_Footer.md"), "r") as f:
+ footer = md2gem(f.read())
+
+# Get files
+(_, _, files) = next(os.walk(TMP_WIKI_CLONE))
+
+# Create list of pages
+pages = "## Pages\n\n=>.. Home\n"
+for file in files:
+
+ if file in ["_Footer.md", "_Sidebar.md", "Home.md"]:
+ continue
+ if not file.endswith(".md"):
+ continue
+ pages += "=>" + file[:-2] + "gmi " + file[:-3].replace("-", " ") + "\n"
+
+pages += "\n\n"
+
+for file in files:
+ filepath = os.path.join(TMP_WIKI_CLONE, file)
+
+ if file in ["_Footer.md", "_Sidebar.md"]:
+ continue
+ if not file.endswith(".md"):
+ # Could be a resource like an image file, copy it
+ shutil.copyfile(filepath, file)
+ continue
+
+ # Markdown file
+
+ with open(filepath, "r") as f:
+ gemtext = md2gem(f.read())
+
+ # Add title, sidebar, footer
+ gemtext = "# " + file[:-3].replace("-", " ") + "\n\n" + pages + gemtext
+ gemtext += "\n\n\n\n" + footer
+
+ if file == "Home.md":
+ file = "index.md"
+
+ new_name = file[:-2] + "gmi"
+
+ with open(new_name, "w") as f:
+ f.write(gemtext)
\ No newline at end of file
diff --git a/contrib/gemini-wiki/requirements.txt b/contrib/gemini-wiki/requirements.txt
new file mode 100644
index 0000000..996537c
--- /dev/null
+++ b/contrib/gemini-wiki/requirements.txt
@@ -0,0 +1 @@
+md2gemini<2
\ No newline at end of file
diff --git a/contrib/themes/README.md b/contrib/themes/README.md
new file mode 100644
index 0000000..7fa21f9
--- /dev/null
+++ b/contrib/themes/README.md
@@ -0,0 +1,76 @@
+# User Contributed Themes
+
+You can use these themes by replacing the `[theme]` section of your config with their contents. Some themes won't display properly on terminals that do not have truecolor support.
+
+## Nord
+
+Contributed by **[@lokesh-krishna](https://github.com/lokesh-krishna)**.
+
+![screenshot of the nord theme](https://user-images.githubusercontent.com/20235646/102846450-005dc480-4436-11eb-89a9-a1a4350f5415.png)
+
+## Dracula
+
+Contributed by **[@crdpa](https://github.com/crdpa)**.
+
+![screenshot of dracula theme](https://user-images.githubusercontent.com/61637474/99983229-5b928d80-2d8a-11eb-8e5c-e5681bb274c5.png)
+
+
+More screenshots
+
+![screenshot of dracula theme](https://user-images.githubusercontent.com/61637474/99983237-5e8d7e00-2d8a-11eb-8e22-3a3459ae560a.png)
+![screenshot of dracula theme](https://user-images.githubusercontent.com/61637474/99983210-53d2e900-2d8a-11eb-9ab7-12dc10c2933a.png)
+
+
+## Gruvbox
+
+Contributed by **[@Skraylet](https://github.com/Skraylet)**.
+
+![screenshot of gruvbox theme](https://user-images.githubusercontent.com/26380693/100381730-4768bd80-3022-11eb-83ae-bcd0495f2ae9.png)
+
+
+Another screenshot
+
+![screenshot of gruvbox theme](https://user-images.githubusercontent.com/26380693/100381734-4a63ae00-3022-11eb-9531-a635df310052.png)
+
+
+## Solarized
+
+Contributed by **[@bnthor](https://github.com/bnthor)**.
+
+### Dark
+
+![screenshot of solarized dark theme](https://user-images.githubusercontent.com/798657/100597218-77071680-32fd-11eb-8e0d-593ff95b7129.png)
+
+
+Another screenshot
+
+![screenshot of solarized dark theme](https://user-images.githubusercontent.com/798657/100597236-7b333400-32fd-11eb-8844-b92601da52c7.png)
+
+
+### Light
+
+![screenshot of solarized light theme](https://user-images.githubusercontent.com/798657/100597327-9aca5c80-32fd-11eb-8c91-fe3e324d8959.png)
+
+
+Another screenshot
+
+![screenshot of solarized light theme](https://user-images.githubusercontent.com/798657/100597349-a453c480-32fd-11eb-866e-10b0587228f6.png)
+
+
+
+### One Dark
+
+Contributed by **[@sergetymo](https://github.com/sergetymo)**.
+
+![screenshot of one dark theme](https://user-images.githubusercontent.com/65758149/101183151-c8920700-3657-11eb-87f5-7d1d6ae616f2.png)
+
+
+More screenshots
+
+![screenshot of bookmark modal](https://user-images.githubusercontent.com/65758149/101183267-f8410f00-3657-11eb-97fa-10f88a9d8de4.png)
+![screenshot of error modal](https://user-images.githubusercontent.com/65758149/101183206-da73aa00-3657-11eb-8733-5040c8aefb99.png)
+
+
+## Yours?
+
+Contribute your own theme by opening a PR.
diff --git a/contrib/themes/dracula.toml b/contrib/themes/dracula.toml
new file mode 100644
index 0000000..d2073be
--- /dev/null
+++ b/contrib/themes/dracula.toml
@@ -0,0 +1,105 @@
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+
+bg = "#282a36"
+fg = "#f8f8f2"
+tab_num = "#50fa7b"
+tab_divider = "#f8f8f2"
+bottombar_bg = "#282a36"
+bottombar_text = "#f8f8f2"
+bottombar_label = "#9aedfe"
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+
+hdg_1 = "#5af78e"
+hdg_2 = "#9aedfe"
+hdg_3 = "#caa9fa"
+amfora_link = "#f4f99d"
+foreign_link = "#d4d989"
+link_number = "#ff5555"
+regular_text = "#f8f8f2"
+quote_text = "#E6E6E6"
+preformatted_text = "#f8f8f2"
+list_text = "#f8f8f2"
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+
+btn_bg = "#bfbfbf"
+btn_text = "#4d4d4d"
+
+dl_choice_modal_bg = "#282a36"
+dl_choice_modal_text = "#f8f8f2"
+dl_modal_bg = "#282a36"
+dl_modal_text = "#f8f8f2"
+info_modal_bg = "#282a36"
+info_modal_text = "#f8f8f2"
+error_modal_bg = "#282a36"
+error_modal_text = "#ff5555"
+yesno_modal_bg = "#282a36"
+yesno_modal_text = "#f1fa8c"
+tofu_modal_bg = "#282a36"
+tofu_modal_text = "#f8f8f2"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+
+input_modal_bg = "#282a36"
+input_modal_text = "#f8f8f2"
+input_modal_field_bg = "#4d4d4d"
+input_modal_field_text ="#f8f8f2"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+
+bkmk_modal_bg = "#282a36"
+bkmk_modal_text = "#f8f8f2"
+bkmk_modal_label = "#f8f8f2"
+bkmk_modal_field_bg = "#000000"
+bkmk_modal_field_text = "#f8f8f2"
+
+subscription_modal_bg = "#282a36"
+subscription_modal_text = "#f8f8f2"
diff --git a/contrib/themes/gruvbox.toml b/contrib/themes/gruvbox.toml
new file mode 100644
index 0000000..9f150d2
--- /dev/null
+++ b/contrib/themes/gruvbox.toml
@@ -0,0 +1,101 @@
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+bg = "#1d2021"
+fg = "#ebdbb2"
+tab_num = "#928374"
+tab_divider = "#928374"
+bottombar_bg = "#1d2021"
+bottombar_text = "#ebdbb2"
+bottombar_label = "#ebdbb2"
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+hdg_1 = "#b8bb26"
+hdg_2 = "#8ec07c"
+hdg_3 = "#689d6a"
+amfora_link = "#ebdbb2"
+foreign_link = "#bdae93"
+link_number = "#83a598"
+regular_text = "#ebdbb2"
+quote_text = "#928374"
+preformatted_text = "#ebdbb2"
+list_text = "#ebdbb2"
+
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+
+btn_bg = "#3c3836"
+btn_text = "#ebdbb2"
+
+dl_choice_modal_bg = "#3c3836"
+dl_choice_modal_text = "#ebdbb2"
+dl_modal_bg = "#3c3836"
+dl_modal_text = "#ebdbb2"
+info_modal_bg = "#3c3836"
+info_modal_text = "#ebdbb2"
+error_modal_bg = "#3c3836"
+error_modal_text = "#fb4934"
+yesno_modal_bg = "#3c3836"
+yesno_modal_text = "#ebdbb2"
+tofu_modal_bg = "#3c3836"
+tofu_modal_text = "#ebdbb2"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+
+input_modal_bg = "#3c3836"
+input_modal_text = "#ebdbb2"
+input_modal_field_bg = "#1d2021"
+input_modal_field_text = "#ebdbb2"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+
+bkmk_modal_bg = "#3c3836"
+bkmk_modal_text = "#ebdbb2"
+bkmk_modal_label = "#ebdbb2"
+bkmk_modal_field_bg = "#1d2021"
+bkmk_modal_field_text = "#f8f8f2"
diff --git a/contrib/themes/nord.toml b/contrib/themes/nord.toml
new file mode 100644
index 0000000..0516b62
--- /dev/null
+++ b/contrib/themes/nord.toml
@@ -0,0 +1,112 @@
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+bg = "#2e3440"
+tab_num = "#88c0d0"
+tab_divider = "#4c566a"
+bottombar_label = "#88c0d0"
+bottombar_text = "#eceff4"
+bottombar_bg = "#3b4252"
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+hdg_1 = "#5e81ac"
+hdg_2 = "#81a1c1"
+hdg_3 = "#8fbcbb"
+amfora_link = "#88c0d0"
+foreign_link = "#b48ead"
+link_number = "#a3be8c"
+regular_text = "#eceff4"
+quote_text = "#81a1c1"
+preformatted_text = "#8fbcbb"
+list_text = "#d8dee9"
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+btn_bg = "#4c566a"
+btn_text = "#eceff4"
+
+# dl_choice_modal_bg
+# dl_choice_modal_text
+# dl_modal_bg
+# dl_modal_text
+# info_modal_bg
+# info_modal_text
+# error_modal_bg
+# error_modal_text
+# yesno_modal_bg
+# yesno_modal_text
+# tofu_modal_bg
+# tofu_modal_text
+# subscription_modal_bg
+# subscription_modal_text
+dl_choice_modal_bg = "#3b4252"
+dl_choice_modal_text = "#eceff4"
+dl_modal_bg = "#3b4252"
+dl_modal_text = "#eceff4"
+info_modal_bg = "#3b4252"
+info_modal_text = "#eceff4"
+error_modal_bg = "#bf616a"
+error_modal_text = "#eceff4"
+yesno_modal_bg = "#3b4252"
+yesno_modal_text = "#eceff4"
+tofu_modal_bg = "#3b4252"
+tofu_modal_text = "#eceff4"
+subscription_modal_bg = "#3b4252"
+subscription_modal_text = "#eceff4"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+input_modal_bg = "#3b4252"
+input_modal_text = "#eceff4"
+input_modal_field_bg = "#4c566a"
+input_modal_field_text = "#eceff4"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+bkmk_modal_bg = "#3b4252"
+bkmk_modal_text = "#eceff4"
+bkmk_modal_label = "#eceff4"
+bkmk_modal_field_bg = "#4c566a"
+bkmk_modal_field_text = "#eceff4"
diff --git a/contrib/themes/one_dark.toml b/contrib/themes/one_dark.toml
new file mode 100644
index 0000000..227f641
--- /dev/null
+++ b/contrib/themes/one_dark.toml
@@ -0,0 +1,128 @@
+# Atom One Dark theme ported to Amfora
+# by Serge Tymoshenko
+
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+
+bg = "#282c34"
+fg = "#abb2bf"
+tab_num = "#abb2bf"
+tab_divider = "#abb2bf"
+bottombar_bg = "#abb2bf"
+bottombar_text = "#282c34"
+bottombar_label = "#282c34"
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+
+hdg_1 = "#e06c75"
+hdg_2 = "#c678dd"
+hdg_3 = "#c678dd"
+amfora_link = "#61afef"
+foreign_link = "#56b6c2"
+link_number = "#abb2bf"
+regular_text = "#abb2bf"
+quote_text = "#98c379"
+preformatted_text = "#e5c07b"
+list_text = "#abb2bf"
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+
+btn_bg = "#282c34"
+btn_text = "#abb2bf"
+
+# dl_choice_modal_bg
+# dl_choice_modal_text
+# dl_modal_bg
+# dl_modal_text
+# info_modal_bg
+# info_modal_text
+# error_modal_bg
+# error_modal_text
+# yesno_modal_bg
+# yesno_modal_text
+# tofu_modal_bg
+# tofu_modal_text
+
+dl_choice_modal_bg = "#98c379"
+dl_choice_modal_text = "#282c34"
+
+dl_modal_bg = "#98c379"
+dl_modal_text = "#282c34"
+
+info_modal_bg = "#98c379"
+info_modal_text = "#282c34"
+
+error_modal_bg = "#e06c75"
+error_modal_text = "#282c34"
+
+yesno_modal_bg = "#e5c07b"
+yesno_modal_text = "#282c34"
+
+tofu_modal_bg = "#e5c07b"
+tofu_modal_text = "#282c34"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+
+input_modal_bg = "#98c379"
+input_modal_text = "#282c34"
+input_modal_field_bg = "#282c34"
+input_modal_field_text = "#abb2bf"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+
+bkmk_modal_bg = "#98c379"
+bkmk_modal_text = "#282c34"
+bkmk_modal_label = "#282c34"
+bkmk_modal_field_bg = "#282c34"
+bkmk_modal_field_text = "#abb2bf"
+
+# subscription_modal_bg
+# subscription_modal_text
+subscription_modal_bg = "#c678dd"
+subscription_modal_text = "#282c34"
\ No newline at end of file
diff --git a/contrib/themes/solarized_dark.toml b/contrib/themes/solarized_dark.toml
new file mode 100644
index 0000000..68d20fc
--- /dev/null
+++ b/contrib/themes/solarized_dark.toml
@@ -0,0 +1,102 @@
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+
+bg = "#002b36"
+fg = "#EDE8D5"
+tab_num = "#3889D2"
+tab_divider = "#0F3642"
+bottombar_bg = "#0F3642"
+bottombar_text = "#93a1a1"
+bottombar_label = "#3ea197"
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+
+hdg_1 = "#3EA197"
+hdg_2 = "#3889D2"
+hdg_3 = "#6D6EC4"
+amfora_link = "#94A1A1"
+foreign_link = "#849496"
+link_number = "#869B00"
+regular_text = "#EDE8D5"
+quote_text = "#EDE8D5"
+preformatted_text = "#EDE8D5"
+list_text = "#EDE8D5"
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+
+btn_bg = "#3889D2"
+btn_text = "#FCF6E3"
+
+dl_choice_modal_bg = "#073642"
+dl_choice_modal_text = "#93a1a1"
+dl_modal_bg = "#073642"
+dl_modal_text = "#94a1a1"
+info_modal_bg = "#073642"
+info_modal_text = "#94a1a1"
+error_modal_bg = "#073642"
+error_modal_text = "#D53234"
+yesno_modal_bg = "#073642"
+yesno_modal_text = "#94a1a1"
+tofu_modal_bg = "#073642"
+tofu_modal_text = "#94a1a1"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+
+input_modal_bg = "#073642"
+input_modal_text = "#94a1a1"
+input_modal_field_bg = "#062B36"
+input_modal_field_text ="#94a1a1"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+
+bkmk_modal_bg = "#073642"
+bkmk_modal_text = "#94a1a1"
+bkmk_modal_label = "#3ea197"
+bkmk_modal_field_bg = "#062B36"
+bkmk_modal_field_text = "#94a1a1"
\ No newline at end of file
diff --git a/contrib/themes/solarized_light.toml b/contrib/themes/solarized_light.toml
new file mode 100644
index 0000000..12dfbb6
--- /dev/null
+++ b/contrib/themes/solarized_light.toml
@@ -0,0 +1,102 @@
+[theme]
+# This section is for changing the COLORS used in Amfora.
+# These colors only apply if 'color' is enabled above.
+# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
+
+# Note that not all colors will work on terminals that do not have truecolor support.
+# If you want to stick to the standard 16 or 256 colors, you can get
+# a list of those here: https://jonasjacek.github.io/colors/
+# DO NOT use the names from that site, just the hex codes.
+
+# Definitions:
+# bg = background
+# fg = foreground
+# dl = download
+# btn = button
+# hdg = heading
+# bkmk = bookmark
+# modal = a popup window/box in the middle of the screen
+
+# EXAMPLES:
+# hdg_1 = "green"
+# hdg_2 = "#5f0000"
+
+# Available keys to set:
+
+# bg: background for pages, tab row, app in general
+# tab_num: The number/highlight of the tabs at the top
+# tab_divider: The color of the divider character between tab numbers: |
+# bottombar_label: The color of the prompt that appears when you press space
+# bottombar_text: The color of the text you type
+# bottombar_bg
+
+bg = "#FCF6E3"
+fg = "#5A6E75"
+tab_num = "#3889D2"
+tab_divider = "#EDE8D5"
+bottombar_bg = "#EDE8D5"
+bottombar_text = "#5A6E75"
+bottombar_label = "#3ea197"
+
+# hdg_1
+# hdg_2
+# hdg_3
+# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
+# foreign_link: HTTP(S), Gopher, etc
+# link_number: The silver number that appears to the left of a link
+# regular_text: Normal gemini text, and plaintext documents
+# quote_text
+# preformatted_text
+# list_text
+
+hdg_1 = "#3EA197"
+hdg_2 = "#3889D2"
+hdg_3 = "#6D6EC4"
+amfora_link = "#5A6E75"
+foreign_link = "#677B83"
+link_number = "#CC3283"
+regular_text = "#0F3642"
+quote_text = "#0F3642"
+preformatted_text = "#0F3642"
+list_text = "#0F3642"
+
+# btn_bg: The bg color for all modal buttons
+# btn_text: The text color for all modal buttons
+
+btn_bg = "#3889D2"
+btn_text = "#FCF6E3"
+
+dl_choice_modal_bg = "#EDE8D5"
+dl_choice_modal_text = "#0F3642"
+dl_modal_bg = "#EDE8D5"
+dl_modal_text = "#0F3642"
+info_modal_bg = "#EDE8D5"
+info_modal_text = "#0F3642"
+error_modal_bg = "#EDE8D5"
+error_modal_text = "#D53234"
+yesno_modal_bg = "#EDE8D5"
+yesno_modal_text = "#0F3642"
+tofu_modal_bg = "#EDE8D5"
+tofu_modal_text = "#0F3642"
+
+# input_modal_bg
+# input_modal_text
+# input_modal_field_bg: The bg of the input field, where you type the text
+# input_modal_field_text: The color of the text you type
+
+input_modal_bg = "#EDE8D5"
+input_modal_text = "#0F3642"
+input_modal_field_bg = "#FCF6E3"
+input_modal_field_text ="#0F3642"
+
+# bkmk_modal_bg
+# bkmk_modal_text
+# bkmk_modal_label
+# bkmk_modal_field_bg
+# bkmk_modal_field_text
+
+bkmk_modal_bg = "#EDE8D5"
+bkmk_modal_text = "#0F3642"
+bkmk_modal_label = "#3ea197"
+bkmk_modal_field_bg = "#FCF6E3"
+bkmk_modal_field_text = "#0F3642"
\ No newline at end of file
diff --git a/default-config.toml b/default-config.toml
index 9ac0d21..c45b587 100644
--- a/default-config.toml
+++ b/default-config.toml
@@ -25,14 +25,15 @@ auto_redirect = false
#
# The best to define a command is using a string array.
# Examples:
-# http = ["firefox"]
-# http = ["custom-browser", "--flag", "--option=2"]
-# http = ["/path/with spaces/in it/firefox"]
+# http = ['firefox']
+# http = ['custom-browser', '--flag', '--option=2']
+# http = ['/path/with spaces/in it/firefox']
#
-# Using just a string will also work, but it is deprecated,
-# and will degrade if you use paths with spaces.
+# Note the use of single quotes, so that backslashes will not be escaped.
+# Using just a string will also work, but it is deprecated, and will degrade if
+# you use paths with spaces.
-http = "default"
+http = 'default'
# Any URL that will accept a query string can be put here
search = "gemini://gus.guru/search"
@@ -46,6 +47,9 @@ ansi = true
# Whether to replace list asterisks with unicode bullets
bullets = true
+# Whether to show link after link text
+show_link = false
+
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
left_margin = 0.15
@@ -55,7 +59,8 @@ max_width = 100
# 'downloads' is the path to a downloads folder.
# An empty value means the code will find the default downloads folder for your system.
# If the path does not exist it will be created.
-downloads = ""
+# Note the use of single quotes, so that backslashes will not be escaped.
+downloads = ''
# Max size for displayable content in bytes - after that size a download window pops up
page_max_size = 2097152 # 2 MiB
@@ -68,51 +73,163 @@ emoji_favicons = false
[auth]
# Authentication settings
+# Note the use of single quotes for values, so that backslashes will not be escaped.
[auth.certs]
# Client certificates
# Set domain name equal to path to client cert
-# "example.com" = "mycert.crt"
+# "example.com" = 'mycert.crt'
[auth.keys]
# Client certificate keys
# Set domain name equal to path to key for the client cert above
-# "example.com" = "mycert.key"
+# "example.com" = 'mycert.key'
[keybindings]
-# In the future there will be more settings here.
+# If you have a non-US keyboard, use bind_tab1 through bind_tab0 to
+# setup the shift-number bindings: Eg, for US keyboards (the default):
+# bind_tab1 = "!"
+# bind_tab2 = "@"
+# bind_tab3 = "#"
+# bind_tab4 = "$"
+# bind_tab5 = "%"
+# bind_tab6 = "^"
+# bind_tab7 = "&"
+# bind_tab8 = "*"
+# bind_tab9 = "("
+# bind_tab0 = ")"
-# Hold down shift and press the numbers on your keyboard (1,2,3,4,5,6,7,8,9,0) to set this up.
-# It is default set to be accurate for US keyboards.
-shift_numbers = "!@#$%^&*()"
+# Whitespace is not allowed in any of the keybindings! Use 'Space' and 'Tab' to bind to those keys.
+# Multiple keys can be bound to one command, just use a TOML array.
+# To add the Alt modifier, the binding must start with Alt-, should be reasonably universal
+# Ctrl- won't work on all keys, see this for a list:
+# https://github.com/gdamore/tcell/blob/cb1e5d6fa606/key.go#L83
+# An example of a TOML array for multiple keys being bound to one command is the default
+# binding for reload:
+# bind_reload = ["R","Ctrl-R"]
+# One thing to note here is that "R" is capitalization sensitive, so it means shift-r.
+# "Ctrl-R" means both ctrl-r and ctrl-shift-R (this is a quirk of what ctrl-r means on
+# an ANSI terminal)
+
+# The default binding for opening the bottom bar for entering a URL or link number is:
+# bind_bottom = "Space"
+# This is how to get the Spacebar as a keybinding, if you try to use " ", it won't work.
+# And, finally, an example of a simple, unmodified character is:
+# bind_edit = "e"
+# This binds the "e" key to the command to edit the current URL.
+
+# The bind_link[1-90] options are for the commands to go to the first 10 links on a page,
+# typically these are bound to the number keys:
+# bind_link1 = "1"
+# bind_link2 = "2"
+# bind_link3 = "3"
+# bind_link4 = "4"
+# bind_link5 = "5"
+# bind_link6 = "6"
+# bind_link7 = "7"
+# bind_link8 = "8"
+# bind_link9 = "9"
+# bind_link0 = "0"
[url-handlers]
# Allows setting the commands to run for various URL schemes.
# E.g. to open FTP URLs with FileZilla set the following key:
-# ftp = "filezilla"
+# ftp = 'filezilla'
# You can set any scheme to "off" or "" to disable handling it, or
# just leave the key unset.
#
# DO NOT use this for setting the HTTP command.
# Use the http setting in the "a-general" section above.
#
-# NOTE: These settings are override by the ones in the proxies section.
+# NOTE: These settings are overrided by the ones in the proxies section.
+# Note the use of single quotes, so that backslashes will not be escaped.
# This is a special key that defines the handler for all URL schemes for which
# no handler is defined.
-other = "off"
+other = 'off'
+
+
+# [[mediatype-handlers]] section
+# ---------------------------------
+#
+# Specify what applications will open certain media types.
+# By default your default application will be used to open the file when you select "Open".
+# You only need to configure this section if you want to override your default application,
+# or do special things like streaming.
+#
+# Note the use of single quotes for commands, so that backslashes will not be escaped.
+#
+#
+# To open jpeg files with the feh command:
+#
+# [[mediatype-handlers]]
+# cmd = ['feh']
+# types = ["image/jpeg"]
+#
+# Each command that you specify must come under its own [[mediatype-handlers]]. You may
+# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
+#
+# If the subtype is omitted then the specified command will be used for the
+# entire type:
+#
+# [[mediatype-handlers]]
+# command = ['vlc', '--flag']
+# types = ["audio", "video"]
+#
+# A catch-all handler can by specified with "*".
+# Note that there are already catch-all handlers in place for all OSes,
+# that open the file using your default application. This is only if you
+# want to override that.
+#
+# [[mediatype-handlers]]
+# cmd = ['some-command']
+# types = [
+# "application/pdf",
+# "*",
+# ]
+#
+# You can also choose to stream the data instead of downloading it all before
+# opening it. This is especially useful for large video or audio files, as
+# well as radio streams, which will never complete. You can do this like so:
+#
+# [[mediatype-handlers]]
+# cmd = ['vlc', '-']
+# types = ["audio", "video"]
+# stream = true
+#
+# This uses vlc to stream all video and audio content.
+# By default stream is set to off for all handlers
+#
+#
+# If you want to always open a type in its viewer without the download or open
+# prompt appearing, you can add no_prompt = true
+#
+# [[mediatype-handlers]]
+# cmd = ['feh']
+# types = ["image"]
+# no_prompt = true
+#
+# Note: Multiple handlers cannot be defined for the same full media type, but
+# still there needs to be an order for which handlers are used. The following
+# order applies regardless of the order written in the config:
+#
+# 1. Full media type: "image/jpeg"
+# 2. Just type: "image"
+# 3. Catch-all: "*"
[cache]
-# Options for page cache - which is only for text/gemini pages
+# Options for page cache - which is only for text pages
# Increase the cache size to speed up browsing at the expense of memory
-
# Zero values mean there is no limit
+
max_size = 0 # Size in bytes
max_pages = 30 # The maximum number of pages the cache will store
+# How long a page will stay in cache, in seconds.
+timeout = 1800 # 30 mins
[proxies]
# Allows setting a Gemini proxy for different schemes.
@@ -128,6 +245,28 @@ max_pages = 30 # The maximum number of pages the cache will store
# Note that HTTP and HTTPS are treated as separate protocols here.
+[subscriptions]
+# For tracking feeds and pages
+
+# Whether a pop-up appears when viewing a potential feed
+popup = true
+
+# How often to check for updates to subscriptions in the background, in seconds.
+# Set it to 0 to disable this feature. You can still update individual feeds
+# manually, or restart the browser.
+#
+# Note Amfora will check for updates on browser start no matter what this setting is.
+update_interval = 1800 # 30 mins
+
+# How many subscriptions can be checked at the same time when updating.
+# If you have many subscriptions you may want to increase this for faster
+# update times. Any value below 1 will be corrected to 1.
+workers = 3
+
+# The number of subscription updates displayed per page.
+entries_per_page = 20
+
+
[theme]
# This section is for changing the COLORS used in Amfora.
# These colors only apply if 'color' is enabled above.
@@ -186,6 +325,8 @@ max_pages = 30 # The maximum number of pages the cache will store
# yesno_modal_text
# tofu_modal_bg
# tofu_modal_text
+# subscription_modal_bg
+# subscription_modal_text
# input_modal_bg
# input_modal_text
diff --git a/display/bookmarks.go b/display/bookmarks.go
index 2a6a9df..16b2144 100644
--- a/display/bookmarks.go
+++ b/display/bookmarks.go
@@ -110,16 +110,17 @@ func openBkmkModal(name string, exists bool, favicon string) (string, int) {
// Bookmarks displays the bookmarks page on the current tab.
func Bookmarks(t *tab) {
+ bkmkPageRaw := "# Bookmarks\r\n\r\n"
+
// Gather bookmarks
- rawContent := "# Bookmarks\r\n\r\n"
m, keys := bookmarks.All()
for i := range keys {
- rawContent += fmt.Sprintf("=> %s %s\r\n", keys[i], m[keys[i]])
+ bkmkPageRaw += fmt.Sprintf("=> %s %s\r\n", keys[i], m[keys[i]])
}
// Render and display
- content, links := renderer.RenderGemini(rawContent, textWidth(), leftMargin(), false)
+ content, links := renderer.RenderGemini(bkmkPageRaw, textWidth(), leftMargin(), false)
page := structs.Page{
- Raw: rawContent,
+ Raw: bkmkPageRaw,
Content: content,
Links: links,
URL: "about:bookmarks",
diff --git a/display/display.go b/display/display.go
index cc67bcc..801d516 100644
--- a/display/display.go
+++ b/display/display.go
@@ -3,6 +3,7 @@ package display
import (
"fmt"
"net/url"
+ "regexp"
"strconv"
"strings"
@@ -26,8 +27,17 @@ var termH int
// The user input and URL display bar at the bottom
var bottomBar = cview.NewInputField()
-// Viewer for primitives
-// This contains the browser and any modals modals drawn on top of it.
+// When the bottom bar string has a space, this regex decides whether it's
+// a non-encoded URL or a search string.
+// See this comment for details:
+// https://github.com/makeworld-the-better-one/amfora/issues/138#issuecomment-740961292
+var hasSpaceisURL = regexp.MustCompile(`[^ ]+\.[^ ].*/.`)
+
+// 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 are those named after modals,
+// which are used to draw modals on top the current tab.
// Ex: "info", "error", "input", "yesno"
var panels = cview.NewPanels()
@@ -40,10 +50,25 @@ var browser = cview.NewTabbedPanels()
var layout = cview.NewFlex()
var newTabPage structs.Page
+var versionPage structs.Page
var App = cview.NewApplication()
-func Init() {
+func Init(version, commit, builtBy string) {
+ versionContent := fmt.Sprintf(
+ "# Amfora Version Info\n\nAmfora: %s\nCommit: %s\nBuilt by: %s",
+ version, commit, builtBy,
+ )
+ renderVersionContent, versionLinks := renderer.RenderGemini(versionContent, textWidth(), leftMargin(), false)
+ versionPage = structs.Page{
+ Raw: versionContent,
+ Content: renderVersionContent,
+ Links: versionLinks,
+ URL: "about:version",
+ Width: -1, // Force reformatting on first display
+ Mediatype: structs.TextGemini,
+ }
+
App.EnableMouse(false)
App.SetRoot(layout, true)
App.SetAfterResizeFunc(func(width int, height int) {
@@ -62,6 +87,10 @@ func Init() {
panels.AddPanel("browser", browser, true, true)
+ tabRow.SetChangedFunc(func() {
+ App.Draw()
+ })
+
helpInit()
layout.SetDirection(cview.FlexRow)
@@ -157,14 +186,21 @@ func Init() {
} else {
// It's a full URL or search term
// Detect if it's a search or URL
- if strings.Contains(query, " ") ||
- (!strings.Contains(query, "//") && !strings.Contains(query, ".") && !strings.HasPrefix(query, "about:")) {
+ if (strings.Contains(query, " ") && !hasSpaceisURL.MatchString(query)) ||
+ (!strings.HasPrefix(query, "//") && !strings.Contains(query, "://") &&
+ !strings.Contains(query, ".")) {
+ // Has a space and follows regex, OR
+ // doesn't start with "//", contain "://", and doesn't have a dot either.
+ // Then it's a search
+
u := viper.GetString("a-general.search") + "?" + gemini.QueryEscape(query)
- cache.RemovePage(u) // Don't use the cached version of the search
+ // Don't use the cached version of the search
+ cache.RemovePage(normalizeURL(u))
URL(u)
} else {
// Full URL
- cache.RemovePage(query) // Don't use cached version for manually entered URL
+ // Don't use cached version for manually entered URL
+ cache.RemovePage(normalizeURL(fixUserURL(query)))
URL(query)
}
return
@@ -188,6 +224,7 @@ func Init() {
})
// Render the default new tab content ONCE and store it for later
+ // This code is repeated in Reload()
newTabContent := getNewTabContent()
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), leftMargin(), false)
newTabPage = structs.Page{
@@ -225,43 +262,36 @@ func Init() {
return event
}
+ // To add a configurable global key command, you'll need to update one of
+ // the two switch statements here. You'll also need to add an enum entry in
+ // config/keybindings.go, update KeyInit() in config/keybindings.go, add a default
+ // keybinding in config/config.go and update the help panel in display/help.go
+
+ cmd := config.TranslateKeyEvent(event)
if tabs[curTab].mode == tabModeDone {
// All the keys and operations that can only work while NOT loading
-
- // History arrow keys
- if event.Modifiers() == tcell.ModAlt {
- if event.Key() == tcell.KeyLeft {
- histBack(tabs[curTab])
- return nil
- }
- if event.Key() == tcell.KeyRight {
- histForward(tabs[curTab])
- return nil
- }
- }
-
//nolint:exhaustive
- switch event.Key() {
- case tcell.KeyCtrlR:
+ switch cmd {
+ case config.CmdReload:
Reload()
return nil
- case tcell.KeyCtrlH:
+ case config.CmdHome:
URL(viper.GetString("a-general.home"))
return nil
- case tcell.KeyCtrlB:
+ case config.CmdBookmarks:
Bookmarks(tabs[curTab])
tabs[curTab].addToHistory("about:bookmarks")
return nil
- case tcell.KeyCtrlD:
+ case config.CmdAddBookmark:
go addBookmark()
return nil
- case tcell.KeyPgUp:
+ case config.CmdPgup:
tabs[curTab].pageUp()
return nil
- case tcell.KeyPgDn:
+ case config.CmdPgdn:
tabs[curTab].pageDown()
return nil
- case tcell.KeyCtrlS:
+ case config.CmdSave:
if tabs[curTab].hasContent() {
savePath, err := downloadPage(tabs[curTab].page)
if err != nil {
@@ -273,59 +303,48 @@ func Init() {
Info("The current page has no content, so it couldn't be downloaded.")
}
return nil
- case tcell.KeyRune:
- // Regular key was sent
- switch string(event.Rune()) {
- case " ":
- // Space starts typing, like Bombadillo
- bottomBar.SetLabel("[::b]URL/Num./Search: [::-]")
- bottomBar.SetText("")
- // Don't save bottom bar, so that whenever you switch tabs, it's not in that mode
- App.SetFocus(bottomBar)
- return nil
- case "e":
- // Letter e allows to edit current URL
- bottomBar.SetLabel("[::b]Edit URL: [::-]")
- bottomBar.SetText(tabs[curTab].page.URL)
- App.SetFocus(bottomBar)
- return nil
- case "R":
- Reload()
- return nil
- case "b":
- histBack(tabs[curTab])
- return nil
- case "f":
- histForward(tabs[curTab])
- return nil
- case "u":
- tabs[curTab].pageUp()
- return nil
- case "d":
- tabs[curTab].pageDown()
- return nil
- }
+ case config.CmdBottom:
+ // Space starts typing, like Bombadillo
+ bottomBar.SetLabel("[::b]URL/Num./Search: [::-]")
+ bottomBar.SetText("")
+ // Don't save bottom bar, so that whenever you switch tabs, it's not in that mode
+ App.SetFocus(bottomBar)
+ return nil
+ case config.CmdEdit:
+ // Letter e allows to edit current URL
+ bottomBar.SetLabel("[::b]Edit URL: [::-]")
+ bottomBar.SetText(tabs[curTab].page.URL)
+ App.SetFocus(bottomBar)
+ return nil
+ case config.CmdBack:
+ histBack(tabs[curTab])
+ return nil
+ case config.CmdForward:
+ histForward(tabs[curTab])
+ return nil
+ case config.CmdSub:
+ Subscriptions(tabs[curTab], "about:subscriptions")
+ tabs[curTab].addToHistory("about:subscriptions")
+ return nil
+ case config.CmdAddSub:
+ go addSubscription()
+ return nil
+ }
- // Number key: 1-9, 0
- i, err := strconv.Atoi(string(event.Rune()))
- if err == nil {
- if i == 0 {
- i = 10 // 0 key is for link 10
- }
- if i <= len(tabs[curTab].page.Links) && i > 0 {
- // It's a valid link number
- followLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Links[i-1])
- return nil
- }
+ // Number key: 1-9, 0, LINK1-LINK10
+ if cmd >= config.CmdLink1 && cmd <= config.CmdLink0 {
+ if int(cmd) <= len(tabs[curTab].page.Links) {
+ // It's a valid link number
+ followLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Links[cmd-1])
+ return nil
}
}
}
// All the keys and operations that can work while a tab IS loading
-
//nolint:exhaustive
- switch event.Key() {
- case tcell.KeyCtrlT:
+ switch cmd {
+ case config.CmdNewTab:
if tabs[curTab].page.Mode == structs.ModeLinkSelect {
next, err := resolveRelLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Selected)
if err != nil {
@@ -338,45 +357,33 @@ func Init() {
NewTab()
}
return nil
- case tcell.KeyCtrlW:
+ case config.CmdCloseTab:
CloseTab()
return nil
- case tcell.KeyCtrlQ:
+ case config.CmdQuit:
Stop()
return nil
- case tcell.KeyCtrlC:
- Stop()
- return nil
- case tcell.KeyF1:
+ case config.CmdPrevTab:
// Wrap around, allow for modulo with negative numbers
n := NumTabs()
SwitchTab((((curTab - 1) % n) + n) % n)
return nil
- case tcell.KeyF2:
+ case config.CmdNextTab:
SwitchTab((curTab + 1) % NumTabs())
return nil
- case tcell.KeyRune:
- // Regular key was sent
+ case config.CmdHelp:
+ Help()
+ return nil
+ }
- if num, err := config.KeyToNum(event.Rune()); err == nil {
- // It's a Shift+Num key
- if num == 0 {
- // Zero key goes to the last tab
- SwitchTab(NumTabs() - 1)
- } else {
- SwitchTab(num - 1)
- }
- return nil
- }
-
- switch string(event.Rune()) {
- case "q":
- Stop()
- return nil
- case "?":
- Help()
- return nil
+ if cmd >= config.CmdTab1 && cmd <= config.CmdTab0 {
+ if cmd == config.CmdTab0 {
+ // Zero key goes to the last tab
+ SwitchTab(NumTabs() - 1)
+ } else {
+ SwitchTab(int(cmd - config.CmdTab1))
}
+ return nil
}
// Let another element handle the event, it's not a special global key
@@ -412,10 +419,8 @@ func NewTab() {
tabs = append(tabs, makeNewTab())
temp := newTabPage // Copy
setPage(tabs[curTab], &temp)
-
- // Can't go backwards, but this isn't the first page either.
- // The first page will be the next one the user goes to.
- tabs[curTab].history.pos = -1
+ tabs[curTab].addToHistory("about:newtab")
+ tabs[curTab].history.pos = 0 // Manually set as first page
browser.AddTab(strconv.Itoa(curTab), strconv.Itoa(curTab+1), tabs[curTab].view)
browser.SetCurrentTab(strconv.Itoa(curTab))
@@ -534,28 +539,15 @@ func Reload() {
// URL loads and handles the provided URL for the current tab.
// It should be an absolute URL.
func URL(u string) {
- // Some code is copied in followLink()
-
- if u == "about:bookmarks" { //nolint:goconst
- Bookmarks(tabs[curTab])
- tabs[curTab].addToHistory("about:bookmarks")
- return
- }
- if u == "about:newtab" {
- temp := newTabPage // Copy
- setPage(tabs[curTab], &temp)
- return
- }
+ t := tabs[curTab]
if strings.HasPrefix(u, "about:") {
- Error("Error", "Not a valid 'about:' URL.")
+ if final, ok := handleAbout(t, u); ok {
+ t.addToHistory(final)
+ }
return
}
- if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") {
- // Assume it's a Gemini URL
- u = "gemini://" + u
- }
- go goURL(tabs[curTab], u)
+ go goURL(t, fixUserURL(u))
}
func NumTabs() int {
diff --git a/display/download.go b/display/download.go
index 8b84b3f..0853065 100644
--- a/display/download.go
+++ b/display/download.go
@@ -4,8 +4,10 @@ import (
"fmt"
"io"
"io/ioutil"
+ "mime"
"net/url"
"os"
+ "os/exec"
"path"
"path/filepath"
"strconv"
@@ -15,8 +17,9 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/structs"
+ "github.com/makeworld-the-better-one/amfora/sysopen"
"github.com/makeworld-the-better-one/go-gemini"
- "github.com/makeworld-the-better-one/progressbar/v3"
+ "github.com/schollz/progressbar/v3"
"github.com/spf13/viper"
"gitlab.com/tslocum/cview"
)
@@ -65,7 +68,7 @@ func dlInit() {
frame.SetTitleColor(tcell.ColorWhite)
}
- chm.AddButtons([]string{"Download", "Open in portal", "Cancel"})
+ chm.AddButtons([]string{"Open", "Download", "Cancel"})
chm.SetBorder(true)
chm.GetFrame().SetTitleAlign(cview.AlignCenter)
chm.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
@@ -85,55 +88,127 @@ func dlInit() {
})
}
+func getMediaHandler(resp *gemini.Response) config.MediaHandler {
+ def := config.MediaHandler{
+ Cmd: nil,
+ NoPrompt: false,
+ Stream: false,
+ }
+
+ mediatype, _, err := mime.ParseMediaType(resp.Meta)
+ if err != nil {
+ return def
+ }
+
+ if ret, ok := config.MediaHandlers[mediatype]; ok {
+ return ret
+ }
+
+ splitType := strings.Split(mediatype, "/")[0]
+ if ret, ok := config.MediaHandlers[splitType]; ok {
+ return ret
+ }
+
+ if ret, ok := config.MediaHandlers["*"]; ok {
+ return ret
+ }
+
+ return def
+}
+
// dlChoice displays the download choice modal and acts on the user's choice.
// It should run in a goroutine.
func dlChoice(text, u string, resp *gemini.Response) {
- defer resp.Body.Close()
+ mediaHandler := getMediaHandler(resp)
+ var choice string
- parsed, err := url.Parse(u)
- if err != nil {
- Error("URL Error", err.Error())
- return
+ if mediaHandler.NoPrompt {
+ choice = "Open"
+ } else {
+ dlChoiceModal.SetText(text)
+ tabPages.ShowPage("dlChoice")
+ tabPages.SendToFront("dlChoice")
+ App.SetFocus(dlChoiceModal)
+ App.Draw()
+ choice = <-dlChoiceCh
}
- dlChoiceModal.SetText(text)
- panels.ShowPanel("dlChoice")
- App.SetFocus(dlChoiceModal)
- App.Draw()
-
- choice := <-dlChoiceCh
if choice == "Download" {
panels.HidePanel("dlChoice")
App.Draw()
- downloadURL(u, resp)
+ downloadURL(config.DownloadsDir, u, resp)
+ resp.Body.Close() // Only close when the file is downloaded
return
}
- if choice == "Open in portal" {
- // 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
+ if choice == "Open" {
+ tabPages.HidePage("dlChoice")
+ App.Draw()
+ open(u, resp)
+ return
+ }
+ tabPages.SwitchToPage(strconv.Itoa(curTab))
+ App.SetFocus(tabs[curTab].view)
+ App.Draw()
+}
+
+// open performs the same actions as downloadURL except it also opens the file.
+// If there is no system viewer configured for the particular mediatype, it opens it
+// with the default system viewer.
+func open(u string, resp *gemini.Response) {
+ mediaHandler := getMediaHandler(resp)
+
+ if mediaHandler.Stream {
+ // Run command with downloaded data from stdin
+
+ cmd := mediaHandler.Cmd
+ var proc *exec.Cmd
+ if len(cmd) == 1 {
+ proc = exec.Command(cmd[0])
+ } else {
+ proc = exec.Command(cmd[0], cmd[1:]...)
}
- portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1"
- ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false)
- if ok {
- browser.SetCurrentTab(strconv.Itoa(curTab))
- App.SetFocus(tabs[curTab].view)
- App.Draw()
+ proc.Stdin = resp.Body
+
+ err := proc.Start()
+ if err != nil {
+ Error("File Opening Error", "Error executing custom command: "+err.Error())
+ return
}
+ Info("Opened with " + cmd[0])
+ return
+ }
+
+ path := downloadURL(config.TempDownloadsDir, u, resp)
+ if path == "" {
return
}
browser.SetCurrentTab(strconv.Itoa(curTab))
App.SetFocus(tabs[curTab].view)
App.Draw()
+ if mediaHandler.Cmd == nil {
+ // Open with system default viewer
+ _, err := sysopen.Open(path)
+ if err != nil {
+ Error("System Viewer Error", err.Error())
+ return
+ }
+ Info("Opened in default system viewer")
+ } else {
+ cmd := mediaHandler.Cmd
+ err := exec.Command(cmd[0], append(cmd[1:], path)...).Start()
+ if err != nil {
+ Error("File Opening Error", "Error executing custom command: "+err.Error())
+ return
+ }
+ Info("Opened with " + cmd[0])
+ }
+ App.Draw()
}
// downloadURL pulls up a modal to show download progress and saves the URL content.
// downloadPage should be used for Page content.
-func downloadURL(u string, resp *gemini.Response) {
+// Returns location downloaded to or an empty string on error.
+func downloadURL(dir, u string, resp *gemini.Response) string {
_, _, width, _ := dlModal.GetInnerRect()
// Copy of progressbar.DefaultBytesSilent with custom width
bar := progressbar.NewOptions64(
@@ -147,15 +222,15 @@ func downloadURL(u string, resp *gemini.Response) {
)
bar.RenderBlank() //nolint:errcheck
- savePath, err := downloadNameFromURL(u, "")
+ savePath, err := downloadNameFromURL(dir, u, "")
if err != nil {
Error("Download Error", "Error deciding on file name: "+err.Error())
- return
+ return ""
}
f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
Error("Download Error", "Error creating download file: "+err.Error())
- return
+ return ""
}
defer f.Close()
@@ -184,7 +259,7 @@ func downloadURL(u string, resp *gemini.Response) {
Error("Download Error", err.Error())
f.Close()
os.Remove(savePath) // Remove partial file
- return
+ return ""
}
dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath))
dlModal.ClearButtons()
@@ -192,6 +267,8 @@ func downloadURL(u string, resp *gemini.Response) {
dlModal.GetForm().SetFocus(100)
App.SetFocus(dlModal)
App.Draw()
+
+ return savePath
}
// downloadPage saves the passed Page to a file.
@@ -202,9 +279,9 @@ func downloadPage(p *structs.Page) (string, error) {
var err error
if p.Mediatype == structs.TextGemini {
- savePath, err = downloadNameFromURL(p.URL, ".gmi")
+ savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".gmi")
} else {
- savePath, err = downloadNameFromURL(p.URL, ".txt")
+ savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".txt")
}
if err != nil {
return "", err
@@ -221,13 +298,13 @@ func downloadPage(p *structs.Page) (string, error) {
// downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file.
// ext is an extension that will be added if the file has no extension, and for domain only URLs.
// It should include the dot.
-func downloadNameFromURL(u string, ext string) (string, error) {
+func downloadNameFromURL(dir, u, ext string) (string, error) {
var name string
var err error
parsed, _ := url.Parse(u)
if parsed.Path == "" || path.Base(parsed.Path) == "/" {
// No file, just the root domain
- name, err = getSafeDownloadName(parsed.Hostname()+ext, true, 0)
+ name, err = getSafeDownloadName(dir, parsed.Hostname()+ext, true, 0)
if err != nil {
return "", err
}
@@ -238,23 +315,23 @@ func downloadNameFromURL(u string, ext string) (string, error) {
// No extension
name += ext
}
- name, err = getSafeDownloadName(name, false, 0)
+ name, err = getSafeDownloadName(dir, name, false, 0)
if err != nil {
return "", err
}
}
- return filepath.Join(config.DownloadsDir, name), nil
+ return filepath.Join(dir, name), nil
}
// getSafeDownloadName is used by downloads.go only.
-// It returns a modified name that is unique for the downloads folder.
+// It returns a modified name that is unique for the specified folder.
// This way duplicate saved files will not overwrite each other.
//
// lastDot should be set to true if the number added to the name should come before
// the last dot in the filename instead of the first.
//
// n should be set to 0, it is used for recursiveness.
-func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
+func getSafeDownloadName(dir, name string, lastDot bool, n int) (string, error) {
// newName("test.txt", 3) -> "test(3).txt"
newName := func() string {
if n <= 0 {
@@ -271,7 +348,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
return name[:idx] + "(" + strconv.Itoa(n) + ")" + name[idx:]
}
- d, err := os.Open(config.DownloadsDir)
+ d, err := os.Open(dir)
if err != nil {
return "", err
}
@@ -285,7 +362,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
for i := range files {
if nn == files[i] {
d.Close()
- return getSafeDownloadName(name, lastDot, n+1)
+ return getSafeDownloadName(dir, name, lastDot, n+1)
}
}
d.Close()
diff --git a/display/file.go b/display/file.go
new file mode 100644
index 0000000..9349c86
--- /dev/null
+++ b/display/file.go
@@ -0,0 +1,120 @@
+package display
+
+import (
+ "fmt"
+ "io/ioutil"
+ "mime"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/makeworld-the-better-one/amfora/renderer"
+ "github.com/makeworld-the-better-one/amfora/structs"
+ "github.com/spf13/viper"
+)
+
+// handleFile handles urls using file:// protocol
+func handleFile(u string) (*structs.Page, bool) {
+ page := &structs.Page{}
+
+ uri, err := url.ParseRequestURI(u)
+ if err != nil {
+ Error("File Error", "Cannot parse URI: "+err.Error())
+ return page, false
+ }
+ fi, err := os.Stat(uri.Path)
+ if err != nil {
+ Error("File Error", "Cannot open local file: "+err.Error())
+ return page, false
+ }
+
+ switch mode := fi.Mode(); {
+ case mode.IsDir():
+ // Must end in slash
+ if u[len(u)-1] != '/' {
+ u += "/"
+ }
+ return createDirectoryListing(u)
+ case mode.IsRegular():
+ if fi.Size() > viper.GetInt64("a-general.page_max_size") {
+ Error("File Error", "Cannot open local file, exceeds page max size")
+ return page, false
+ }
+
+ mimetype := mime.TypeByExtension(filepath.Ext(uri.Path))
+ if strings.HasSuffix(u, ".gmi") || strings.HasSuffix(u, ".gemini") {
+ mimetype = "text/gemini"
+ }
+
+ if !strings.HasPrefix(mimetype, "text/") {
+ Error("File Error", "Cannot open file, not recognized as text.")
+ return page, false
+ }
+
+ content, err := ioutil.ReadFile(uri.Path)
+ if err != nil {
+ Error("File Error", "Cannot open local file: "+err.Error())
+ return page, false
+ }
+
+ if mimetype == "text/gemini" {
+ rendered, links := renderer.RenderGemini(string(content), textWidth(), leftMargin(), false)
+ page = &structs.Page{
+ Mediatype: structs.TextGemini,
+ URL: u,
+ Raw: string(content),
+ Content: rendered,
+ Links: links,
+ Width: termW,
+ }
+ } else {
+ page = &structs.Page{
+ Mediatype: structs.TextPlain,
+ URL: u,
+ Raw: string(content),
+ Content: renderer.RenderPlainText(string(content), leftMargin()),
+ Links: []string{},
+ Width: termW,
+ }
+ }
+ }
+ return page, true
+}
+
+// createDirectoryListing creates a text/gemini page for a directory
+// that lists all the files as links.
+func createDirectoryListing(u string) (*structs.Page, bool) {
+ page := &structs.Page{}
+
+ uri, err := url.ParseRequestURI(u)
+ if err != nil {
+ Error("Directory Error", "Cannot parse URI: "+err.Error())
+ }
+
+ files, err := ioutil.ReadDir(uri.Path)
+ if err != nil {
+ Error("Directory error", "Cannot open local directory: "+err.Error())
+ return page, false
+ }
+ content := "Index of " + uri.Path + "\n"
+ content += "=> ../ ../\n"
+ for _, f := range files {
+ separator := ""
+ if f.IsDir() {
+ separator = "/"
+ }
+ content += fmt.Sprintf("=> %s%s %s%s\n", f.Name(), separator, f.Name(), separator)
+ }
+
+ rendered, links := renderer.RenderGemini(content, textWidth(), leftMargin(), false)
+ page = &structs.Page{
+ Mediatype: structs.TextGemini,
+ URL: u,
+ Raw: content,
+ Content: rendered,
+ Links: links,
+ Width: termW,
+ }
+ return page, true
+}
diff --git a/display/handlers.go b/display/handlers.go
new file mode 100644
index 0000000..21e3af3
--- /dev/null
+++ b/display/handlers.go
@@ -0,0 +1,522 @@
+package display
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "mime"
+ "net"
+ "net/url"
+ "os/exec"
+ "path"
+ "strings"
+
+ "github.com/makeworld-the-better-one/amfora/cache"
+ "github.com/makeworld-the-better-one/amfora/client"
+ "github.com/makeworld-the-better-one/amfora/config"
+ "github.com/makeworld-the-better-one/amfora/renderer"
+ "github.com/makeworld-the-better-one/amfora/rr"
+ "github.com/makeworld-the-better-one/amfora/structs"
+ "github.com/makeworld-the-better-one/amfora/subscriptions"
+ "github.com/makeworld-the-better-one/amfora/webbrowser"
+ "github.com/makeworld-the-better-one/go-gemini"
+ "github.com/makeworld-the-better-one/go-isemoji"
+ "github.com/spf13/viper"
+)
+
+// handleHTTP is used by handleURL.
+// It opens HTTP links and displays Info and Error modals.
+// Returns false if there was an error.
+func handleHTTP(u string, showInfo bool) bool {
+ if len(config.HTTPCommand) == 1 {
+ // Possibly a non-command
+
+ switch strings.TrimSpace(config.HTTPCommand[0]) {
+ case "", "off":
+ Error("HTTP Error", "Opening HTTP URLs is turned off.")
+ return false
+ case "default":
+ s, err := webbrowser.Open(u)
+ if err != nil {
+ Error("Webbrowser Error", err.Error())
+ return false
+ }
+ if showInfo {
+ Info(s)
+ }
+ return true
+ }
+ }
+
+ // Custom command
+ var err error = nil
+ if len(config.HTTPCommand) > 1 {
+ err = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...).Start()
+ } else {
+ err = exec.Command(config.HTTPCommand[0], u).Start()
+ }
+ if err != nil {
+ Error("HTTP Error", "Error executing custom browser command: "+err.Error())
+ return false
+ }
+
+ App.Draw()
+ return true
+}
+
+// handleOther is used by handleURL.
+// It opens links other than Gemini and HTTP and displays Error modals.
+func handleOther(u string) {
+ // The URL should have a scheme due to a previous call to normalizeURL
+ parsed, _ := url.Parse(u)
+
+ // Search for a handler for the URL scheme
+ handler := strings.TrimSpace(viper.GetString("url-handlers." + parsed.Scheme))
+ if len(handler) == 0 {
+ handler = strings.TrimSpace(viper.GetString("url-handlers.other"))
+ }
+ switch handler {
+ case "", "off":
+ Error("URL Error", "Opening "+parsed.Scheme+" URLs is turned off.")
+ default:
+ // The config has a custom command to execute for URLs
+ fields := strings.Fields(handler)
+ err := exec.Command(fields[0], append(fields[1:], u)...).Start()
+ if err != nil {
+ Error("URL Error", "Error executing custom command: "+err.Error())
+ }
+ }
+ App.Draw()
+}
+
+// handleFavicon handles getting and displaying a favicon.
+// `old` is the previous favicon for the tab.
+func handleFavicon(t *tab, host, old string) {
+ defer func() {
+ // Update display if needed
+ if t.page.Favicon != old && isValidTab(t) {
+ rewriteTabRow()
+ }
+ }()
+
+ if !viper.GetBool("a-general.emoji_favicons") {
+ // Not enabled
+ return
+ }
+ if t.page.Favicon != "" {
+ return
+ }
+ if host == "" {
+ return
+ }
+
+ fav := cache.GetFavicon(host)
+ if fav == cache.KnownNoFavicon {
+ // It's been cached that this host doesn't have a favicon
+ return
+ }
+ if fav != "" {
+ t.page.Favicon = fav
+ rewriteTabRow()
+ return
+ }
+
+ // No favicon cached
+ res, err := client.Fetch("gemini://" + host + "/favicon.txt")
+ if err != nil {
+ if res != nil {
+ res.Body.Close()
+ }
+ cache.AddFavicon(host, cache.KnownNoFavicon)
+ return
+ }
+ defer res.Body.Close()
+
+ if res.Status != 20 {
+ cache.AddFavicon(host, cache.KnownNoFavicon)
+ return
+ }
+ if !strings.HasPrefix(res.Meta, "text/") {
+ cache.AddFavicon(host, cache.KnownNoFavicon)
+ return
+ }
+ // It's a regular plain response
+
+ buf := new(bytes.Buffer)
+ _, err = io.CopyN(buf, res.Body, 29+2+1) // 29 is the max emoji length, +2 for CRLF, +1 so that the right size will EOF
+ if err == nil {
+ // Content was too large
+ cache.AddFavicon(host, cache.KnownNoFavicon)
+ return
+ } else if err != io.EOF {
+ // Some network reading error
+ // No favicon is NOT known, could be a temporary error
+ return
+ }
+ // EOF, which is what we want.
+ emoji := strings.TrimRight(buf.String(), "\r\n")
+ if !isemoji.IsEmoji(emoji) {
+ cache.AddFavicon(host, cache.KnownNoFavicon)
+ return
+ }
+ // Valid favicon found
+ t.page.Favicon = emoji
+ cache.AddFavicon(host, emoji)
+}
+
+// handleAbout can be called to deal with any URLs that start with
+// 'about:'. It will display errors if the URL is not recognized,
+// but not display anything if an 'about:' URL is not passed.
+//
+// It does not add the displayed page to history.
+//
+// It returns the URL displayed, and a bool indicating if the provided
+// URL could be handled. The string returned will always be empty
+// if the bool is false.
+func handleAbout(t *tab, u string) (string, bool) {
+ if !strings.HasPrefix(u, "about:") {
+ return "", false
+ }
+
+ switch u {
+ case "about:bookmarks":
+ Bookmarks(t)
+ return u, true
+ case "about:newtab":
+ temp := newTabPage // Copy
+ setPage(t, &temp)
+ t.applyBottomBar()
+ return u, true
+ case "about:version":
+ temp := versionPage
+ setPage(t, &temp)
+ t.applyBottomBar()
+ return u, true
+ }
+
+ if u == "about:subscriptions" || (len(u) > 20 && u[:20] == "about:subscriptions?") {
+ // about:subscriptions?2 views page 2
+ return Subscriptions(t, u), true
+ }
+ if u == "about:manage-subscriptions" || (len(u) > 27 && u[:27] == "about:manage-subscriptions?") {
+ ManageSubscriptions(t, u)
+ // Don't count remove command in history
+ if u == "about:manage-subscriptions" {
+ return u, true
+ }
+ return "", false
+ }
+
+ Error("Error", "Not a valid 'about:' URL.")
+ return "", false
+}
+
+// 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.
+//
+// The bottomBar is not actually changed in this func, except during loading.
+// The func that calls this one should apply the bottomBar values if necessary.
+//
+// numRedirects is the number of redirects that resulted in the provided URL.
+// It should typically be 0.
+func handleURL(t *tab, u string, numRedirects int) (string, bool) {
+ defer App.Draw() // Just in case
+
+ // Save for resetting on error
+ oldLable := t.barLabel
+ oldText := t.barText
+
+ // Custom return function
+ ret := func(s string, b bool) (string, bool) {
+ if !b {
+ // Reset bottomBar if page wasn't loaded
+ t.barLabel = oldLable
+ t.barText = oldText
+ }
+ t.mode = tabModeDone
+
+ go func(p *structs.Page) {
+ if b && t.hasContent() && viper.GetBool("subscriptions.popup") {
+ // The current page might be an untracked feed, and the user wants
+ // to be notified in such cases.
+
+ feed, isFeed := getFeedFromPage(p)
+ if isFeed && isValidTab(t) && t.page == p {
+ // After parsing and track-checking time, the page is still being displayed
+ addFeedDirect(p.URL, feed, subscriptions.IsSubscribed(p.URL))
+ }
+ }
+ }(t.page)
+
+ return s, b
+ }
+
+ t.barLabel = ""
+ bottomBar.SetLabel("")
+
+ App.SetFocus(t.view)
+
+ if strings.HasPrefix(u, "about:") {
+ return ret(handleAbout(t, u))
+ }
+
+ u = normalizeURL(u)
+ u = cache.Redirect(u)
+
+ parsed, err := url.Parse(u)
+ if err != nil {
+ Error("URL Error", err.Error())
+ return ret("", false)
+ }
+
+ proxy := strings.TrimSpace(viper.GetString("proxies." + parsed.Scheme))
+ usingProxy := false
+
+ proxyHostname, proxyPort, err := net.SplitHostPort(proxy)
+ if err != nil {
+ // Error likely means there's no port in the host
+ proxyHostname = proxy
+ proxyPort = "1965"
+ }
+
+ if strings.HasPrefix(u, "http") {
+ if proxy == "" || proxy == "off" {
+ // No proxy available
+ handleHTTP(u, true)
+ return ret("", false)
+ }
+ usingProxy = true
+ }
+
+ if strings.HasPrefix(u, "file") {
+ page, ok := handleFile(u)
+ if !ok {
+ return ret("", false)
+ }
+ setPage(t, page)
+ return ret(u, true)
+ }
+
+ if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") && !strings.HasPrefix(u, "file") {
+ // Not a Gemini URL
+ if proxy == "" || proxy == "off" {
+ // No proxy available
+ handleOther(u)
+ return ret("", false)
+ }
+ usingProxy = true
+ }
+
+ // Gemini URL, or one with a Gemini proxy available
+
+ // Load page from cache if it exists,
+ // and this isn't a page that was redirected to by the server (indicates dynamic content)
+ if numRedirects == 0 {
+ page, ok := cache.GetPage(u)
+ if ok {
+ setPage(t, page)
+ return ret(u, true)
+ }
+ }
+ // Otherwise download it
+ bottomBar.SetText("Loading...")
+ t.barText = "Loading..." // Save it too, in case the tab switches during loading
+ t.mode = tabModeLoading
+ App.Draw()
+
+ var res *gemini.Response
+ if usingProxy {
+ res, err = client.FetchWithProxy(proxyHostname, proxyPort, u)
+ } else {
+ res, err = client.Fetch(u)
+ }
+
+ // Loading may have taken a while, make sure tab is still valid
+ if !isValidTab(t) {
+ return ret("", false)
+ }
+
+ if errors.Is(err, client.ErrTofu) {
+ if usingProxy {
+ // They are using a proxy
+ if Tofu(proxy, client.GetExpiry(proxyHostname, proxyPort)) {
+ // They want to continue anyway
+ client.ResetTofuEntry(proxyHostname, proxyPort, res.Cert)
+ // Response can be used further down, no need to reload
+ } else {
+ // They don't want to continue
+ return ret("", false)
+ }
+ } else {
+ if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
+ // They want to continue anyway
+ client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
+ // Response can be used further down, no need to reload
+ } else {
+ // They don't want to continue
+ return ret("", false)
+ }
+ }
+ } else if err != nil {
+ Error("URL Fetch Error", err.Error())
+ return ret("", false)
+ }
+
+ // Fetch happened successfully, use RestartReader to buffer read data
+ res.Body = rr.NewRestartReader(res.Body)
+
+ if renderer.CanDisplay(res) {
+ page, err := renderer.MakePage(u, res, textWidth(), leftMargin(), usingProxy)
+ // Rendering may have taken a while, make sure tab is still valid
+ if !isValidTab(t) {
+ return ret("", false)
+ }
+
+ if errors.Is(err, renderer.ErrTooLarge) {
+ // Downloading now
+ // Disable read timeout and go back to start
+ res.SetReadTimeout(0) //nolint: errcheck
+ res.Body.(*rr.RestartReader).Restart()
+ go dlChoice("That page is too large. What would you like to do?", u, res)
+ return ret("", false)
+ }
+ if errors.Is(err, renderer.ErrTimedOut) {
+ // Downloading now
+ // Disable read timeout and go back to start
+ res.SetReadTimeout(0) //nolint: errcheck
+ res.Body.(*rr.RestartReader).Restart()
+ go dlChoice("Loading that page timed out. What would you like to do?", u, res)
+ return ret("", false)
+ }
+ if err != nil {
+ Error("Page Error", "Issuing creating page: "+err.Error())
+ return ret("", false)
+ }
+
+ page.Width = termW
+
+ if !client.HasClientCert(parsed.Host) {
+ // Don't cache pages with client certs
+ go cache.AddPage(page)
+ }
+
+ setPage(t, page)
+ return ret(u, true)
+ }
+ // Not displayable
+ // Could be a non 20 status code, or a different kind of document
+
+ // Handle each status code
+ switch res.Status {
+ case 10, 11:
+ userInput, ok := Input(res.Meta)
+ if ok {
+ // Make another request with the query string added
+ parsed.RawQuery = gemini.QueryEscape(userInput)
+ if len(parsed.String()) > gemini.URLMaxLength {
+ Error("Input Error", "URL for that input would be too long.")
+ return ret("", false)
+ }
+ return ret(handleURL(t, parsed.String(), 0))
+ }
+ return ret("", false)
+ case 30, 31:
+ parsedMeta, err := url.Parse(res.Meta)
+ if err != nil {
+ Error("Redirect Error", "Invalid URL: "+err.Error())
+ return ret("", false)
+ }
+ redir := parsed.ResolveReference(parsedMeta).String()
+ // Prompt before redirecting to non-Gemini protocol
+ redirect := false
+ if !strings.HasPrefix(redir, "gemini") {
+ if YesNo("Follow redirect to non-Gemini URL?\n" + redir) {
+ redirect = true
+ } else {
+ return ret("", false)
+ }
+ }
+ // Prompt before redirecting
+ autoRedirect := viper.GetBool("a-general.auto_redirect")
+ if redirect || (autoRedirect && numRedirects < 5) || YesNo("Follow redirect?\n"+redir) {
+ if res.Status == gemini.StatusRedirectPermanent {
+ go cache.AddRedir(u, redir)
+ }
+ return ret(handleURL(t, redir, numRedirects+1))
+ }
+ return ret("", false)
+ case 40:
+ Error("Temporary Failure", escapeMeta(res.Meta))
+ return ret("", false)
+ case 41:
+ Error("Server Unavailable", escapeMeta(res.Meta))
+ return ret("", false)
+ case 42:
+ Error("CGI Error", escapeMeta(res.Meta))
+ return ret("", false)
+ case 43:
+ Error("Proxy Failure", escapeMeta(res.Meta))
+ return ret("", false)
+ case 44:
+ Error("Slow Down", "You should wait "+escapeMeta(res.Meta)+" seconds before making another request.")
+ return ret("", false)
+ case 50:
+ Error("Permanent Failure", escapeMeta(res.Meta))
+ return ret("", false)
+ case 51:
+ Error("Not Found", escapeMeta(res.Meta))
+ return ret("", false)
+ case 52:
+ Error("Gone", escapeMeta(res.Meta))
+ return ret("", false)
+ case 53:
+ Error("Proxy Request Refused", escapeMeta(res.Meta))
+ return ret("", false)
+ case 59:
+ Error("Bad Request", escapeMeta(res.Meta))
+ return ret("", false)
+ case 60:
+ Error("Client Certificate Required", escapeMeta(res.Meta))
+ return ret("", false)
+ case 61:
+ Error("Certificate Not Authorised", escapeMeta(res.Meta))
+ return ret("", false)
+ case 62:
+ Error("Certificate Not Valid", escapeMeta(res.Meta))
+ return ret("", false)
+ }
+
+ // Status code 20, but not a document that can be displayed
+
+ // First see if it's a feed, and ask the user about adding it if it is
+ filename := path.Base(parsed.Path)
+ mediatype, _, _ := mime.ParseMediaType(res.Meta)
+ feed, ok := subscriptions.GetFeed(mediatype, filename, res.Body)
+ if ok {
+ go func() {
+ added := addFeedDirect(u, feed, subscriptions.IsSubscribed(u))
+ if !added {
+ // Otherwise offer download choices
+ // Disable read timeout and go back to start
+ res.SetReadTimeout(0) //nolint: errcheck
+ res.Body.(*rr.RestartReader).Restart()
+ go dlChoice("That file could not be displayed. What would you like to do?", u, res)
+ }
+ }()
+ return ret("", false)
+ }
+
+ // Otherwise offer download choices
+ // Disable read timeout and go back to start
+ res.SetReadTimeout(0) //nolint: errcheck
+ res.Body.(*rr.RestartReader).Restart()
+ go dlChoice("That file could not be displayed. What would you like to do?", u, res)
+ return ret("", false)
+}
diff --git a/display/help.go b/display/help.go
index 9ceca12..8c9d1aa 100644
--- a/display/help.go
+++ b/display/help.go
@@ -4,7 +4,6 @@ import (
"fmt"
"strconv"
"strings"
- "text/tabwriter"
"github.com/gdamore/tcell/v2"
"github.com/makeworld-the-better-one/amfora/config"
@@ -15,39 +14,39 @@ var helpCells = strings.TrimSpace(`
?|Bring up this help. You can scroll!
Esc|Leave the help
Arrow keys, h/j/k/l|Scroll and move a page.
-PgUp, u|Go up a page in document
-PgDn, d|Go down a page in document
+%s|Go up a page in document
+%s|Go down a page in document
g|Go to top of document
G|Go to bottom of document
Tab|Navigate to the next item in a popup.
Shift-Tab|Navigate to the previous item in a popup.
-b, Alt-Left|Go back in the history
-f, Alt-Right|Go forward in the history
-spacebar|Open bar at the bottom - type a URL, link number, search term.
+%s|Go back in the history
+%s|Go forward in the history
+%s|Open bar at the bottom - type a URL, link number, search term.
|You can also type two dots (..) to go up a directory in the URL.
|Typing new:N will open link number N in a new tab
|instead of the current one.
-Numbers|Go to links 1-10 respectively.
-e|Edit current URL
+%s|Go to links 1-10 respectively.
+%s|Edit current URL
Enter, Tab|On a page this will start link highlighting.
|Press Tab and Shift-Tab to pick different links.
|Press Enter again to go to one, or Esc to stop.
-Shift-NUMBER|Go to a specific tab.
-Shift-0, )|Go to the last tab.
-F1|Previous tab
-F2|Next tab
-Ctrl-H|Go home
-Ctrl-T|New tab, or if a link is selected,
+%s|Go to a specific tab. (Default: Shift-NUMBER)
+%s|Go to the last tab.
+%s|Previous tab
+%s|Next tab
+%s|Go home
+%s|New tab, or if a link is selected,
|this will open the link in a new tab.
-Ctrl-W|Close tab. For now, only the right-most tab can be closed.
-Ctrl-R, R|Reload a page, discarding the cached version.
+%s|Close tab. For now, only the right-most tab can be closed.
+%s|Reload a page, discarding the cached version.
|This can also be used if you resize your terminal.
-Ctrl-B|View bookmarks
-Ctrl-D|Add, change, or remove a bookmark for the current page.
-Ctrl-S|Save the current page to your downloads.
-q, Ctrl-Q|Quit
-Ctrl-C|Hard quit. This can be used when in the middle of downloading,
-|for example.
+%s|View bookmarks
+%s|Add, change, or remove a bookmark for the current page.
+%s|Save the current page to your downloads.
+%s|View subscriptions
+%s|Add or update a subscription
+%s|Quit
`)
var helpTable = cview.NewTextView()
@@ -74,12 +73,63 @@ func helpInit() {
App.Draw()
}
})
- lines := strings.Split(helpCells, "\n")
- w := tabwriter.NewWriter(helpTable, 0, 8, 2, ' ', 0)
- for i, line := range lines {
- cells := strings.Split(line, "|")
- if i > 0 && len(cells[0]) > 0 {
- fmt.Fprintln(w, "\t")
+
+ tabKeys := fmt.Sprintf("%s to %s", strings.Split(config.GetKeyBinding(config.CmdTab1), ",")[0],
+ strings.Split(config.GetKeyBinding(config.CmdTab9), ",")[0])
+ linkKeys := fmt.Sprintf("%s to %s", strings.Split(config.GetKeyBinding(config.CmdLink1), ",")[0],
+ strings.Split(config.GetKeyBinding(config.CmdLink0), ",")[0])
+
+ helpCells = fmt.Sprintf(helpCells,
+ config.GetKeyBinding(config.CmdPgup),
+ config.GetKeyBinding(config.CmdPgdn),
+ config.GetKeyBinding(config.CmdBack),
+ config.GetKeyBinding(config.CmdForward),
+ config.GetKeyBinding(config.CmdBottom),
+ linkKeys,
+ config.GetKeyBinding(config.CmdEdit),
+ tabKeys,
+ config.GetKeyBinding(config.CmdTab0),
+ config.GetKeyBinding(config.CmdPrevTab),
+ config.GetKeyBinding(config.CmdNextTab),
+ config.GetKeyBinding(config.CmdHome),
+ config.GetKeyBinding(config.CmdNewTab),
+ config.GetKeyBinding(config.CmdCloseTab),
+ config.GetKeyBinding(config.CmdReload),
+ config.GetKeyBinding(config.CmdBookmarks),
+ config.GetKeyBinding(config.CmdAddBookmark),
+ config.GetKeyBinding(config.CmdSave),
+ config.GetKeyBinding(config.CmdSub),
+ config.GetKeyBinding(config.CmdAddSub),
+ config.GetKeyBinding(config.CmdQuit),
+ )
+
+ rows := strings.Count(helpCells, "\n") + 1
+ cells := strings.Split(
+ strings.ReplaceAll(helpCells, "\n", "|"),
+ "|")
+ cell := 0
+ extraRows := 0 // Rows continued from the previous, without spacing
+ for r := 0; r < rows; r++ {
+ for c := 0; c < 2; c++ {
+ var tableCell *cview.TableCell
+ if c == 0 {
+ // First column, the keybinding
+ tableCell = cview.NewTableCell(" " + cells[cell]).
+ SetAttributes(tcell.AttrBold).
+ SetAlign(cview.AlignLeft)
+ } else {
+ tableCell = cview.NewTableCell(" " + cells[cell])
+ }
+ if c == 0 && cells[cell] == "" || (cell > 0 && cells[cell-1] == "" && c == 1) {
+ // The keybinding column for this row was blank, meaning the explanation
+ // column is continued from the previous row.
+ // The row should be added without any spacing rows
+ helpTable.SetCell(((2*r)-extraRows/2)-1, c, tableCell)
+ extraRows++
+ } else {
+ helpTable.SetCell((2*r)-extraRows/2, c, tableCell) // Every other row, for readability
+ }
+ cell++
}
fmt.Fprintf(w, "%s\t%s\n", cells[0], cells[1])
}
diff --git a/display/newtab.go b/display/newtab.go
index e1755c5..433ebb2 100644
--- a/display/newtab.go
+++ b/display/newtab.go
@@ -17,10 +17,18 @@ You can customize this page by creating a gemtext file called newtab.gmi, in Amf
Happy browsing!
+## Internal Pages
+
=> about:bookmarks Bookmarks
+=> about:subscriptions Subscriptions
+
+## Learn more about Amfora!
+
+=> https://github.com/makeworld-the-better-one/amfora Amfora homepage
+=> https://github.com/makeworld-the-better-one/amfora/wiki Amfora Wiki [GitHub]
+=> gemini://makeworld.gq/amfora-wiki/ Amfora Wiki [On Gemini!]
=> //gemini.circumlunar.space Project Gemini
-=> https://github.com/makeworld-the-better-one/amfora Amfora homepage [HTTPS]
`
// Read the new tab content from a file if it exists or fallback to a default page.
diff --git a/display/private.go b/display/private.go
index 6ee915b..1ead234 100644
--- a/display/private.go
+++ b/display/private.go
@@ -1,24 +1,15 @@
package display
import (
- "bytes"
- "errors"
- "io"
- "net"
+ "fmt"
"net/url"
- "os/exec"
+ "strconv"
"strings"
- "github.com/makeworld-the-better-one/amfora/cache"
- "github.com/makeworld-the-better-one/amfora/client"
"github.com/makeworld-the-better-one/amfora/config"
"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/makeworld-the-better-one/go-isemoji"
"github.com/spf13/viper"
- "gitlab.com/tslocum/cview"
)
// This file contains the functions that aren't part of the public API.
@@ -28,15 +19,10 @@ import (
// Not when a URL is opened on a new tab for the first time.
// It will handle setting the bottomBar.
func followLink(t *tab, prev, next string) {
-
- // Copied from URL()
- if next == "about:bookmarks" {
- Bookmarks(t)
- t.addToHistory("about:bookmarks")
- return
- }
if strings.HasPrefix(next, "about:") {
- Error("Error", "Not a valid 'about:' URL for linking")
+ if final, ok := handleAbout(t, next); ok {
+ t.addToHistory(final)
+ }
return
}
@@ -77,7 +63,9 @@ func reformatPage(p *structs.Page) {
case structs.TextGemini:
// Links are not recorded because they won't change
proxied := true
- if strings.HasPrefix(p.URL, "gemini") || strings.HasPrefix(p.URL, "about") {
+ if strings.HasPrefix(p.URL, "gemini") ||
+ strings.HasPrefix(p.URL, "about") ||
+ strings.HasPrefix(p.URL, "file") {
proxied = false
}
rendered, _ = renderer.RenderGemini(p.Raw, textWidth(), leftMargin(), proxied)
@@ -141,146 +129,6 @@ func setPage(t *tab, p *structs.Page) {
t.barText = p.URL
}
-// handleHTTP is used by handleURL.
-// It opens HTTP links and displays Info and Error modals.
-// Returns false if there was an error.
-func handleHTTP(u string, showInfo bool) bool {
- if len(config.HTTPCommand) == 1 {
- // Possibly a non-command
-
- switch strings.TrimSpace(config.HTTPCommand[0]) {
- case "", "off":
- Error("HTTP Error", "Opening HTTP URLs is turned off.")
- return false
- case "default":
- s, err := webbrowser.Open(u)
- if err != nil {
- Error("Webbrowser Error", err.Error())
- return false
- }
- if showInfo {
- Info(s)
- }
- return true
- }
- }
-
- // Custom command
- var err error = nil
- if len(config.HTTPCommand) > 1 {
- err = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...).Start()
- } else {
- err = exec.Command(config.HTTPCommand[0], u).Start()
- }
- if err != nil {
- Error("HTTP Error", "Error executing custom browser command: "+err.Error())
- return false
- }
-
- App.Draw()
- return true
-}
-
-// handleOther is used by handleURL.
-// It opens links other than Gemini and HTTP and displays Error modals.
-func handleOther(u string) {
- // The URL should have a scheme due to a previous call to normalizeURL
- parsed, _ := url.Parse(u)
-
- // Search for a handler for the URL scheme
- handler := strings.TrimSpace(viper.GetString("url-handlers." + parsed.Scheme))
- if len(handler) == 0 {
- handler = strings.TrimSpace(viper.GetString("url-handlers.other"))
- }
- switch handler {
- case "", "off":
- Error("URL Error", "Opening "+parsed.Scheme+" URLs is turned off.")
- default:
- // The config has a custom command to execute for URLs
- fields := strings.Fields(handler)
- err := exec.Command(fields[0], append(fields[1:], u)...).Start()
- if err != nil {
- Error("URL Error", "Error executing custom command: "+err.Error())
- }
- }
- App.Draw()
-}
-
-// handleFavicon handles getting and displaying a favicon.
-// `old` is the previous favicon for the tab.
-func handleFavicon(t *tab, host, old string) {
- defer func() {
- // Update display if needed
- if t.page.Favicon != old && isValidTab(t) {
- // TODO update browser tab label
- }
- }()
-
- if !viper.GetBool("a-general.emoji_favicons") {
- // Not enabled
- return
- }
- if t.page.Favicon != "" {
- return
- }
- if host == "" {
- return
- }
-
- fav := cache.GetFavicon(host)
- if fav == cache.KnownNoFavicon {
- // It's been cached that this host doesn't have a favicon
- return
- }
- if fav != "" {
- t.page.Favicon = fav
- // TODO update browser tab label
- return
- }
-
- // No favicon cached
- res, err := client.Fetch("gemini://" + host + "/favicon.txt")
- if err != nil {
- if res != nil {
- res.Body.Close()
- }
- cache.AddFavicon(host, cache.KnownNoFavicon)
- return
- }
- defer res.Body.Close()
-
- if res.Status != 20 {
- cache.AddFavicon(host, cache.KnownNoFavicon)
- return
- }
- if !strings.HasPrefix(res.Meta, "text/") {
- cache.AddFavicon(host, cache.KnownNoFavicon)
- return
- }
- // It's a regular plain response
-
- buf := new(bytes.Buffer)
- _, err = io.CopyN(buf, res.Body, 29+2+1) // 29 is the max emoji length, +2 for CRLF, +1 so that the right size will EOF
- if err == nil {
- // Content was too large
- cache.AddFavicon(host, cache.KnownNoFavicon)
- return
- } else if err != io.EOF {
- // Some network reading error
- // No favicon is NOT known, could be a temporary error
- return
- }
- // EOF, which is what we want.
- emoji := strings.TrimRight(buf.String(), "\r\n")
- if !isemoji.IsEmoji(emoji) {
- cache.AddFavicon(host, cache.KnownNoFavicon)
- return
- }
- // Valid favicon found
- t.page.Favicon = emoji
- cache.AddFavicon(host, emoji)
-}
-
// goURL is like handleURL, but takes care of history and the bottomBar.
// It should be preferred over handleURL in most cases.
// It has no return values to be processed.
@@ -297,269 +145,31 @@ func goURL(t *tab, u string) {
}
}
-// 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.
-//
-// The bottomBar is not actually changed in this func, except during loading.
-// The func that calls this one should apply the bottomBar values if necessary.
-//
-// numRedirects is the number of redirects that resulted in the provided URL.
-// It should typically be 0.
-func handleURL(t *tab, u string, numRedirects int) (string, bool) {
- defer App.Draw() // Just in case
-
- // Save for resetting on error
- oldLable := t.barLabel
- oldText := t.barText
-
- // Custom return function
- ret := func(s string, b bool) (string, bool) {
- if !b {
- // Reset bottomBar if page wasn't loaded
- t.barLabel = oldLable
- t.barText = oldText
+// rewriteTabRow clears the tabRow and writes all the tabs number/favicons into it.
+func rewriteTabRow() {
+ tabRow.Clear()
+ if viper.GetBool("a-general.color") {
+ for i := 0; i < NumTabs(); i++ {
+ char := strconv.Itoa(i + 1)
+ if tabs[i].page.Favicon != "" {
+ char = tabs[i].page.Favicon
+ }
+ fmt.Fprintf(tabRow, `["%d"][%s] %s [%s][""]|`,
+ i,
+ config.GetColorString("tab_num"),
+ char,
+ config.GetColorString("tab_divider"),
+ )
}
- t.mode = tabModeDone
- return s, b
- }
-
- t.barLabel = ""
- bottomBar.SetLabel("")
-
- App.SetFocus(t.view)
-
- // To allow linking to the bookmarks page, and history browsing
- if u == "about:bookmarks" {
- Bookmarks(t)
- return ret("about:bookmarks", true)
- }
-
- u = normalizeURL(u)
- u = cache.Redirect(u)
-
- parsed, err := url.Parse(u)
- if err != nil {
- Error("URL Error", err.Error())
- return ret("", false)
- }
-
- proxy := strings.TrimSpace(viper.GetString("proxies." + parsed.Scheme))
- usingProxy := false
-
- proxyHostname, proxyPort, err := net.SplitHostPort(proxy)
- if err != nil {
- // Error likely means there's no port in the host
- proxyHostname = proxy
- proxyPort = "1965"
- }
-
- if strings.HasPrefix(u, "http") {
- if proxy == "" || proxy == "off" {
- // No proxy available
- handleHTTP(u, true)
- return ret("", false)
- }
- usingProxy = true
- }
-
- if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") {
- // Not a Gemini URL
- if proxy == "" || proxy == "off" {
- // No proxy available
- handleOther(u)
- return ret("", false)
- }
- usingProxy = true
- }
-
- // Gemini URL, or one with a Gemini proxy available
-
- // Load page from cache if it exists,
- // and this isn't a page that was redirected to by the server (indicates dynamic content)
- if numRedirects == 0 {
- page, ok := cache.GetPage(u)
- if ok {
- setPage(t, page)
- return ret(u, true)
- }
- }
- // Otherwise download it
- bottomBar.SetText("Loading...")
- t.barText = "Loading..." // Save it too, in case the tab switches during loading
- t.mode = tabModeLoading
- App.Draw()
-
- var res *gemini.Response
- if usingProxy {
- res, err = client.FetchWithProxy(proxyHostname, proxyPort, u)
} else {
- res, err = client.Fetch(u)
+ for i := 0; i < NumTabs(); i++ {
+ char := strconv.Itoa(i + 1)
+ if tabs[i].page.Favicon != "" {
+ char = tabs[i].page.Favicon
+ }
+ fmt.Fprintf(tabRow, `["%d"] %s [""]|`, i, char)
+ }
}
-
- // Loading may have taken a while, make sure tab is still valid
- if !isValidTab(t) {
- return ret("", false)
- }
-
- if errors.Is(err, client.ErrTofu) {
- if usingProxy {
- // They are using a proxy
- if Tofu(proxy, client.GetExpiry(proxyHostname, proxyPort)) {
- // They want to continue anyway
- client.ResetTofuEntry(proxyHostname, proxyPort, res.Cert)
- // Response can be used further down, no need to reload
- } else {
- // They don't want to continue
- return ret("", false)
- }
- } else {
- if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
- // They want to continue anyway
- client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
- // Response can be used further down, no need to reload
- } else {
- // They don't want to continue
- return ret("", false)
- }
- }
- } else if err != nil {
- Error("URL Fetch Error", err.Error())
- return ret("", false)
- }
- if renderer.CanDisplay(res) {
- page, err := renderer.MakePage(u, res, textWidth(), leftMargin(), usingProxy)
- // Rendering may have taken a while, make sure tab is still valid
- if !isValidTab(t) {
- return ret("", false)
- }
-
- if errors.Is(err, renderer.ErrTooLarge) {
- // Make new request for downloading purposes
- res, clientErr := client.Fetch(u)
- if clientErr != nil && !errors.Is(clientErr, client.ErrTofu) {
- Error("URL Fetch Error", err.Error())
- return ret("", false)
- }
- go dlChoice("That page is too large. What would you like to do?", u, res)
- return ret("", false)
- }
- if errors.Is(err, renderer.ErrTimedOut) {
- // Make new request for downloading purposes
- res, clientErr := client.Fetch(u)
- if clientErr != nil && !errors.Is(clientErr, client.ErrTofu) {
- Error("URL Fetch Error", err.Error())
- return ret("", false)
- }
- go dlChoice("Loading that page timed out. What would you like to do?", u, res)
- return ret("", false)
- }
- if err != nil {
- Error("Page Error", "Issuing creating page: "+err.Error())
- return ret("", false)
- }
-
- page.Width = termW
-
- if !client.HasClientCert(parsed.Host) {
- // Don't cache pages with client certs
- go cache.AddPage(page)
- }
-
- setPage(t, page)
- return ret(u, true)
- }
- // Not displayable
- // Could be a non 20 (or 21) status code, or a different kind of document
-
- // Handle each status code
- switch res.Status {
- case 10, 11:
- 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 = gemini.QueryEscape(userInput)
- if len(parsed.String()) > gemini.URLMaxLength {
- Error("Input Error", "URL for that input would be too long.")
- return ret("", false)
- }
- return ret(handleURL(t, parsed.String(), 0))
- }
- return ret("", false)
- case 30, 31:
- parsedMeta, err := url.Parse(res.Meta)
- if err != nil {
- Error("Redirect Error", "Invalid URL: "+err.Error())
- return ret("", false)
- }
- redir := parsed.ResolveReference(parsedMeta).String()
- // Prompt before redirecting to non-Gemini protocol
- redirect := false
- if !strings.HasPrefix(redir, "gemini") {
- if YesNo("Follow redirect to non-Gemini URL?\n" + redir) {
- redirect = true
- } else {
- return ret("", false)
- }
- }
- // Prompt before redirecting
- autoRedirect := viper.GetBool("a-general.auto_redirect")
- if redirect || (autoRedirect && numRedirects < 5) || YesNo("Follow redirect?\n"+redir) {
- if res.Status == gemini.StatusRedirectPermanent {
- go cache.AddRedir(u, redir)
- }
- return ret(handleURL(t, redir, numRedirects+1))
- }
- return ret("", false)
- case 40:
- Error("Temporary Failure", cview.Escape(res.Meta))
- return ret("", false)
- case 41:
- Error("Server Unavailable", cview.Escape(res.Meta))
- return ret("", false)
- case 42:
- Error("CGI Error", cview.Escape(res.Meta))
- return ret("", false)
- case 43:
- Error("Proxy Failure", cview.Escape(res.Meta))
- return ret("", false)
- case 44:
- Error("Slow Down", "You should wait "+cview.Escape(res.Meta)+" seconds before making another request.")
- return ret("", false)
- case 50:
- Error("Permanent Failure", cview.Escape(res.Meta))
- return ret("", false)
- case 51:
- Error("Not Found", cview.Escape(res.Meta))
- return ret("", false)
- case 52:
- Error("Gone", cview.Escape(res.Meta))
- return ret("", false)
- case 53:
- Error("Proxy Request Refused", cview.Escape(res.Meta))
- return ret("", false)
- case 59:
- Error("Bad Request", cview.Escape(res.Meta))
- return ret("", false)
- case 60:
- Error("Client Certificate Required", cview.Escape(res.Meta))
- return ret("", false)
- case 61:
- Error("Certificate Not Authorised", cview.Escape(res.Meta))
- return ret("", false)
- case 62:
- Error("Certificate Not Valid", cview.Escape(res.Meta))
- return ret("", false)
- }
-
- // Status code 20, but not a document that can be displayed
- go dlChoice("That file could not be displayed. What would you like to do?", u, res)
- return ret("", false)
+ tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight()
+ App.Draw()
}
diff --git a/display/subscriptions.go b/display/subscriptions.go
new file mode 100644
index 0000000..7c5ffbe
--- /dev/null
+++ b/display/subscriptions.go
@@ -0,0 +1,328 @@
+package display
+
+import (
+ "fmt"
+ "net/url"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell"
+ "github.com/makeworld-the-better-one/amfora/cache"
+ "github.com/makeworld-the-better-one/amfora/config"
+ "github.com/makeworld-the-better-one/amfora/renderer"
+ "github.com/makeworld-the-better-one/amfora/structs"
+ "github.com/makeworld-the-better-one/amfora/subscriptions"
+ "github.com/makeworld-the-better-one/go-gemini"
+ "github.com/mmcdole/gofeed"
+ "github.com/spf13/viper"
+)
+
+// Map page number (zero-indexed) to the time it was made at.
+// This allows for caching the pages until there's an update.
+var subscriptionPageUpdated = make(map[int]time.Time)
+
+// toLocalDay truncates the provided time to a date only,
+// but converts to the local time first.
+func toLocalDay(t time.Time) time.Time {
+ t = t.Local()
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+}
+
+// Subscriptions displays the subscriptions page on the current tab.
+func Subscriptions(t *tab, u string) string {
+ pageN := 0 // Pages are zero-indexed internally
+
+ // Correct URL if query string exists
+ // The only valid query string is an int above 1.
+ // Anything "redirects" to the first page, with no query string.
+ // This is done over just serving the first page content for
+ // invalid query strings so that there won't be duplicate caches.
+ correctURL := func(u2 string) string {
+ if len(u2) > 20 && u2[:20] == "about:subscriptions?" {
+ query, err := gemini.QueryUnescape(u2[20:])
+ if err != nil {
+ return "about:subscriptions"
+ }
+ // Valid query string
+ i, err := strconv.Atoi(query)
+ if err != nil {
+ // Not an int
+ return "about:subscriptions"
+ }
+ if i < 2 {
+ return "about:subscriptions"
+ }
+ // Valid int above 1
+ pageN = i - 1 // Pages are zero-indexed internally
+ return u2
+ }
+ return u2
+ }
+ u = correctURL(u)
+
+ // Retrieve cached version if there hasn't been any updates
+ p, ok := cache.GetPage(u)
+ if subscriptionPageUpdated[pageN].After(subscriptions.LastUpdated) && ok {
+ setPage(t, p)
+ t.applyBottomBar()
+ return u
+ }
+
+ pe := subscriptions.GetPageEntries()
+
+ // Figure out where the entries for this page start, if at all.
+ epp := viper.GetInt("subscriptions.entries_per_page")
+ if epp <= 0 {
+ epp = 1
+ }
+ start := pageN * epp // Index of the first page entry to be displayed
+ end := start + epp
+ if end > len(pe.Entries) {
+ end = len(pe.Entries)
+ }
+
+ var rawPage string
+ if pageN == 0 {
+ rawPage = "# Subscriptions\n\n" + rawPage
+ } else {
+ rawPage = fmt.Sprintf("# Subscriptions (page %d)\n\n", pageN+1) + rawPage
+ }
+
+ if start > len(pe.Entries)-1 && len(pe.Entries) != 0 {
+ // The page is out of range, doesn't exist
+ rawPage += "This page does not exist.\n\n=> about:subscriptions Subscriptions\n"
+ } else {
+ // Render page
+
+ rawPage += "You can use Ctrl-X to subscribe to a page, or to an Atom/RSS/JSON feed. See the online wiki for more.\n" +
+ "If you just opened Amfora then updates may appear incrementally. Reload the page to see them.\n\n" +
+ "=> about:manage-subscriptions Manage subscriptions\n\n"
+
+ // curDay represents what day of posts the loop is on.
+ // It only goes backwards in time.
+ // Its initial setting means:
+ // Only display posts older than 26 hours in the future, nothing further in the future.
+ //
+ // 26 hours was chosen because it is the largest timezone difference
+ // currently in the world. Posts may be dated in the future
+ // due to software bugs, where the local user's date is used, but
+ // the UTC timezone is specified. Gemfeed does this at the time of
+ // writing, but will not after #3 gets merged on its repo. Still,
+ // the older version will be used for a while.
+ curDay := toLocalDay(time.Now()).Add(26 * time.Hour)
+
+ for _, entry := range pe.Entries[start:end] { // From new to old
+ // Convert to local time, remove sub-day info
+ pub := toLocalDay(entry.Published)
+
+ if pub.Before(curDay) {
+ // This post is on a new day, add a day header
+ curDay = pub
+ rawPage += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006"))
+ }
+ if entry.Title == "" || entry.Title == "/" {
+ // Just put author/title
+ // Mainly used for when you're tracking the root domain of a site
+ rawPage += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix)
+ } else {
+ // Include title and dash
+ rawPage += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title)
+ }
+ }
+
+ if pageN == 0 && len(pe.Entries) > epp {
+ // First page, and there's more than can fit
+ rawPage += "\n\n=> about:subscriptions?2 Next Page\n"
+ } else if pageN > 0 {
+ // A later page
+ rawPage += fmt.Sprintf(
+ "\n\n=> about:subscriptions?%d Previous Page\n",
+ pageN, // pageN is zero-indexed but the query string is one-indexed
+ )
+ if end != len(pe.Entries) {
+ // There's more
+ rawPage += fmt.Sprintf("=> about:subscriptions?%d Next Page\n", pageN+2)
+ }
+ }
+ }
+
+ content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
+ page := structs.Page{
+ Raw: rawPage,
+ Content: content,
+ Links: links,
+ URL: u,
+ Width: termW,
+ Mediatype: structs.TextGemini,
+ }
+ go cache.AddPage(&page)
+ setPage(t, &page)
+ t.applyBottomBar()
+
+ subscriptionPageUpdated[pageN] = time.Now()
+
+ return u
+}
+
+// ManageSubscriptions displays the subscription managing page in
+// the current tab. `u` is the URL entered by the user.
+func ManageSubscriptions(t *tab, u string) {
+ if len(u) > 27 && u[:27] == "about:manage-subscriptions?" {
+ // There's a query string, aka a URL to unsubscribe from
+ manageSubscriptionQuery(t, u)
+ return
+ }
+
+ rawPage := "# Manage Subscriptions\n\n" +
+ "Below is list of URLs you are subscribed to, both feeds and pages. " +
+ "Navigate to the link to unsubscribe from that feed or page.\n\n"
+
+ urls := subscriptions.AllURLS()
+ sort.Strings(urls)
+
+ for _, u2 := range urls {
+ rawPage += fmt.Sprintf(
+ "=>%s %s\n",
+ "about:manage-subscriptions?"+gemini.QueryEscape(u2),
+ u2,
+ )
+ }
+
+ content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
+ page := structs.Page{
+ Raw: rawPage,
+ Content: content,
+ Links: links,
+ URL: "about:manage-subscriptions",
+ Width: termW,
+ Mediatype: structs.TextGemini,
+ }
+ go cache.AddPage(&page)
+ setPage(t, &page)
+ t.applyBottomBar()
+}
+
+func manageSubscriptionQuery(t *tab, u string) {
+ sub, err := gemini.QueryUnescape(u[27:])
+ if err != nil {
+ Error("URL Error", "Invalid query string: "+err.Error())
+ return
+ }
+
+ err = subscriptions.Remove(sub)
+ if err != nil {
+ ManageSubscriptions(t, "about:manage-subscriptions") // Reload
+ Error("Save Error", "Error saving the unsubscription to disk: "+err.Error())
+ return
+ }
+ ManageSubscriptions(t, "about:manage-subscriptions") // Reload
+ Info("Unsubscribed from " + sub)
+}
+
+// openSubscriptionModal displays the "Add subscription" modal
+// It returns whether the user wanted to subscribe to feed/page.
+// The subscribed arg specifies whether this feed/page is already
+// subscribed to.
+func openSubscriptionModal(validFeed, subscribed bool) bool {
+ // Reuses yesNoModal
+
+ if viper.GetBool("a-general.color") {
+ yesNoModal.
+ SetBackgroundColor(config.GetColor("subscription_modal_bg")).
+ SetTextColor(config.GetColor("subscription_modal_text"))
+ yesNoModal.GetFrame().
+ SetBorderColor(config.GetColor("subscription_modal_text")).
+ SetTitleColor(config.GetColor("subscription_modal_text"))
+ } else {
+ yesNoModal.
+ SetBackgroundColor(tcell.ColorBlack).
+ SetTextColor(tcell.ColorWhite)
+ yesNoModal.GetFrame().
+ SetBorderColor(tcell.ColorWhite).
+ SetTitleColor(tcell.ColorWhite)
+ }
+ if validFeed {
+ yesNoModal.GetFrame().SetTitle("Feed Subscription")
+ if subscribed {
+ yesNoModal.SetText("You are already subscribed to this feed. Would you like to manually update it?")
+ } else {
+ yesNoModal.SetText("Would you like to subscribe to this feed?")
+ }
+ } else {
+ yesNoModal.GetFrame().SetTitle("Page Subscription")
+ if subscribed {
+ yesNoModal.SetText("You are already subscribed to this page. Would you like to manually update it?")
+ } else {
+ yesNoModal.SetText("Would you like to subscribe to this page?")
+ }
+ }
+
+ tabPages.ShowPage("yesno")
+ tabPages.SendToFront("yesno")
+ App.SetFocus(yesNoModal)
+ App.Draw()
+
+ resp := <-yesNoCh
+ tabPages.SwitchToPage(strconv.Itoa(curTab))
+ App.SetFocus(tabs[curTab].view)
+ App.Draw()
+ return resp
+}
+
+// getFeedFromPage is like subscriptions.GetFeed but takes a structs.Page as input.
+func getFeedFromPage(p *structs.Page) (*gofeed.Feed, bool) {
+ parsed, _ := url.Parse(p.URL)
+ filename := path.Base(parsed.Path)
+ r := strings.NewReader(p.Raw)
+ return subscriptions.GetFeed(p.RawMediatype, filename, r)
+}
+
+// addFeedDirect is only for adding feeds, not pages.
+// It's for when you already have a feed and know if it's tracked.
+// Used mainly by handleURL because it already did a lot of the work.
+// It returns a bool indicating whether the user actually wanted to
+// add the feed or not.
+//
+// Like addFeed, it should be called in a goroutine.
+func addFeedDirect(u string, feed *gofeed.Feed, tracked bool) bool {
+ if openSubscriptionModal(true, tracked) {
+ err := subscriptions.AddFeed(u, feed)
+ if err != nil {
+ Error("Feed Error", err.Error())
+ }
+ return true
+ }
+ return false
+}
+
+// addFeed goes through the process of subscribing to the current page/feed.
+// It is the high-level way of doing it. It should be called in a goroutine.
+func addSubscription() {
+ t := tabs[curTab]
+ p := t.page
+
+ if !t.hasContent() {
+ // It's an about: page, or a malformed one
+ return
+ }
+
+ feed, isFeed := getFeedFromPage(p)
+ tracked := subscriptions.IsSubscribed(p.URL)
+
+ if openSubscriptionModal(isFeed, tracked) {
+ var err error
+
+ if isFeed {
+ err = subscriptions.AddFeed(p.URL, feed)
+ } else {
+ err = subscriptions.AddPage(p.URL, strings.NewReader(p.Raw))
+ }
+
+ if err != nil {
+ Error("Feed/Page Error", err.Error())
+ }
+ }
+}
diff --git a/display/tab.go b/display/tab.go
index a73df8f..420e604 100644
--- a/display/tab.go
+++ b/display/tab.go
@@ -148,9 +148,8 @@ func (t *tab) pageDown() {
t.view.ScrollTo(row+(termH/4)*3, col)
}
-// hasContent returns true when the tab has a page that could be displayed.
-// The most likely situation where false would be returned is when the default
-// new tab content is being displayed.
+// hasContent returns false when the tab's page is malformed,
+// has no content or URL, or if it's an 'about:' page.
func (t *tab) hasContent() bool {
if t.page == nil || t.view == nil {
return false
diff --git a/display/util.go b/display/util.go
index e03fdd6..33495c1 100644
--- a/display/util.go
+++ b/display/util.go
@@ -3,12 +3,21 @@ package display
import (
"errors"
"net/url"
+ "strings"
+ "github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
+ "gitlab.com/tslocum/cview"
+ "golang.org/x/text/unicode/norm"
)
// This file contains funcs that are small, self-contained utilities.
+// escapeMeta santizes a META string for use within a cview modal.
+func escapeMeta(meta string) string {
+ return cview.Escape(strings.ReplaceAll(meta, "\n", ""))
+}
+
// isValidTab indicates whether the passed tab is still being used, even if it's not currently displayed.
func isValidTab(t *tab) bool {
tempTabs := tabs
@@ -66,15 +75,23 @@ func resolveRelLink(t *tab, prev, next string) (string, error) {
// Example: gemini://gus.guru:1965/ and //gus.guru/.
// This function will take both output the same URL each time.
//
+// It will also percent-encode invalid characters, and decode chars
+// that don't need to be encoded. It will also apply Unicode NFC
+// normalization.
+//
// 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)
+ u = norm.NFC.String(u)
+
+ tmp, err := gemini.GetPunycodeURL(u)
if err != nil {
return u
}
+ u = tmp
+ parsed, _ := url.Parse(u)
if parsed.Scheme == "" {
// Always add scheme
@@ -95,7 +112,32 @@ func normalizeURL(u string) string {
// gemini://example.com -> gemini://example.com/
if parsed.Path == "" {
parsed.Path = "/"
+ } else {
+ // Decode and re-encode path
+ // This removes needless encoding, like that of ASCII chars
+ // And encodes anything that wasn't but should've been
+ parsed.RawPath = strings.ReplaceAll(url.PathEscape(parsed.Path), "%2F", "/")
+ }
+
+ // Do the same to the query string
+ un, err := gemini.QueryUnescape(parsed.RawQuery)
+ if err == nil {
+ parsed.RawQuery = gemini.QueryEscape(un)
}
return parsed.String()
}
+
+// fixUserURL will take a user-typed URL and add a gemini scheme to it if
+// necessary. It is not the same as normalizeURL, and that func should still
+// be used, afterward.
+//
+// For example "example.com" will become "gemini://example.com", but
+// "//example.com" will be left untouched.
+func fixUserURL(u string) string {
+ if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") {
+ // Assume it's a Gemini URL
+ u = "gemini://" + u
+ }
+ return u
+}
diff --git a/display/util_test.go b/display/util_test.go
index 4cc3506..09309a8 100644
--- a/display/util_test.go
+++ b/display/util_test.go
@@ -1,3 +1,4 @@
+//nolint: lll
package display
import (
@@ -21,6 +22,11 @@ var normalizeURLTests = []struct {
{"mailto:example@example.com", "mailto:example@example.com"},
{"magnet:?xt=urn:btih:test", "magnet:?xt=urn:btih:test"},
{"https://example.com", "https://example.com"},
+ // Fixing URL tests
+ {"gemini://gemini.circumlunar.space/%64%6f%63%73/%66%61%71%2e%67%6d%69", "gemini://gemini.circumlunar.space/docs/faq.gmi"},
+ {"gemini://example.com/蛸", "gemini://example.com/%E8%9B%B8"},
+ {"gemini://gemini.circumlunar.space/%64%6f%63%73/;;.'%66%61%71蛸%2e%67%6d%69", "gemini://gemini.circumlunar.space/docs/%3B%3B.%27faq%E8%9B%B8.gmi"},
+ {"gemini://example.com/?%2Ch%64ello蛸", "gemini://example.com/?%2Chdello%E8%9B%B8"},
}
func TestNormalizeURL(t *testing.T) {
diff --git a/go.mod b/go.mod
index fd2d0cd..24c599e 100644
--- a/go.mod
+++ b/go.mod
@@ -5,25 +5,29 @@ go 1.14
require (
github.com/dustin/go-humanize v1.0.0
github.com/fsnotify/fsnotify v1.4.9 // indirect
+ github.com/gdamore/tcell v1.4.0
github.com/gdamore/tcell/v2 v2.1.0
github.com/google/go-cmp v0.5.0 // indirect
- github.com/magiconair/properties v1.8.4 // indirect
- github.com/makeworld-the-better-one/go-gemini v0.9.0
+ github.com/makeworld-the-better-one/go-gemini v0.11.0
github.com/makeworld-the-better-one/go-isemoji v1.1.0
- github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f
github.com/mitchellh/go-homedir v1.1.0
- github.com/mitchellh/mapstructure v1.3.3 // indirect
- github.com/pelletier/go-toml v1.8.1 // indirect
+ github.com/mitchellh/mapstructure v1.3.1 // indirect
+ github.com/mmcdole/gofeed v1.1.0
+ github.com/pelletier/go-toml v1.8.0 // indirect
github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b
- github.com/spf13/afero v1.4.1 // indirect
+ github.com/schollz/progressbar/v3 v3.7.2
+ 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.1
github.com/stretchr/testify v1.6.1
gitlab.com/tslocum/cview v1.5.3-0.20201215184006-1af0da7606b8
- golang.org/x/text v0.3.4
+ golang.org/x/text v0.3.5-0.20201208001344-75a595aef632
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
- gopkg.in/yaml.v2 v2.3.0 // indirect
)
+
+replace github.com/mmcdole/gofeed => github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe
+
+replace github.com/schollz/progressbar/v3 => github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568
diff --git a/go.sum b/go.sum
index 056be89..24cb67a 100644
--- a/go.sum
+++ b/go.sum
@@ -16,8 +16,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
+github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/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/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
+github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
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=
@@ -32,6 +36,7 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
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/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
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=
@@ -45,6 +50,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
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.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
+github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
github.com/gdamore/tcell/v2 v2.0.0-dev/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
github.com/gdamore/tcell/v2 v2.1.0 h1:UnSmozHgBkQi2PGsFr+rpdXuAPRRucMegpQp3Z3kDro=
github.com/gdamore/tcell/v2 v2.1.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
@@ -71,6 +78,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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=
@@ -106,6 +114,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
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/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
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=
@@ -114,7 +124,6 @@ github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1
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/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
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=
@@ -125,16 +134,17 @@ github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tW
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/magiconair/properties v1.8.4 h1:8KGKTcQQGm0Kv7vEbKFErAoAOFyyacLStRtQSeYtvkY=
-github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/makeworld-the-better-one/go-gemini v0.9.0 h1:Iz4ywRDrfsyoR8xZOkSKGXXftMR2spIV6ibVuhrKvSw=
-github.com/makeworld-the-better-one/go-gemini v0.9.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4=
+github.com/makeworld-the-better-one/go-gemini v0.11.0 h1:MNGiULJFvcqls9oCy40tE897hDeKvNmEK9i5kRucgQk=
+github.com/makeworld-the-better-one/go-gemini v0.11.0/go.mod h1:F+3x+R1xeYK90jMtBq+U+8Sh64r2dHleDZ/en3YgSmg=
github.com/makeworld-the-better-one/go-isemoji v1.1.0 h1:wZBHOKB5zAIgaU2vaWnXFDDhatebB8TySrNVxjVV84g=
github.com/makeworld-the-better-one/go-isemoji v1.1.0/go.mod h1:FBjkPl9rr0G4vlZCc+Mr+QcnOfGCTbGWYW8/1sp06I0=
-github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f h1:YEUlTs5gb35UlBLTgqrub9axWTYB3d7/8TxrkJDZpRI=
-github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f/go.mod h1:X6sxWNi9PBgQybpR4fpXPVD5fm7svLqZTQ5DJuERIoM=
+github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe h1:i3b9Qy5z23DcXRnrsMYcM5s9Ng5VIidM1xZd+szuTsY=
+github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe/go.mod h1:QQO3maftbOu+hiVOGOZDRLymqGQCos4zxbA4j89gMrE=
+github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568 h1:fod4pD+rsU73WIUxl8Kpo35LDuOx0uxzlprBKbm84vw=
+github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568/go.mod h1:CG/f0JmacksUc6TkZToO7tVq4t03zIQSQUtTd7F9GR4=
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-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.7/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=
@@ -151,19 +161,24 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4
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/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=
-github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+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/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI=
+github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
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/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
-github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
+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/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
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=
@@ -182,8 +197,10 @@ github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b h1:8NiY6v9/IlFU8osj1
github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b/go.mod h1:T1HolqzmdHnJIH6p7A9LDuvYGQgEHx9ijX3vKgDKU60=
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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
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=
@@ -192,8 +209,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
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/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.4.1 h1:asw9sl74539yqavKaglDM5hFpdJVK0Y5Dr/JOgQ89nQ=
-github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
+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/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=
@@ -210,12 +227,12 @@ github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/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/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
gitlab.com/tslocum/cbind v0.1.4 h1:cbZXPPcieXspk8cShoT6efz7HAT8yMNQcofYWNizis4=
gitlab.com/tslocum/cbind v0.1.4/go.mod h1:RvwYE3auSjBNlCmWeGspzn+jdLUVQ8C2QGC+0nP9ChI=
@@ -232,7 +249,8 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
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/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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=
@@ -251,6 +269,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
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-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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=
@@ -264,7 +283,13 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
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 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201216054612-986b41b23924 h1:QsnDpLLOKwHBBDa8nDws4DYNc/ryVW2vCpxCs09d4PY=
+golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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=
@@ -288,16 +313,22 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201113135734-0a15ea8d9b02/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5-0.20201208001344-75a595aef632 h1:clKlpQ6BheG1zIRhU2SPRAXpLgol/tqWVEeRkjpsaDI=
+golang.org/x/text v0.3.5-0.20201208001344-75a595aef632/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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=
diff --git a/renderer/page.go b/renderer/page.go
index d7afb80..0c43306 100644
--- a/renderer/page.go
+++ b/renderer/page.go
@@ -5,6 +5,7 @@ import (
"errors"
"io"
"mime"
+ "os"
"strings"
"time"
@@ -63,19 +64,17 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b
}
buf := new(bytes.Buffer)
- go func() {
- time.Sleep(time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second)
- res.Body.Close()
- }()
-
_, err := io.CopyN(buf, res.Body, viper.GetInt64("a-general.page_max_size")+1)
- res.Body.Close()
+
if err == nil {
// Content was larger than max size
return nil, ErrTooLarge
} else if err != io.EOF {
- if strings.HasSuffix(err.Error(), "use of closed network connection") {
- // Timed out
+ if os.IsTimeout(err) {
+ // I would use
+ // errors.Is(err, os.ErrDeadlineExceeded)
+ // but that isn't supported before Go 1.15.
+
return nil, ErrTimedOut
}
// Some other error
@@ -104,31 +103,37 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b
if mediatype == "text/gemini" {
rendered, links := RenderGemini(utfText, width, leftMargin, proxied)
return &structs.Page{
- Mediatype: structs.TextGemini,
- URL: url,
- Raw: utfText,
- Content: rendered,
- Links: links,
+ Mediatype: structs.TextGemini,
+ RawMediatype: mediatype,
+ URL: url,
+ Raw: utfText,
+ Content: rendered,
+ Links: links,
+ MadeAt: time.Now(),
}, nil
} else if strings.HasPrefix(mediatype, "text/") {
if mediatype == "text/x-ansi" || strings.HasSuffix(url, ".ans") || strings.HasSuffix(url, ".ansi") {
// ANSI
return &structs.Page{
- Mediatype: structs.TextAnsi,
- URL: url,
- Raw: utfText,
- Content: RenderANSI(utfText, leftMargin),
- Links: []string{},
+ Mediatype: structs.TextAnsi,
+ RawMediatype: mediatype,
+ URL: url,
+ Raw: utfText,
+ Content: RenderANSI(utfText, leftMargin),
+ Links: []string{},
+ MadeAt: time.Now(),
}, nil
}
// Treated as plaintext
return &structs.Page{
- Mediatype: structs.TextPlain,
- URL: url,
- Raw: utfText,
- Content: RenderPlainText(utfText, leftMargin),
- Links: []string{},
+ Mediatype: structs.TextPlain,
+ RawMediatype: mediatype,
+ URL: url,
+ Raw: utfText,
+ Content: RenderPlainText(utfText, leftMargin),
+ Links: []string{},
+ MadeAt: time.Now(),
}, nil
}
diff --git a/renderer/renderer.go b/renderer/renderer.go
index 8f41fdd..8ebee07 100644
--- a/renderer/renderer.go
+++ b/renderer/renderer.go
@@ -134,6 +134,9 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string,
// There is link text
url = lines[i][:delim]
linkText = strings.Trim(lines[i][delim:], " \t")
+ if viper.GetBool("a-general.show_link") {
+ linkText += " (" + url + ")"
+ }
}
if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" {
@@ -240,13 +243,18 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string,
} else if strings.HasPrefix(lines[i], ">") {
// It's a quote line, add extra quote symbols and italics 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], " ")
- wrappedLines = append(wrappedLines,
- wrapLine(lines[i], width, fmt.Sprintf("[%s::i]> ", config.GetColorString("quote_text")),
- "[-::-]", true)...,
- )
+ if len(lines[i]) == 1 {
+ // Just an empty quote line
+ wrappedLines = append(wrappedLines, fmt.Sprintf("[%s::i]>[-::-]", config.GetColorString("quote_text")))
+ } else {
+ // Remove beginning quote and maybe space
+ lines[i] = strings.TrimPrefix(lines[i], ">")
+ lines[i] = strings.TrimPrefix(lines[i], " ")
+ wrappedLines = append(wrappedLines,
+ wrapLine(lines[i], width, fmt.Sprintf("[%s::i]> ", config.GetColorString("quote_text")),
+ "[-::-]", true)...,
+ )
+ }
} else if strings.TrimSpace(lines[i]) == "" {
// Just add empty line without processing
diff --git a/rr/README.md b/rr/README.md
new file mode 100644
index 0000000..a6ce3cb
--- /dev/null
+++ b/rr/README.md
@@ -0,0 +1,37 @@
+# package `rr`, aka `RestartReader`
+
+This package exists just to hold the `RestartReader` type. It wraps `io.ReadCloser` and implements it. It holds the data from every `Read` in a `[]byte` buffer, and allows you to call `.Restart()`, causing subsequent `Read` calls to start from the beginning again.
+
+See [#140](https://github.com/makeworld-the-better-one/amfora/issues/140) for why this was needed.
+
+Other projects are encouraged to copy this code if it's useful to them, and this package may move out of Amfora if I end up using it in multiple projects.
+
+## License
+
+If you prefer, you can consider the code in this package, and this package only, to be licensed under the MIT license instead.
+
+
+Click to see MIT license terms
+
+```
+Copyright (c) 2020 makeworld
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
diff --git a/rr/rr.go b/rr/rr.go
new file mode 100644
index 0000000..ffd8a8a
--- /dev/null
+++ b/rr/rr.go
@@ -0,0 +1,81 @@
+package rr
+
+import (
+ "errors"
+ "io"
+)
+
+var ErrClosed = errors.New("RestartReader: closed")
+
+type RestartReader struct {
+ r io.ReadCloser
+ buf []byte
+
+ // Where in the buffer we are. If it's equal to len(buf) then the reader
+ // should be used.
+ i int64
+}
+
+func (rr *RestartReader) Read(p []byte) (n int, err error) {
+ if rr.buf == nil {
+ return 0, ErrClosed
+ }
+
+ if rr.i >= int64(len(rr.buf)) {
+ // Read new data
+ tmp := make([]byte, len(p))
+ n, err = rr.r.Read(tmp)
+ if n > 0 {
+ rr.buf = append(rr.buf, tmp[:n]...)
+ copy(p, tmp[:n])
+ }
+ rr.i = int64(len(rr.buf))
+ return
+ }
+
+ // Reading from buffer
+
+ bufSize := len(rr.buf[rr.i:])
+
+ if len(p) > bufSize {
+ // It wants more data then what's in the buffer
+ tmp := make([]byte, len(p)-bufSize)
+ n, err = rr.r.Read(tmp)
+ if n > 0 {
+ rr.buf = append(rr.buf, tmp[:n]...)
+ }
+ copy(p, rr.buf[rr.i:])
+ n += bufSize
+ rr.i = int64(len(rr.buf))
+ return
+ }
+ // All the required data is in the buffer
+ end := rr.i + int64(len(p))
+ copy(p, rr.buf[rr.i:end])
+ rr.i = end
+ n = len(p)
+ err = nil
+ return
+}
+
+// Restart causes subsequent Read calls to read from the beginning, instead
+// of where they left off.
+func (rr *RestartReader) Restart() {
+ rr.i = 0
+}
+
+// Close clears the buffer and closes the underlying io.ReadCloser, returning
+// its error.
+func (rr *RestartReader) Close() error {
+ rr.buf = nil
+ return rr.r.Close()
+}
+
+// NewRestartReader creates and initializes a new RestartReader that reads from
+// the provided io.ReadCloser.
+func NewRestartReader(r io.ReadCloser) *RestartReader {
+ return &RestartReader{
+ r: r,
+ buf: make([]byte, 0),
+ }
+}
diff --git a/rr/rr_test.go b/rr/rr_test.go
new file mode 100644
index 0000000..fb16f3c
--- /dev/null
+++ b/rr/rr_test.go
@@ -0,0 +1,45 @@
+package rr
+
+import (
+ "io/ioutil"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+var r1 *RestartReader
+
+func reset() {
+ r1 = NewRestartReader(ioutil.NopCloser(strings.NewReader("1234567890")))
+}
+
+func TestRead(t *testing.T) {
+ reset()
+ p := make([]byte, 1)
+ n, err := r1.Read(p)
+ assert.Equal(t, 1, n, "should read one byte")
+ assert.Equal(t, nil, err, "should be no error")
+ assert.Equal(t, []byte{'1'}, p, "should have read one byte, '1'")
+}
+
+//nolint
+func TestRestart(t *testing.T) {
+ reset()
+ p := make([]byte, 4)
+ r1.Read(p)
+
+ r1.Restart()
+ p = make([]byte, 5)
+ n, err := r1.Read(p)
+ assert.Equal(t, []byte("12345"), p, "should read the first 5 bytes again")
+ assert.Equal(t, 5, n, "should have read 4 bytes")
+ assert.Equal(t, nil, err, "err should be nil")
+
+ r1.Restart()
+ p = make([]byte, 4)
+ n, err = r1.Read(p)
+ assert.Equal(t, []byte("1234"), p, "should read the first 4 bytes again")
+ assert.Equal(t, 4, n, "should have read 4 bytes")
+ assert.Equal(t, nil, err, "err should be nil")
+}
diff --git a/structs/structs.go b/structs/structs.go
index 4dd194c..8770cc0 100644
--- a/structs/structs.go
+++ b/structs/structs.go
@@ -1,5 +1,7 @@
package structs
+import "time"
+
type Mediatype string
const (
@@ -18,18 +20,20 @@ const (
// Page is for storing UTF-8 text/gemini pages, as well as text/plain pages.
type Page struct {
- URL string
- Mediatype Mediatype
- Raw string // The raw response, as received over the network
- Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin.
- Links []string // URLs, for each region in the content.
- Row int // Scroll position
- Column int // ditto
- Width int // The terminal width when the Content was set, to know when reformatting should happen.
- Selected string // The current text or link selected
- SelectedID string // The cview region ID for the selected text/link
- Mode PageMode
- Favicon string
+ URL string
+ Mediatype Mediatype // Used for rendering purposes, generalized
+ RawMediatype string // The actual mediatype sent by the server
+ Raw string // The raw response, as received over the network
+ Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin.
+ Links []string // URLs, for each region in the content.
+ Row int // Scroll position
+ Column int // ditto
+ Width int // The terminal width when the Content was set, to know when reformatting should happen.
+ Selected string // The current text or link selected
+ SelectedID string // The cview region ID for the selected text/link
+ Mode PageMode
+ Favicon string
+ MadeAt time.Time // When the page was made. Zero value indicates it should stay in cache forever.
}
// Size returns an approx. size of a Page in bytes.
diff --git a/subscriptions/entries.go b/subscriptions/entries.go
new file mode 100644
index 0000000..5281af5
--- /dev/null
+++ b/subscriptions/entries.go
@@ -0,0 +1,144 @@
+package subscriptions
+
+import (
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+// This file contains funcs for creating PageEntries, which
+// are consumed by display/subscriptions.go
+
+// getURL returns a URL to be used in a PageEntry, from a
+// list of URLs for that item. It prefers gemini URLs, then
+// HTTP(S), then by order.
+func getURL(urls []string) string {
+ if len(urls) == 0 {
+ return ""
+ }
+
+ var firstHTTP string
+ for _, u := range urls {
+ if strings.HasPrefix(u, "gemini://") {
+ return u
+ }
+ if (strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) && firstHTTP == "" {
+ // First HTTP(S) URL in the list
+ firstHTTP = u
+ }
+ }
+ if firstHTTP != "" {
+ return firstHTTP
+ }
+ return urls[0]
+}
+
+// GetPageEntries returns the current list of PageEntries
+// for use in rendering a page.
+// The contents of the returned entries will never change,
+// so this function needs to be called again to get updates.
+// It always returns sorted entries - by post time, from newest to oldest.
+func GetPageEntries() *PageEntries {
+ var pe PageEntries
+
+ data.RLock()
+
+ for _, feed := range data.Feeds {
+ for _, item := range feed.Items {
+ if item.Links == nil || len(item.Links) == 0 {
+ // Ignore items without links
+ continue
+ }
+
+ // Set pub
+
+ var pub time.Time
+
+ // Try to use updated time first, then published
+
+ if item.UpdatedParsed != nil && !item.UpdatedParsed.IsZero() {
+ pub = *item.UpdatedParsed
+ } else if item.PublishedParsed != nil && !item.PublishedParsed.IsZero() {
+ pub = *item.PublishedParsed
+ } else {
+ // No time on the post, use now
+ pub = time.Now()
+ }
+
+ // Set prefix
+
+ // Prefer using the feed title over anything else.
+ // Many feeds in Gemini only have this due to gemfeed's default settings.
+ prefix := feed.Title
+
+ if prefix == "" {
+ // feed.Title was empty
+
+ if item.Author != nil {
+ // Prefer using the item author over the feed author
+ prefix = item.Author.Name
+ } else {
+ if feed.Author != nil {
+ prefix = feed.Author.Name
+ } else {
+ prefix = "[author unknown]"
+ }
+ }
+ } else {
+ // There's already a title, so add the author (if exists) to
+ // the end of the title in parentheses.
+ // Don't add the author if it's the same as the title.
+
+ if item.Author != nil && item.Author.Name != prefix {
+ // Prefer using the item author over the feed author
+ prefix += " (" + item.Author.Name + ")"
+ } else if feed.Author != nil && feed.Author.Name != prefix {
+ prefix += " (" + feed.Author.Name + ")"
+ }
+ }
+
+ pe.Entries = append(pe.Entries, &PageEntry{
+ Prefix: prefix,
+ Title: item.Title,
+ URL: getURL(item.Links),
+ Published: pub,
+ })
+ }
+ }
+
+ for u, page := range data.Pages {
+ parsed, _ := url.Parse(u)
+
+ // Path is title
+ title := parsed.Path
+ if strings.HasPrefix(title, "/~") && title != "/~" {
+ // A user dir
+ title = title[2:] // Remove beginning slash and tilde
+ // Remove trailing slash if the root of a user dir is being tracked
+ if strings.Count(title, "/") <= 1 && title[len(title)-1] == '/' {
+ title = title[:len(title)-1]
+ }
+ } else if strings.HasPrefix(title, "/users/") && title != "/users/" {
+ // "/users/" is removed for aesthetics when tracking hosted users
+ title = strings.TrimPrefix(title, "/users/")
+ title = strings.TrimPrefix(title, "~") // Remove leading tilde
+ // Remove trailing slash if the root of a user dir is being tracked
+ if strings.Count(title, "/") <= 1 && title[len(title)-1] == '/' {
+ title = title[:len(title)-1]
+ }
+ }
+
+ pe.Entries = append(pe.Entries, &PageEntry{
+ Prefix: parsed.Host,
+ Title: title,
+ URL: u,
+ Published: page.Changed,
+ })
+ }
+
+ data.RUnlock()
+
+ sort.Sort(&pe)
+ return &pe
+}
diff --git a/subscriptions/structs.go b/subscriptions/structs.go
new file mode 100644
index 0000000..facb809
--- /dev/null
+++ b/subscriptions/structs.go
@@ -0,0 +1,108 @@
+package subscriptions
+
+import (
+ "sync"
+ "time"
+
+ "github.com/mmcdole/gofeed"
+)
+
+/*
+Example stored JSON.
+
+{
+ "feeds": {
+ "url1": ,
+ "url2": ,
+ },
+ "pages": {
+ "url1": {
+ "hash": ,
+ "changed":