Merge branch 'master' into mediatype-handlers

This commit is contained in:
makeworld 2020-12-24 16:40:10 -05:00
commit 7c2f59fcdc
37 changed files with 1379 additions and 400 deletions

154
.gitignore vendored
View File

@ -14,15 +14,15 @@ rec.yml
# GIMP files
*.xcf
# Created by https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
# Edit at https://www.toptal.com/developers/gitignore?templates=code,go,linux,macos,windows
# Created by https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows,python
# Edit at https://www.toptal.com/developers/gitignore?templates=code,go,linux,macos,windows,python
### Code ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
### Go ###
# Binaries for programs and plugins
@ -38,6 +38,9 @@ rec.yml
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
### Go Patch ###
/vendor/
/Godeps/
@ -66,6 +69,7 @@ rec.yml
# Icon must end with two \r
Icon
# Thumbnails
._*
@ -85,9 +89,149 @@ Network Trash Folder
Temporary Items
.apdisk
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
pytestdebug.log
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
doc/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
pythonenv*
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# profiling data
.prof
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
@ -110,4 +254,4 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows,python

View File

@ -25,7 +25,6 @@ linters:
- lll
- maligned
- misspell
- nakedret
- nolintlint
- prealloc
- scopelint

View File

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

View File

@ -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 <filename>`. You can rename the file to just `amfora` for easy access, and move it to `/usr/local/bin/`.
Download a binary from the [releases](https://github.com/makeworld-the-better-one/amfora/releases) page. On Unix-based systems you will have to make the file executable with `chmod +x <filename>`. You can rename the file to just `amfora` for easy access, and move it to `/usr/local/bin/`.
On Windows, make sure you click "Advanced > Run anyway" after double-clicking, or something like that.
@ -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.
<details>
<summary>Click to expand</summary>
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

View File

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

View File

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

20
cache/page.go vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,237 @@
package config
import (
"errors"
"strings"
"github.com/gdamore/tcell"
"github.com/spf13/viper"
)
// KeyToNum returns the number on the user's keyboard they pressed,
// using the rune returned when when they press Shift+Num.
// The error is not nil if the provided key is invalid.
func KeyToNum(key rune) (int, error) {
runes := []rune(viper.GetString("keybindings.shift_numbers"))
for i := range runes {
if key == runes[i] {
if i == len(runes)-1 {
// Last key is 0, not 10
return 0, nil
// NOTE: CmdLink[1-90] and CmdTab[1-90] need to be in-order and consecutive
// This property is used to simplify key handling in display/display.go
type Command int
const (
CmdInvalid Command = 0
CmdLink1 = 1
CmdLink2 = 2
CmdLink3 = 3
CmdLink4 = 4
CmdLink5 = 5
CmdLink6 = 6
CmdLink7 = 7
CmdLink8 = 8
CmdLink9 = 9
CmdLink0 = 10
CmdTab1 = 11
CmdTab2 = 12
CmdTab3 = 13
CmdTab4 = 14
CmdTab5 = 15
CmdTab6 = 16
CmdTab7 = 17
CmdTab8 = 18
CmdTab9 = 19
CmdTab0 = 20
CmdBottom = iota
CmdEdit
CmdHome
CmdBookmarks
CmdAddBookmark
CmdSave
CmdReload
CmdBack
CmdForward
CmdPgup
CmdPgdn
CmdNewTab
CmdCloseTab
CmdNextTab
CmdPrevTab
CmdQuit
CmdHelp
CmdSub
CmdAddSub
)
type keyBinding struct {
key tcell.Key
mod tcell.ModMask
r rune
}
// 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
}

View File

@ -0,0 +1,8 @@
# gemini-wiki
This folder contains a Python script that downloads the Amfora [wiki](https://github.com/makeworld-the-better-one/amfora/wiki)
and converts it to gemtext, incorporating the sidebar and footer as well.
The script expects to be run inside the folder where the Gemini version of the wiki should be.
The output of this script can be viewed at `gemini://makeworld.gq/amfora-wiki/`.

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

@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Formatted with black.
import shutil
import subprocess
import sys
import os
import md2gemini
TMP_WIKI_CLONE = "/tmp/amfora.wiki"
def md2gem(markdown):
return md2gemini.md2gemini(
markdown,
links="copy",
plain=False,
strip_html=True,
md_links=True,
link_func=link_func,
)
def link_func(link):
if "://" in link:
# Absolute URL
return link
# Link to other wiki page
return link + ".gmi"
def run_cmd(*args):
proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if proc.returncode != 0:
print(
"Command "
+ " ".join(args)
+ "failed with exit code "
+ str(proc.returncode)
)
print("Output was:")
print()
print(proc.stdout.decode())
sys.exit(1)
# Delete leftover git repo
try:
shutil.rmtree(TMP_WIKI_CLONE)
except FileNotFoundError:
pass
os.mkdir(TMP_WIKI_CLONE)
run_cmd(
"git",
"clone",
"--depth",
"1",
"https://github.com/makeworld-the-better-one/amfora.wiki.git",
TMP_WIKI_CLONE,
)
# Save special files
with open(os.path.join(TMP_WIKI_CLONE, "_Footer.md"), "r") as f:
footer = md2gem(f.read())
# Get files
(_, _, files) = next(os.walk(TMP_WIKI_CLONE))
# Create list of pages
pages = "## Pages\n\n=>.. Home\n"
for file in files:
if file in ["_Footer.md", "_Sidebar.md", "Home.md"]:
continue
if not file.endswith(".md"):
continue
pages += "=>" + file[:-2] + "gmi " + file[:-3].replace("-", " ") + "\n"
pages += "\n\n"
for file in files:
filepath = os.path.join(TMP_WIKI_CLONE, file)
if file in ["_Footer.md", "_Sidebar.md"]:
continue
if not file.endswith(".md"):
# Could be a resource like an image file, copy it
shutil.copyfile(filepath, file)
continue
# Markdown file
with open(filepath, "r") as f:
gemtext = md2gem(f.read())
# Add title, sidebar, footer
gemtext = "# " + file[:-3].replace("-", " ") + "\n\n" + pages + gemtext
gemtext += "\n\n\n\n" + footer
if file == "Home.md":
file = "index.md"
new_name = file[:-2] + "gmi"
with open(new_name, "w") as f:
f.write(gemtext)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
//nolint: lll
package display
import (
@ -21,6 +22,11 @@ var normalizeURLTests = []struct {
{"mailto:example@example.com", "mailto:example@example.com"},
{"magnet:?xt=urn:btih:test", "magnet:?xt=urn:btih:test"},
{"https://example.com", "https://example.com"},
// Fixing URL tests
{"gemini://gemini.circumlunar.space/%64%6f%63%73/%66%61%71%2e%67%6d%69", "gemini://gemini.circumlunar.space/docs/faq.gmi"},
{"gemini://example.com/蛸", "gemini://example.com/%E8%9B%B8"},
{"gemini://gemini.circumlunar.space/%64%6f%63%73/;;.'%66%61%71蛸%2e%67%6d%69", "gemini://gemini.circumlunar.space/docs/%3B%3B.%27faq%E8%9B%B8.gmi"},
{"gemini://example.com/?%2Ch%64ello蛸", "gemini://example.com/?%2Chdello%E8%9B%B8"},
}
func TestNormalizeURL(t *testing.T) {

9
go.mod
View File

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

22
go.sum
View File

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

View File

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

View File

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

37
rr/README.md Normal file
View File

@ -0,0 +1,37 @@
# package `rr`, aka `RestartReader`
This package exists just to hold the `RestartReader` type. It wraps `io.ReadCloser` and implements it. It holds the data from every `Read` in a `[]byte` buffer, and allows you to call `.Restart()`, causing subsequent `Read` calls to start from the beginning again.
See [#140](https://github.com/makeworld-the-better-one/amfora/issues/140) for why this was needed.
Other projects are encouraged to copy this code if it's useful to them, and this package may move out of Amfora if I end up using it in multiple projects.
## License
If you prefer, you can consider the code in this package, and this package only, to be licensed under the MIT license instead.
<details>
<summary>Click to see MIT license terms</summary>
```
Copyright (c) 2020 makeworld
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
</details>

81
rr/rr.go Normal file
View File

@ -0,0 +1,81 @@
package rr
import (
"errors"
"io"
)
var ErrClosed = errors.New("RestartReader: closed")
type RestartReader struct {
r io.ReadCloser
buf []byte
// Where in the buffer we are. If it's equal to len(buf) then the reader
// should be used.
i int64
}
func (rr *RestartReader) Read(p []byte) (n int, err error) {
if rr.buf == nil {
return 0, ErrClosed
}
if rr.i >= int64(len(rr.buf)) {
// Read new data
tmp := make([]byte, len(p))
n, err = rr.r.Read(tmp)
if n > 0 {
rr.buf = append(rr.buf, tmp[:n]...)
copy(p, tmp[:n])
}
rr.i = int64(len(rr.buf))
return
}
// Reading from buffer
bufSize := len(rr.buf[rr.i:])
if len(p) > bufSize {
// It wants more data then what's in the buffer
tmp := make([]byte, len(p)-bufSize)
n, err = rr.r.Read(tmp)
if n > 0 {
rr.buf = append(rr.buf, tmp[:n]...)
}
copy(p, rr.buf[rr.i:])
n += bufSize
rr.i = int64(len(rr.buf))
return
}
// All the required data is in the buffer
end := rr.i + int64(len(p))
copy(p, rr.buf[rr.i:end])
rr.i = end
n = len(p)
err = nil
return
}
// Restart causes subsequent Read calls to read from the beginning, instead
// of where they left off.
func (rr *RestartReader) Restart() {
rr.i = 0
}
// Close clears the buffer and closes the underlying io.ReadCloser, returning
// its error.
func (rr *RestartReader) Close() error {
rr.buf = nil
return rr.r.Close()
}
// NewRestartReader creates and initializes a new RestartReader that reads from
// the provided io.ReadCloser.
func NewRestartReader(r io.ReadCloser) *RestartReader {
return &RestartReader{
r: r,
buf: make([]byte, 0),
}
}

45
rr/rr_test.go Normal file
View File

@ -0,0 +1,45 @@
package rr
import (
"io/ioutil"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var r1 *RestartReader
func reset() {
r1 = NewRestartReader(ioutil.NopCloser(strings.NewReader("1234567890")))
}
func TestRead(t *testing.T) {
reset()
p := make([]byte, 1)
n, err := r1.Read(p)
assert.Equal(t, 1, n, "should read one byte")
assert.Equal(t, nil, err, "should be no error")
assert.Equal(t, []byte{'1'}, p, "should have read one byte, '1'")
}
//nolint
func TestRestart(t *testing.T) {
reset()
p := make([]byte, 4)
r1.Read(p)
r1.Restart()
p = make([]byte, 5)
n, err := r1.Read(p)
assert.Equal(t, []byte("12345"), p, "should read the first 5 bytes again")
assert.Equal(t, 5, n, "should have read 4 bytes")
assert.Equal(t, nil, err, "err should be nil")
r1.Restart()
p = make([]byte, 4)
n, err = r1.Read(p)
assert.Equal(t, []byte("1234"), p, "should read the first 4 bytes again")
assert.Equal(t, 4, n, "should have read 4 bytes")
assert.Equal(t, nil, err, "err should be nil")
}

View File

@ -1,5 +1,7 @@
package structs
import "time"
type Mediatype string
const (
@ -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.

View File

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

View File

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