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

Merge branch 'master' into cview-update

Won't compile yet but conflicts were resolved.
This commit is contained in:
makeworld 2020-12-25 18:14:23 -05:00
commit 82e33130d8
58 changed files with 4431 additions and 878 deletions

View File

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

View File

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

154
.gitignore vendored
View File

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

View File

@ -15,7 +15,6 @@ linters:
- dupl
- exhaustive
- exportloopref
- goconst
- gocritic
- goerr113
- gofmt
@ -26,7 +25,6 @@ linters:
- lll
- maligned
- misspell
- nakedret
- nolintlint
- prealloc
- scopelint

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@
<h6>Image modified from: amphora by Alvaro Cabrera from the Noun Project</h6>
</center>
[![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 <filename>`. 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 <filename>`. 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.
<details>
<summary>Click to expand</summary>
**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
</details>
Just call `amfora` or `amfora <url>` 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 <kbd>?</kbd> inside the application to pull up the help menu with a list of all the keybindings, and <kbd>Esc</kbd> to leave it. If you have used Bombadillo you will find it similar.
It is designed with large 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 <kbd>Ctrl-F</kbd>
- [ ] 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\<username>\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.

View File

@ -11,3 +11,7 @@ Thank you to the following contributors, who have helped make Amfora great. FOSS
- Matt Caroll (@ohiolab)
- Patryk Niedźwiedziński (@pniedzwiedzinski)
- Trevor Slocum (@tsclocum)
- Mattias Jadelius (@jedthehumanoid)
- Lokesh Krishna (@lokesh-krishna)
- Jeff (@phaedrus-jaf)
- Stephen Robinson (@sudobash1)

View File

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

View File

@ -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]
if ok && (timeout == 0 || time.Since(p.MadeAt) < timeout) {
return p, ok
}
return nil, false
}

View File

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

View File

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

View File

@ -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()
// Make sure it exists
err = os.MkdirAll(TempDownloadsDir, 0755)
if err != nil {
return err
return fmt.Errorf("temp downloads path could not be created: %s", TempDownloadsDir)
}
BkmkStore.SetConfigFile(bkmkPath)
BkmkStore.SetConfigType("toml")
err = BkmkStore.ReadInConfig()
if err != nil {
return err
} 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)
}
BkmkStore.Set("DO NOT TOUCH", true)
err = BkmkStore.WriteConfig()
} else if os.IsNotExist(err) {
// Try to create path
err = os.MkdirAll(dDir, 0755)
if err != nil {
return err
return fmt.Errorf("temp downloads path could not be created: %s", dDir)
}
// 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
} 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
}

View File

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

View File

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

View File

@ -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
}
return i + 1, nil
// 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 -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
}

View File

@ -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{}
@ -38,6 +38,8 @@ var theme = map[string]tcell.Color{
"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.

View File

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

111
contrib/gemini-wiki/main.py Normal file
View File

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

View File

@ -0,0 +1 @@
md2gemini<2

76
contrib/themes/README.md Normal file
View File

@ -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)
<details>
<summary>More screenshots</summary>
![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)
</details>
## 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)
<details>
<summary>Another screenshot</summary>
![screenshot of gruvbox theme](https://user-images.githubusercontent.com/26380693/100381734-4a63ae00-3022-11eb-9531-a635df310052.png)
</details>
## 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)
<details>
<summary>Another screenshot</summary>
![screenshot of solarized dark theme](https://user-images.githubusercontent.com/798657/100597236-7b333400-32fd-11eb-8844-b92601da52c7.png)
</details>
### Light
![screenshot of solarized light theme](https://user-images.githubusercontent.com/798657/100597327-9aca5c80-32fd-11eb-8c91-fe3e324d8959.png)
<details>
<summary>Another screenshot</summary>
![screenshot of solarized light theme](https://user-images.githubusercontent.com/798657/100597349-a453c480-32fd-11eb-866e-10b0587228f6.png)
</details>
### 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)
<details>
<summary>More screenshots</summary>
![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)
</details>
## Yours?
Contribute your own theme by opening a PR.

105
contrib/themes/dracula.toml Normal file
View File

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

101
contrib/themes/gruvbox.toml Normal file
View File

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

112
contrib/themes/nord.toml Normal file
View File

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

View File

@ -0,0 +1,128 @@
# Atom One Dark theme ported to Amfora
# by Serge Tymoshenko <serge@tymo.name>
[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"

View File

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

View File

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

View File

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

View File

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

View File

@ -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 " ":
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 "e":
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 "R":
Reload()
return nil
case "b":
case config.CmdBack:
histBack(tabs[curTab])
return nil
case "f":
case config.CmdForward:
histForward(tabs[curTab])
return nil
case "u":
tabs[curTab].pageUp()
case config.CmdSub:
Subscriptions(tabs[curTab], "about:subscriptions")
tabs[curTab].addToHistory("about:subscriptions")
return nil
case "d":
tabs[curTab].pageDown()
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 {
// 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[i-1])
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
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 "?":
case config.CmdHelp:
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 {

View File

@ -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()
parsed, err := url.Parse(u)
if err != nil {
Error("URL Error", err.Error())
return
}
mediaHandler := getMediaHandler(resp)
var choice string
if mediaHandler.NoPrompt {
choice = "Open"
} else {
dlChoiceModal.SetText(text)
panels.ShowPanel("dlChoice")
tabPages.ShowPage("dlChoice")
tabPages.SendToFront("dlChoice")
App.SetFocus(dlChoiceModal)
App.Draw()
choice = <-dlChoiceCh
}
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
}
portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1"
ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false)
if ok {
browser.SetCurrentTab(strconv.Itoa(curTab))
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:]...)
}
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()

120
display/file.go Normal file
View File

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

522
display/handlers.go Normal file
View File

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

View File

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

View File

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

View File

@ -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
}
t.mode = tabModeDone
return s, b
fmt.Fprintf(tabRow, `["%d"][%s] %s [%s][""]|`,
i,
config.GetColorString("tab_num"),
char,
config.GetColorString("tab_divider"),
)
}
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)
} else {
for i := 0; i < NumTabs(); i++ {
char := strconv.Itoa(i + 1)
if tabs[i].page.Favicon != "" {
char = tabs[i].page.Favicon
}
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)
fmt.Fprintf(tabRow, `["%d"] %s [""]|`, i, char)
}
}
// Otherwise download it
bottomBar.SetText("Loading...")
t.barText = "Loading..." // Save it too, in case the tab switches during loading
t.mode = tabModeLoading
tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight()
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)
}
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)
}

328
display/subscriptions.go Normal file
View File

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

View File

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

View File

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

View File

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

20
go.mod
View File

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

65
go.sum
View File

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

View File

@ -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
@ -105,30 +104,36 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b
rendered, links := RenderGemini(utfText, width, leftMargin, proxied)
return &structs.Page{
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,
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,
RawMediatype: mediatype,
URL: url,
Raw: utfText,
Content: RenderPlainText(utfText, leftMargin),
Links: []string{},
MadeAt: time.Now(),
}, nil
}

View File

@ -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,6 +243,10 @@ 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
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], " ")
@ -247,6 +254,7 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string,
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

37
rr/README.md Normal file
View File

@ -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.
<details>
<summary>Click to see MIT license terms</summary>
```
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.
```
</details>

81
rr/rr.go Normal file
View File

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

45
rr/rr_test.go Normal file
View File

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

View File

@ -1,5 +1,7 @@
package structs
import "time"
type Mediatype string
const (
@ -19,7 +21,8 @@ const (
// Page is for storing UTF-8 text/gemini pages, as well as text/plain pages.
type Page struct {
URL string
Mediatype Mediatype
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.
@ -30,6 +33,7 @@ type Page struct {
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.

144
subscriptions/entries.go Normal file
View File

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

108
subscriptions/structs.go Normal file
View File

@ -0,0 +1,108 @@
package subscriptions
import (
"sync"
"time"
"github.com/mmcdole/gofeed"
)
/*
Example stored JSON.
{
"feeds": {
"url1": <gofeed.Feed>,
"url2": <gofeed.Feed>,
},
"pages": {
"url1": {
"hash": <hash>,
"changed": <time>
},
"url2": {
"hash": <hash>,
"changed": <time>
}
}
}
"pages" are the pages tracked for changes that aren't feeds.
The hash used is SHA-256.
The time is in RFC 3339 format, preferably in the UTC timezone.
*/
// Decoded JSON
type jsonData struct {
feedMu *sync.RWMutex
pageMu *sync.RWMutex
Feeds map[string]*gofeed.Feed `json:"feeds,omitempty"`
Pages map[string]*pageJSON `json:"pages,omitempty"`
}
// Lock locks both feed and page mutexes.
func (j *jsonData) Lock() {
j.feedMu.Lock()
j.pageMu.Lock()
}
// Unlock unlocks both feed and page mutexes.
func (j *jsonData) Unlock() {
j.feedMu.Unlock()
j.pageMu.Unlock()
}
// RLock read-locks both feed and page mutexes.
func (j *jsonData) RLock() {
j.feedMu.RLock()
j.pageMu.RLock()
}
// RUnlock read-unlocks both feed and page mutexes.
func (j *jsonData) RUnlock() {
j.feedMu.RUnlock()
j.pageMu.RUnlock()
}
type pageJSON struct {
Hash string `json:"hash"`
Changed time.Time `json:"changed"` // When the latest change happened
}
// Global instance of jsonData - loaded from JSON and used
var data = jsonData{
feedMu: &sync.RWMutex{},
pageMu: &sync.RWMutex{},
// Maps are created in Init()
}
// PageEntry is a single item on a subscriptions page.
// It is used for both feeds and pages.
type PageEntry struct {
Prefix string // Feed/log title, author, etc - something before the post title
Title string
URL string
Published time.Time
}
// PageEntries is new-to-old list of Entry structs, used to create a
// subscriptions page.
// It should always be assumed to be sorted when used in other packages,
// by post time, from newest to oldest.
type PageEntries struct {
Entries []*PageEntry
}
// Implement sort.Interface
func (e *PageEntries) Len() int {
return len(e.Entries)
}
func (e *PageEntries) Less(i, j int) bool {
return e.Entries[i].Published.After(e.Entries[j].Published)
}
func (e *PageEntries) Swap(i, j int) {
e.Entries[i], e.Entries[j] = e.Entries[j], e.Entries[i]
}

View File

@ -0,0 +1,469 @@
package subscriptions
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"mime"
urlPkg "net/url"
"os"
"path"
"reflect"
"strings"
"sync"
"time"
"github.com/makeworld-the-better-one/amfora/client"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/mmcdole/gofeed"
"github.com/spf13/viper"
)
var (
ErrSaving = errors.New("couldn't save JSON to disk")
ErrNotSuccess = errors.New("status 20 not returned")
ErrNotFeed = errors.New("not a valid feed")
ErrTooManyRedirects = errors.New("redirected more than 5 times")
)
var writeMu = sync.Mutex{} // Prevent concurrent writes to subscriptions.json file
// LastUpdated is the time when the in-memory data was last updated.
// It can be used to know if the subscriptions page should be regenerated.
var LastUpdated time.Time
// Init should be called after config.Init.
func Init() error {
f, err := os.Open(config.SubscriptionPath)
if err == nil {
// File exists and could be opened
fi, err := f.Stat()
if err == nil && fi.Size() > 0 {
// File is not empty
jsonBytes, err := ioutil.ReadAll(f)
f.Close()
if err != nil {
return fmt.Errorf("read subscriptions.json error: %w", err)
}
err = json.Unmarshal(jsonBytes, &data)
if err != nil {
return fmt.Errorf("subscriptions.json is corrupted: %w", err)
}
}
f.Close()
} else if !os.IsNotExist(err) {
// There's an error opening the file, but it's not bc is doesn't exist
return fmt.Errorf("open subscriptions.json error: %w", err)
}
if data.Feeds == nil {
data.Feeds = make(map[string]*gofeed.Feed)
}
if data.Pages == nil {
data.Pages = make(map[string]*pageJSON)
}
LastUpdated = time.Now()
if viper.GetInt("subscriptions.update_interval") > 0 {
// Update subscriptions every so often
go func() {
for {
updateAll()
time.Sleep(time.Duration(viper.GetInt("subscriptions.update_interval")) * time.Second)
}
}()
} else {
// User disabled automatic updates
// So just update once at the beginning
go updateAll()
}
return nil
}
// IsSubscribed returns true if the URL is already subscribed to,
// whether a feed or page.
func IsSubscribed(url string) bool {
data.feedMu.RLock()
for u := range data.Feeds {
if url == u {
data.feedMu.RUnlock()
return true
}
}
data.feedMu.RUnlock()
data.pageMu.RLock()
for u := range data.Pages {
if url == u {
data.pageMu.RUnlock()
return true
}
}
data.pageMu.RUnlock()
return false
}
// GetFeed returns a Feed object and a bool indicating whether the passed
// content was actually recognized as a feed.
func GetFeed(mediatype, filename string, r io.Reader) (*gofeed.Feed, bool) {
if r == nil {
return nil, false
}
// Check mediatype and filename
if mediatype != "application/atom+xml" && mediatype != "application/rss+xml" && mediatype != "application/json+feed" &&
filename != "atom.xml" && filename != "feed.xml" && filename != "feed.json" &&
!strings.HasSuffix(filename, ".atom") && !strings.HasSuffix(filename, ".rss") &&
!strings.HasSuffix(filename, ".xml") {
// No part of the above is true
return nil, false
}
feed, err := gofeed.NewParser().Parse(r)
if feed == nil {
return nil, false
}
return feed, err == nil
}
func writeJSON() error {
writeMu.Lock()
defer writeMu.Unlock()
data.Lock()
jsonBytes, err := json.MarshalIndent(&data, "", " ")
data.Unlock()
if err != nil {
return err
}
err = ioutil.WriteFile(config.SubscriptionPath, jsonBytes, 0666)
if err != nil {
return err
}
return nil
}
// AddFeed stores a feed.
// It can be used to update a feed for a URL, although the package
// will handle that on its own.
func AddFeed(url string, feed *gofeed.Feed) error {
if feed == nil {
panic("feed is nil")
}
// Remove any unused fields to save memory and disk space
feed.Image = nil
feed.Generator = ""
feed.Categories = nil
feed.DublinCoreExt = nil
feed.ITunesExt = nil
feed.Custom = nil
feed.Link = ""
feed.Links = nil
for _, item := range feed.Items {
item.Description = ""
item.Content = ""
item.Image = nil
item.Categories = nil
item.Enclosures = nil
item.DublinCoreExt = nil
item.ITunesExt = nil
item.Extensions = nil
item.Custom = nil
item.Link = "" // Links is used instead
}
data.feedMu.Lock()
oldFeed, ok := data.Feeds[url]
if !ok || !reflect.DeepEqual(feed, oldFeed) {
// Feeds are different, or there was never an old one
LastUpdated = time.Now()
data.Feeds[url] = feed
data.feedMu.Unlock()
err := writeJSON()
if err != nil {
return ErrSaving
}
} else {
data.feedMu.Unlock()
}
return nil
}
// AddPage stores a page to track for changes.
// It can be used to update the page as well, although the package
// will handle that on its own.
func AddPage(url string, r io.Reader) error {
if r == nil {
return nil
}
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return err
}
newHash := fmt.Sprintf("%x", h.Sum(nil))
data.pageMu.Lock()
_, ok := data.Pages[url]
if !ok || data.Pages[url].Hash != newHash {
// Page content is different, or it didn't exist
LastUpdated = time.Now()
data.Pages[url] = &pageJSON{
Hash: newHash,
Changed: time.Now().UTC(),
}
data.pageMu.Unlock()
err := writeJSON()
if err != nil {
return ErrSaving
}
} else {
data.pageMu.Unlock()
}
return nil
}
// getResource returns a URL and Response for the given URL.
// It will follow up to 5 redirects, and if there is a permanent
// redirect it will return the new URL. Otherwise the URL will
// stay the same. THe returned URL will never be empty.
//
// If there is over 5 redirects the error will be ErrTooManyRedirects.
// ErrNotSuccess, as well as other fetch errors will also be returned.
func getResource(url string) (string, *gemini.Response, error) {
res, err := client.Fetch(url)
if err != nil {
if res != nil {
res.Body.Close()
}
return url, nil, err
}
if res.Status == gemini.StatusSuccess {
// No redirects
return url, res, nil
}
parsed, err := urlPkg.Parse(url)
if err != nil {
return url, nil, err
}
i := 0
redirs := make([]int, 0)
urls := make([]*urlPkg.URL, 0)
// Loop through redirects
for (res.Status == gemini.StatusRedirectPermanent || res.Status == gemini.StatusRedirectTemporary) && i < 5 {
redirs = append(redirs, res.Status)
urls = append(urls, parsed)
tmp, err := parsed.Parse(res.Meta)
if err != nil {
// Redirect URL returned by the server is invalid
return url, nil, err
}
parsed = tmp
// Make the new request
res, err := client.Fetch(parsed.String())
if err != nil {
if res != nil {
res.Body.Close()
}
return url, nil, err
}
i++
}
// Two possible options here:
// - Never redirected, got error on start
// - No more redirects, other status code
// - Too many redirects
if i == 0 {
// Never redirected or succeeded
return url, res, ErrNotSuccess
}
if i < 5 {
// The server stopped redirecting after <5 redirects
if res.Status == gemini.StatusSuccess {
// It ended by succeeding
for j := range redirs {
if redirs[j] == gemini.StatusRedirectTemporary {
if j == 0 {
// First redirect is temporary
return url, res, nil
}
// There were permanent redirects before this one
// Return the URL of the latest permanent redirect
return urls[j-1].String(), res, nil
}
}
// They were all permanent redirects
return urls[len(urls)-1].String(), res, nil
}
// It stopped because there was a non-redirect, non-success response
return url, res, ErrNotSuccess
}
// Too many redirects, return original
return url, nil, ErrTooManyRedirects
}
func updateFeed(url string) {
newURL, res, err := getResource(url)
if err != nil {
return
}
mediatype, _, err := mime.ParseMediaType(res.Meta)
if err != nil {
return
}
filename := path.Base(newURL)
feed, ok := GetFeed(mediatype, filename, res.Body)
if !ok {
return
}
err = AddFeed(newURL, feed)
if url != newURL && err == nil {
// URL has changed, remove old one
Remove(url) //nolint:errcheck
}
}
func updatePage(url string) {
newURL, res, err := getResource(url)
if err != nil {
return
}
err = AddPage(newURL, res.Body)
if url != newURL && err == nil {
// URL has changed, remove old one
Remove(url) //nolint:errcheck
}
}
// updateAll updates all subscriptions using workers.
// It only returns once all the workers are done.
func updateAll() {
worker := func(jobs <-chan [2]string, wg *sync.WaitGroup) {
// Each job is: [2]string{<type>, "url"}
// where <type> is "feed" or "page"
defer wg.Done()
for j := range jobs {
if j[0] == "feed" {
updateFeed(j[1]) //nolint:errcheck
} else if j[0] == "page" {
updatePage(j[1]) //nolint:errcheck
}
}
}
var wg sync.WaitGroup
data.RLock()
numJobs := len(data.Feeds) + len(data.Pages)
jobs := make(chan [2]string, numJobs)
if numJobs == 0 {
data.RUnlock()
return
}
numWorkers := viper.GetInt("subscriptions.workers")
if numWorkers < 1 {
numWorkers = 1
}
// Start workers, waiting for jobs
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
worker(jobs, &wg)
}()
}
// Get map keys in a slice
feedKeys := make([]string, len(data.Feeds))
i := 0
for k := range data.Feeds {
feedKeys[i] = k
i++
}
pageKeys := make([]string, len(data.Pages))
i = 0
for k := range data.Pages {
pageKeys[i] = k
i++
}
data.RUnlock()
for j := 0; j < numJobs; j++ {
if j < len(feedKeys) {
jobs <- [2]string{"feed", feedKeys[j]}
} else {
// In the Pages
jobs <- [2]string{"page", pageKeys[j-len(feedKeys)]}
}
}
close(jobs)
wg.Wait()
}
// AllURLs returns all the subscribed-to URLS.
func AllURLS() []string {
data.RLock()
defer data.RUnlock()
urls := make([]string, len(data.Feeds)+len(data.Pages))
i := 0
for k := range data.Feeds {
urls[i] = k
i++
}
for k := range data.Pages {
urls[i] = k
i++
}
return urls
}
// Remove removes a subscription from memory and from the disk.
// The URL must be provided. It will do nothing if the URL is
// not an actual subscription.
//
// It returns any errors that occurred when saving to disk.
func Remove(u string) error {
data.Lock()
// Just delete from both instead of using a loop to find it
delete(data.Feeds, u)
delete(data.Pages, u)
data.Unlock()
return writeJSON()
}

View File

@ -0,0 +1,14 @@
// +build darwin
package sysopen
import "os/exec"
// Open opens `path` in default system viewer.
func Open(path string) (string, error) {
err := exec.Command("open", path).Start()
if err != nil {
return "", err
}
return "Opened in default system viewer", nil
}

View File

@ -0,0 +1,11 @@
// +build !linux,!darwin,!windows,!freebsd,!netbsd,!openbsd
package sysopen
import "fmt"
// Open opens `path` in default system viewer, but not on this OS.
func Open(path string) (string, error) {
return "", fmt.Errorf("unsupported OS for default system viewer. " +
"Set a catch-all [[mediatype-handlers]] command in the config")
}

View File

@ -0,0 +1,35 @@
// +build linux freebsd netbsd openbsd
//nolint:goerr113
package sysopen
import (
"fmt"
"os"
"os/exec"
)
// Open opens `path` in default system viewer. It tries to do so using
// xdg-open. It only works if there is a display server working.
func Open(path string) (string, error) {
var (
xorgDisplay = os.Getenv("DISPLAY")
waylandDisplay = os.Getenv("WAYLAND_DISPLAY")
xdgOpenPath, xdgOpenNotFoundErr = exec.LookPath("xdg-open")
)
switch {
case xorgDisplay == "" && waylandDisplay == "":
return "", fmt.Errorf("no display server was found. " +
"You may set a default [[mediatype-handlers]] command in the config")
case xdgOpenNotFoundErr == nil:
// Use start rather than run or output in order
// to make application run in background.
if err := exec.Command(xdgOpenPath, path).Start(); err != nil {
return "", err
}
return "Opened in default system viewer", nil
default:
return "", fmt.Errorf("could not determine default system viewer. " +
"Set a catch-all [[mediatype-handlers]] command in the config")
}
}

View File

@ -0,0 +1,15 @@
// +build windows
// +build !linux !darwin !freebsd !netbsd !openbsd
package sysopen
import "os/exec"
// Open opens `path` in default system vierwer.
func Open(path string) (string, error) {
err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start()
if err != nil {
return "", err
}
return "Opened in default system viewer", nil
}