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