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 39ce25f..80cc6f1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -25,7 +25,6 @@ linters: - lll - maligned - misspell - - nakedret - nolintlint - prealloc - scopelint diff --git a/CHANGELOG.md b/CHANGELOG.md index bc886f8..91109a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Ability to set custom keybindings in config (#135) + +### Fixed +- Don't use cache when URL is typed in bottom bar (#159) + +## [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.9.3 +- 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 diff --git a/README.md b/README.md index 252a6dc..9b251b2 100644 --- a/README.md +++ b/README.md @@ -20,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. @@ -36,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 @@ -49,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 @@ -59,13 +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 -This section is for advanced users who want to install the latest (possibly unstable) version of Amfora. - **Requirements:** - Go 1.13 or later - GNU Make @@ -82,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: ``` @@ -124,7 +146,7 @@ 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 -- [x] *Subscriptions* +- [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 - [ ] Stream support diff --git a/THANKS.md b/THANKS.md index c27c0af..498411e 100644 --- a/THANKS.md +++ b/THANKS.md @@ -12,4 +12,6 @@ Thank you to the following contributors, who have helped make Amfora great. FOSS - Patryk Niedźwiedziński (@pniedzwiedzinski) - 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 62e2c6d..09bbbc5 100644 --- a/amfora.go +++ b/amfora.go @@ -11,7 +11,7 @@ import ( ) var ( - version = "v1.6.0" + version = "v1.7.2" commit = "unknown" builtBy = "unknown" ) @@ -52,7 +52,7 @@ func main() { client.Init() - display.Init() + display.Init(version, commit, builtBy) display.NewTab() display.NewTab() // Open extra tab and close it to fully initialize the app and wrapping display.CloseTab() diff --git a/cache/page.go b/cache/page.go index 0175e2f..4e80b89 100644 --- a/cache/page.go +++ b/cache/page.go @@ -4,6 +4,7 @@ package cache import ( "sync" + "time" "github.com/makeworld-the-better-one/amfora/structs" ) @@ -13,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. @@ -26,6 +28,16 @@ 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] @@ -110,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/client/client.go b/client/client.go index 42e9777..f216fcc 100644 --- a/client/client.go +++ b/client/client.go @@ -18,7 +18,6 @@ var ( certCacheMu = &sync.RWMutex{} fetchClient *gemini.Client - dlClient *gemini.Client // For downloading ) func Init() { @@ -26,10 +25,6 @@ func Init() { ConnectTimeout: 10 * time.Second, // Default is 15 ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second, } - dlClient = &gemini.Client{ - ConnectTimeout: 10 * time.Second, // Default is 15 - // No read timeout, download can take as long as it needs - } } func clientCert(host string) ([]byte, []byte) { @@ -112,11 +107,6 @@ func Fetch(u string) (*gemini.Response, error) { return fetch(u, fetchClient) } -// Download is the same as Fetch but with no read timeout. -func Download(u string) (*gemini.Response, error) { - return fetch(u, dlClient) -} - func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) { parsed, _ := url.Parse(u) cert, key := clientCert(parsed.Host) @@ -145,8 +135,3 @@ func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemi func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) { return fetchWithProxy(proxyHostname, proxyPort, u, fetchClient) } - -// DownloadWithProxy is the same as FetchWithProxy but with no read timeout. -func DownloadWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) { - return fetchWithProxy(proxyHostname, proxyPort, u, dlClient) -} diff --git a/config/config.go b/config/config.go index 9f9cd71..54fcf98 100644 --- a/config/config.go +++ b/config/config.go @@ -165,12 +165,108 @@ func Init() error { 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 { @@ -202,93 +298,10 @@ func Init() error { DownloadsDir = dDir } - // Setup temporary downloads dir - if viper.GetString("a-general.temp_downloads") == "" { - TempDownloadsDir = filepath.Join(os.TempDir(), "amfora_temp") - - // 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 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.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.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.shift_numbers", "!@#$%^&*()") - viper.SetDefault("url-handlers.other", "off") - viper.SetDefault("cache.max_size", 0) - viper.SetDefault("cache.max_pages", 20) - 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 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") diff --git a/config/default.go b/config/default.go index 4fbe02c..2624c61 100644 --- a/config/default.go +++ b/config/default.go @@ -90,12 +90,51 @@ emoji_favicons = false [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. @@ -164,13 +203,15 @@ other = 'off' [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. 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/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 index a601e45..7fa21f9 100644 --- a/contrib/themes/README.md +++ b/contrib/themes/README.md @@ -6,7 +6,7 @@ You can use these themes by replacing the `[theme]` section of your config with Contributed by **[@lokesh-krishna](https://github.com/lokesh-krishna)**. -![screenshot of the nord theme](https://user-images.githubusercontent.com/20235646/99020443-a93a1980-2584-11eb-8028-0b95cfcf0fc6.png) +![screenshot of the nord theme](https://user-images.githubusercontent.com/20235646/102846450-005dc480-4436-11eb-89a9-a1a4350f5415.png) ## Dracula diff --git a/contrib/themes/dracula.toml b/contrib/themes/dracula.toml index 3be9b62..d2073be 100644 --- a/contrib/themes/dracula.toml +++ b/contrib/themes/dracula.toml @@ -101,3 +101,5 @@ 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/nord.toml b/contrib/themes/nord.toml index ec2bf7b..0516b62 100644 --- a/contrib/themes/nord.toml +++ b/contrib/themes/nord.toml @@ -29,13 +29,12 @@ # 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" -fg = "#eceff4" -tab_num = "#88c0d0" -tab_divider = "#eceff4" -bottombar_bg = "#3b4252" -bottombar_text = "#eceff4" -bottombar_label = "#88c0d0" +bg = "#2e3440" +tab_num = "#88c0d0" +tab_divider = "#4c566a" +bottombar_label = "#88c0d0" +bottombar_text = "#eceff4" +bottombar_bg = "#3b4252" # hdg_1 # hdg_2 @@ -47,21 +46,21 @@ bottombar_label = "#88c0d0" # 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 = "#8fbcbb" -preformatted_text = "#eceff4" -list_text = "#eceff4" +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" +btn_bg = "#4c566a" +btn_text = "#eceff4" # dl_choice_modal_bg # dl_choice_modal_text @@ -75,37 +74,39 @@ btn_text = "#eceff4" # yesno_modal_text # tofu_modal_bg # tofu_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 = "#2e3440" -yesno_modal_bg = "#3b4252" -yesno_modal_text = "#eceff4" -tofu_modal_bg = "#3b4252" -tofu_modal_text = "#eceff4" +# 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" +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 = "#88c0d0" -bkmk_modal_field_bg = "#4c566a" +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 index 2cbc7ea..227f641 100644 --- a/contrib/themes/one_dark.toml +++ b/contrib/themes/one_dark.toml @@ -52,15 +52,15 @@ bottombar_label = "#282c34" # preformatted_text # list_text -hdg_1 = "#c678dd" +hdg_1 = "#e06c75" hdg_2 = "#c678dd" hdg_3 = "#c678dd" amfora_link = "#61afef" foreign_link = "#56b6c2" link_number = "#abb2bf" regular_text = "#abb2bf" -quote_text = "#abb2bf" -preformatted_text = "#abb2bf" +quote_text = "#98c379" +preformatted_text = "#e5c07b" list_text = "#abb2bf" # btn_bg: The bg color for all modal buttons @@ -121,3 +121,8 @@ 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/default-config.toml b/default-config.toml index 9d3cf84..e5a6272 100644 --- a/default-config.toml +++ b/default-config.toml @@ -87,12 +87,51 @@ emoji_favicons = false [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. @@ -161,13 +200,15 @@ other = 'off' [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. diff --git a/display/display.go b/display/display.go index 2110745..b3d448a 100644 --- a/display/display.go +++ b/display/display.go @@ -3,6 +3,7 @@ package display import ( "fmt" "net/url" + "regexp" "strconv" "strings" @@ -26,6 +27,12 @@ var termH int // The user input and URL display bar at the bottom var bottomBar = cview.NewInputField() +// 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". @@ -52,6 +59,7 @@ var layout = cview.NewFlex(). SetDirection(cview.FlexRow) var newTabPage structs.Page +var versionPage structs.Page var App = cview.NewApplication(). EnableMouse(false). @@ -70,7 +78,21 @@ var App = cview.NewApplication(). }(tabs[curTab]) }) -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, + } + tabRow.SetChangedFunc(func() { App.Draw() }) @@ -176,14 +198,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 @@ -245,43 +274,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 { @@ -293,66 +315,48 @@ func Init() { Info("The current page has no content, so it couldn't be downloaded.") } return nil - case tcell.KeyCtrlA: + 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 tcell.KeyCtrlX: + case config.CmdAddSub: go addSubscription() 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 - } + } - // 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 { @@ -365,45 +369,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 @@ -439,10 +431,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 tabPages.AddAndSwitchToPage(strconv.Itoa(curTab), tabs[curTab].view, true) App.SetFocus(tabs[curTab].view) @@ -584,11 +574,7 @@ func URL(u string) { return } - if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") { - // Assume it's a Gemini URL - u = "gemini://" + u - } - go goURL(t, u) + go goURL(t, fixUserURL(u)) } func NumTabs() int { diff --git a/display/download.go b/display/download.go index 5f5e3b6..15d3dac 100644 --- a/display/download.go +++ b/display/download.go @@ -19,7 +19,7 @@ import ( "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" ) diff --git a/display/file.go b/display/file.go index 51a660c..9349c86 100644 --- a/display/file.go +++ b/display/file.go @@ -31,6 +31,10 @@ func handleFile(u string) (*structs.Page, bool) { 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") { diff --git a/display/handlers.go b/display/handlers.go index c211d1e..21e3af3 100644 --- a/display/handlers.go +++ b/display/handlers.go @@ -15,6 +15,7 @@ import ( "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" @@ -186,6 +187,11 @@ func handleAbout(t *tab, u string) (string, bool) { 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?") { @@ -362,6 +368,10 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { 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 @@ -369,35 +379,20 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { return ret("", false) } - var res2 *gemini.Response - var dlErr error - if errors.Is(err, renderer.ErrTooLarge) { - // Make new request for downloading purposes - if usingProxy { - res2, dlErr = client.DownloadWithProxy(proxyHostname, proxyPort, u) - } else { - res2, dlErr = client.Download(u) - } - if dlErr != nil && !errors.Is(dlErr, 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, res2) + // 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) { - // Make new request for downloading purposes - if usingProxy { - res2, dlErr = client.DownloadWithProxy(proxyHostname, proxyPort, u) - } else { - res2, dlErr = client.Download(u) - } - if dlErr != nil && !errors.Is(dlErr, 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, res2) + // 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 { @@ -416,7 +411,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { return ret(u, true) } // Not displayable - // Could be a non 20 (or 21) status code, or a different kind of document + // Could be a non 20 status code, or a different kind of document // Handle each status code switch res.Status { @@ -424,7 +419,6 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { 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.") @@ -510,6 +504,9 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { 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) } }() @@ -517,6 +514,9 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { } // 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 4128d39..20da364 100644 --- a/display/help.go +++ b/display/help.go @@ -1,10 +1,12 @@ package display import ( + "fmt" "strconv" "strings" "github.com/gdamore/tcell" + "github.com/makeworld-the-better-one/amfora/config" "gitlab.com/tslocum/cview" ) @@ -12,41 +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. -Ctrl-A|View subscriptions -Ctrl-X|Add or update a subscription -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.NewTable(). @@ -71,6 +71,36 @@ func helpInit() { App.Draw() } }) + + 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", "|"), diff --git a/display/newtab.go b/display/newtab.go index fbc119b..433ebb2 100644 --- a/display/newtab.go +++ b/display/newtab.go @@ -17,11 +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/util.go b/display/util.go index 4ea8e91..33495c1 100644 --- a/display/util.go +++ b/display/util.go @@ -5,8 +5,10 @@ import ( "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. @@ -73,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 @@ -102,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 70b1468..349d222 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ require ( github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606 github.com/google/go-cmp v0.5.0 // indirect - github.com/makeworld-the-better-one/go-gemini v0.9.3 + 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.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/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 @@ -22,10 +22,11 @@ require ( github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.6.1 gitlab.com/tslocum/cview v1.4.8-0.20200713214710-cc7796c4ca44 - golang.org/x/sys v0.0.0-20200817155316-9781c653f443 // indirect - golang.org/x/text v0.3.3 + 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.57.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 8c7bee8..ae1e114 100644 --- a/go.sum +++ b/go.sum @@ -133,16 +133,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/makeworld-the-better-one/go-gemini v0.9.3 h1:vpJc1u4LYpEI5h7GcOE2zSfOmpE9gQzt0vEayp/ilWc= -github.com/makeworld-the-better-one/go-gemini v0.9.3/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/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.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/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.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -249,6 +250,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-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= @@ -286,6 +289,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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= @@ -309,15 +314,20 @@ 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-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200817155316-9781c653f443 h1:X18bCaipMcoJGm27Nv7zr4XYPKGUy92GtqboKC2Hxaw= -golang.org/x/sys v0.0.0-20200817155316-9781c653f443/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/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.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 ad42d17..b5a81ce 100644 --- a/renderer/page.go +++ b/renderer/page.go @@ -7,6 +7,7 @@ import ( "mime" "os" "strings" + "time" "github.com/makeworld-the-better-one/amfora/structs" "github.com/makeworld-the-better-one/go-gemini" @@ -108,6 +109,7 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b 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") { @@ -119,6 +121,7 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b Raw: utfText, Content: RenderANSI(utfText, leftMargin), Links: []string{}, + MadeAt: time.Now(), }, nil } @@ -130,6 +133,7 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b Raw: utfText, Content: RenderPlainText(utfText, leftMargin), Links: []string{}, + MadeAt: time.Now(), }, nil } diff --git a/renderer/renderer.go b/renderer/renderer.go index b21ecd9..8ebee07 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -243,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 ce78fe0..8770cc0 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -1,5 +1,7 @@ package structs +import "time" + type Mediatype string const ( @@ -31,6 +33,7 @@ type Page struct { SelectedID string // The cview region ID for the selected text/link Mode PageMode Favicon string + MadeAt time.Time // When the page was made. Zero value indicates it should stay in cache forever. } // Size returns an approx. size of a Page in bytes. diff --git a/subscriptions/entries.go b/subscriptions/entries.go index 5e45f10..5281af5 100644 --- a/subscriptions/entries.go +++ b/subscriptions/entries.go @@ -112,14 +112,14 @@ func GetPageEntries() *PageEntries { // Path is title title := parsed.Path - if strings.HasPrefix(title, "/~") { + 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/") { + } 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 diff --git a/subscriptions/subscriptions.go b/subscriptions/subscriptions.go index 76de80f..91535d3 100644 --- a/subscriptions/subscriptions.go +++ b/subscriptions/subscriptions.go @@ -8,6 +8,7 @@ import ( "io" "io/ioutil" "mime" + urlPkg "net/url" "os" "path" "reflect" @@ -23,9 +24,10 @@ import ( ) var ( - ErrSaving = errors.New("couldn't save JSON to disk") - ErrNotSuccess = errors.New("status 20 not returned") - ErrNotFeed = errors.New("not a valid feed") + ErrSaving = errors.New("couldn't save JSON to disk") + ErrNotSuccess = errors.New("status 20 not returned") + ErrNotFeed = errors.New("not a valid feed") + ErrTooManyRedirects = errors.New("redirected more than 5 times") ) var writeMu = sync.Mutex{} // Prevent concurrent writes to subscriptions.json file @@ -58,9 +60,12 @@ func Init() error { } else if !os.IsNotExist(err) { // There's an error opening the file, but it's not bc is doesn't exist return fmt.Errorf("open subscriptions.json error: %w", err) - } else { - // File does not exist, initialize maps + } + + if data.Feeds == nil { data.Feeds = make(map[string]*gofeed.Feed) + } + if data.Pages == nil { data.Pages = make(map[string]*pageJSON) } @@ -115,7 +120,8 @@ func GetFeed(mediatype, filename string, r io.Reader) (*gofeed.Feed, bool) { // Check mediatype and filename if mediatype != "application/atom+xml" && mediatype != "application/rss+xml" && mediatype != "application/json+feed" && filename != "atom.xml" && filename != "feed.xml" && filename != "feed.json" && - !strings.HasSuffix(filename, ".atom") && !strings.HasSuffix(filename, ".rss") { + !strings.HasSuffix(filename, ".atom") && !strings.HasSuffix(filename, ".rss") && + !strings.HasSuffix(filename, ".xml") { // No part of the above is true return nil, false } @@ -229,46 +235,133 @@ func AddPage(url string, r io.Reader) error { return nil } -func updateFeed(url string) error { +// getResource returns a URL and Response for the given URL. +// It will follow up to 5 redirects, and if there is a permanent +// redirect it will return the new URL. Otherwise the URL will +// stay the same. THe returned URL will never be empty. +// +// If there is over 5 redirects the error will be ErrTooManyRedirects. +// ErrNotSuccess, as well as other fetch errors will also be returned. +func getResource(url string) (string, *gemini.Response, error) { res, err := client.Fetch(url) if err != nil { if res != nil { res.Body.Close() } - return err + return url, nil, err } - defer res.Body.Close() - if res.Status != gemini.StatusSuccess { - return ErrNotSuccess + if res.Status == gemini.StatusSuccess { + // No redirects + return url, res, nil } - mediatype, _, err := mime.ParseMediaType(res.Meta) + + parsed, err := urlPkg.Parse(url) if err != nil { - return err + return url, nil, err } - filename := path.Base(url) - feed, ok := GetFeed(mediatype, filename, res.Body) - if !ok { - return ErrNotFeed + + i := 0 + redirs := make([]int, 0) + urls := make([]*urlPkg.URL, 0) + + // Loop through redirects + for (res.Status == gemini.StatusRedirectPermanent || res.Status == gemini.StatusRedirectTemporary) && i < 5 { + redirs = append(redirs, res.Status) + urls = append(urls, parsed) + + tmp, err := parsed.Parse(res.Meta) + if err != nil { + // Redirect URL returned by the server is invalid + return url, nil, err + } + parsed = tmp + + // Make the new request + res, err := client.Fetch(parsed.String()) + if err != nil { + if res != nil { + res.Body.Close() + } + return url, nil, err + } + + i++ } - return AddFeed(url, feed) + + // Two possible options here: + // - Never redirected, got error on start + // - No more redirects, other status code + // - Too many redirects + + if i == 0 { + // Never redirected or succeeded + return url, res, ErrNotSuccess + } + + if i < 5 { + // The server stopped redirecting after <5 redirects + + if res.Status == gemini.StatusSuccess { + // It ended by succeeding + + for j := range redirs { + if redirs[j] == gemini.StatusRedirectTemporary { + if j == 0 { + // First redirect is temporary + return url, res, nil + } + // There were permanent redirects before this one + // Return the URL of the latest permanent redirect + return urls[j-1].String(), res, nil + } + } + // They were all permanent redirects + return urls[len(urls)-1].String(), res, nil + } + + // It stopped because there was a non-redirect, non-success response + return url, res, ErrNotSuccess + } + + // Too many redirects, return original + return url, nil, ErrTooManyRedirects } -func updatePage(url string) error { - res, err := client.Fetch(url) +func updateFeed(url string) { + newURL, res, err := getResource(url) if err != nil { - if res != nil { - res.Body.Close() - } - return err - } - defer res.Body.Close() - - if res.Status != gemini.StatusSuccess { - return ErrNotSuccess + return } - return AddPage(url, res.Body) + mediatype, _, err := mime.ParseMediaType(res.Meta) + if err != nil { + return + } + filename := path.Base(newURL) + feed, ok := GetFeed(mediatype, filename, res.Body) + if !ok { + return + } + + err = AddFeed(newURL, feed) + if url != newURL && err == nil { + // URL has changed, remove old one + Remove(url) //nolint:errcheck + } +} + +func updatePage(url string) { + newURL, res, err := getResource(url) + if err != nil { + return + } + + err = AddPage(newURL, res.Body) + if url != newURL && err == nil { + // URL has changed, remove old one + Remove(url) //nolint:errcheck + } } // updateAll updates all subscriptions using workers.