mirror of
https://github.com/makew0rld/amfora.git
synced 2025-02-02 15:07:34 -05:00
Merge branch 'master' into cview-update
Won't compile yet but conflicts were resolved.
This commit is contained in:
commit
82e33130d8
16
.github/workflows/golangci-lint.yml
vendored
16
.github/workflows/golangci-lint.yml
vendored
@ -1,5 +1,19 @@
|
|||||||
name: golangci-lint
|
name: golangci-lint
|
||||||
on: [push, pull_request]
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.toml'
|
||||||
|
- '**.desktop'
|
||||||
|
- 'LICENSE'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.toml'
|
||||||
|
- '**.desktop'
|
||||||
|
- 'LICENSE'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
golangci:
|
golangci:
|
||||||
name: lint
|
name: lint
|
||||||
|
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@ -1,9 +1,23 @@
|
|||||||
on: [push, pull_request]
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.toml'
|
||||||
|
- '**.desktop'
|
||||||
|
- 'LICENSE'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.toml'
|
||||||
|
- '**.desktop'
|
||||||
|
- 'LICENSE'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
go-version: ['1.13', '1.14', '1.15']
|
go-version: ['1.13', '1.14', '1.15']
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
154
.gitignore
vendored
154
.gitignore
vendored
@ -14,15 +14,15 @@ rec.yml
|
|||||||
# GIMP files
|
# GIMP files
|
||||||
*.xcf
|
*.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 ###
|
### Code ###
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
*.code-workspace
|
||||||
|
|
||||||
### Go ###
|
### Go ###
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
@ -38,6 +38,9 @@ rec.yml
|
|||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
### Go Patch ###
|
### Go Patch ###
|
||||||
/vendor/
|
/vendor/
|
||||||
/Godeps/
|
/Godeps/
|
||||||
@ -66,6 +69,7 @@ rec.yml
|
|||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
||||||
@ -85,9 +89,149 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.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 ###
|
||||||
# Windows thumbnail cache files
|
# Windows thumbnail cache files
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
ehthumbs_vista.db
|
ehthumbs_vista.db
|
||||||
|
|
||||||
@ -110,4 +254,4 @@ $RECYCLE.BIN/
|
|||||||
# Windows shortcuts
|
# Windows shortcuts
|
||||||
*.lnk
|
*.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
|
||||||
|
@ -15,7 +15,6 @@ linters:
|
|||||||
- dupl
|
- dupl
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- exportloopref
|
- exportloopref
|
||||||
- goconst
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- goerr113
|
- goerr113
|
||||||
- gofmt
|
- gofmt
|
||||||
@ -26,7 +25,6 @@ linters:
|
|||||||
- lll
|
- lll
|
||||||
- maligned
|
- maligned
|
||||||
- misspell
|
- misspell
|
||||||
- nakedret
|
|
||||||
- nolintlint
|
- nolintlint
|
||||||
- prealloc
|
- prealloc
|
||||||
- scopelint
|
- scopelint
|
||||||
|
52
CHANGELOG.md
52
CHANGELOG.md
@ -5,15 +5,59 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- **Media type handlers** - open non-text files in another application (#121, #134)
|
||||||
|
- Ability to set custom keybindings in config (#135)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Update cview to `36671ba7d31c2287748e22966a92c5e94ff850cc` for large perf and feature updates (#107)
|
- Update cview to `1af0da7606b8476944b5740bb4f0b711aaf2a1df` for large perf and feature updates (#107)
|
||||||
- Update to tcell v2 (depencency of cview)
|
- Update to tcell v2 (dependency of cview)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Don't use cache when URL is typed in bottom bar (#159)
|
||||||
|
- Fix downloading of pages that are too large or timed out
|
||||||
- More reliable start, no more flash of unindented text, or text that stays unindented (#107)
|
- More reliable start, no more flash of unindented text, or text that stays unindented (#107)
|
||||||
|
|
||||||
|
|
||||||
## [v1.6.0] - 2020-11-04
|
## [1.7.2] - 2020-12-21
|
||||||
|
### Fixed
|
||||||
|
- Viewing subscriptions after subscribing to a certain user page won't crash Amfora (#157)
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.1] - 2020-12-21
|
||||||
|
### Fixed
|
||||||
|
- Fixed bug that caused Amfora to crash when subscribing to a page (#151)
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.0] - 2020-12-20
|
||||||
|
### Added
|
||||||
|
- **Subscriptions** to feeds and page changes (#61)
|
||||||
|
- Opening local files with `file://` URIs (#103, #117)
|
||||||
|
- `show_link` option added in config to optionally see the URL (#133)
|
||||||
|
- Support for Unicode in domain names (IDNs)
|
||||||
|
- Unnecessarily encoded characters in URLs will be decoded (#138)
|
||||||
|
- URLs are NFC-normalized before any processing (#138)
|
||||||
|
- Links to the wiki in the new tab
|
||||||
|
- Cache times out after 30 minutes by default (#110)
|
||||||
|
- `about:version` page (#126)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated [go-gemini](https://github.com/makeworld-the-better-one/go-gemini) to v0.11.0
|
||||||
|
- Supports CN-only wildcard certs
|
||||||
|
- Time out when header takes too long
|
||||||
|
- Preformatted text is now light yellow by default
|
||||||
|
- Downloading a file no longer uses a second request
|
||||||
|
- You can go back to the new tab page in history (#96)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Single quotes are used in the default config for commands and paths so that Windows paths with backslashes will be parsed correctly
|
||||||
|
- Downloading now uses proxies when appropriate
|
||||||
|
- User-entered URLs with invalid characters will be percent-encoded (#138)
|
||||||
|
- Custom downloads dir is actually used (#148)
|
||||||
|
- Empty quote lines no longer disappear
|
||||||
|
|
||||||
|
|
||||||
|
## [1.6.0] - 2020-11-04
|
||||||
### Added
|
### Added
|
||||||
- **Support client certificates** through config (#112)
|
- **Support client certificates** through config (#112)
|
||||||
- `ansi` config setting, to disable ANSI colors in pages (#79, #86)
|
- `ansi` config setting, to disable ANSI colors in pages (#79, #86)
|
||||||
@ -39,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Make the `..` command work lke it used to in v1.4.0
|
- Make the `..` command work lke it used to in v1.4.0
|
||||||
|
|
||||||
|
|
||||||
## [v1.5.0] - 2020-09-01
|
## [1.5.0] - 2020-09-01
|
||||||
### Added
|
### Added
|
||||||
- **Proxy support** - see the `[proxies]` section in the config (#66, #80)
|
- **Proxy support** - see the `[proxies]` section in the config (#66, #80)
|
||||||
- **Emoji favicons** can now be seen if `emoji_favicons` is enabled in the config (#62)
|
- **Emoji favicons** can now be seen if `emoji_favicons` is enabled in the config (#62)
|
||||||
|
4
Makefile
4
Makefile
@ -21,8 +21,8 @@ clean:
|
|||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: amfora amfora.desktop
|
install: amfora amfora.desktop
|
||||||
install -Dm 755 amfora $(PREFIX)/bin/amfora
|
install -m 755 amfora $(PREFIX)/bin/amfora
|
||||||
install -Dm 644 amfora.desktop $(PREFIX)/share/applications/amfora.desktop
|
install -m 644 amfora.desktop $(PREFIX)/share/applications/amfora.desktop
|
||||||
|
|
||||||
.PHONY: uninstall
|
.PHONY: uninstall
|
||||||
uninstall:
|
uninstall:
|
||||||
|
4
NOTES.md
4
NOTES.md
@ -3,10 +3,6 @@
|
|||||||
## Issues
|
## Issues
|
||||||
- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL
|
- URL for each tab should not be stored as a string - in the current code there's lots of reparsing the URL
|
||||||
|
|
||||||
|
|
||||||
## Regressions
|
|
||||||
|
|
||||||
|
|
||||||
## Upstream Bugs
|
## Upstream Bugs
|
||||||
- Wrapping messes up on brackets
|
- Wrapping messes up on brackets
|
||||||
- Filed [issue 23](https://gitlab.com/tslocum/cview/-/issues/23)
|
- Filed [issue 23](https://gitlab.com/tslocum/cview/-/issues/23)
|
||||||
|
74
README.md
74
README.md
@ -5,7 +5,6 @@
|
|||||||
<h6>Image modified from: amphora by Alvaro Cabrera from the Noun Project</h6>
|
<h6>Image modified from: amphora by Alvaro Cabrera from the Noun Project</h6>
|
||||||
</center>
|
</center>
|
||||||
|
|
||||||
[](https://travis-ci.com/github/makeworld-the-better-one/amfora)
|
|
||||||
[](https://goreportcard.com/report/github.com/makeworld-the-better-one/amfora)
|
[](https://goreportcard.com/report/github.com/makeworld-the-better-one/amfora)
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
@ -21,13 +20,13 @@ Amfora aims to be the best looking [Gemini](https://gemini.circumlunar.space/) c
|
|||||||
|
|
||||||
It also aims to be completely cross platform, with full Windows support. If you're on Windows, I would not recommend using the default terminal software. Use [Windows Terminal](https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701) instead, and make sure it [works with UTF-8](https://akr.am/blog/posts/using-utf-8-in-the-windows-terminal). Note that some of the application colors might not display correctly on Windows, but all functionality will still work.
|
It 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
|
## Installation
|
||||||
|
|
||||||
### Binary
|
### 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.
|
On Windows, make sure you click "Advanced > Run anyway" after double-clicking, or something like that.
|
||||||
|
|
||||||
@ -37,7 +36,7 @@ curl -sSL https://raw.githubusercontent.com/makeworld-the-better-one/amfora/mast
|
|||||||
update-desktop-database ~/.local/share/applications
|
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
|
### Arch Linux
|
||||||
@ -50,7 +49,7 @@ sudo pacman -S amfora
|
|||||||
|
|
||||||
### Homebrew
|
### 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 tap makeworld-the-better-one/tap
|
||||||
brew install amfora
|
brew install amfora
|
||||||
@ -60,9 +59,29 @@ You can update it with:
|
|||||||
brew upgrade amfora
|
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
|
### From Source
|
||||||
|
|
||||||
This section is for advanced users who want to install the latest (possibly unstable) version of Amfora.
|
This section is for advanced users who want to install the latest (possibly unstable) version of Amfora.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- Go 1.13 or later
|
- Go 1.13 or later
|
||||||
- GNU Make
|
- GNU Make
|
||||||
@ -79,6 +98,12 @@ sudo make install # If you want to install the binary for all users
|
|||||||
|
|
||||||
Because you installed with the Makefile, running `amfora -v` will tell you exactly what commit the binary was built from.
|
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:
|
MacOS users can also use [Homebrew](https://brew.sh/) to install the latest commit of Amfora:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -90,17 +115,8 @@ You can update it with:
|
|||||||
brew upgrade --fetch-HEAD amfora
|
brew upgrade --fetch-HEAD amfora
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
</details>
|
||||||
|
|
||||||
Just call `amfora` or `amfora <url>` on the terminal. On Windows it might be `amfora.exe` instead.
|
|
||||||
|
|
||||||
To determine the version, you can run `amfora --version` or `amfora -v`.
|
|
||||||
|
|
||||||
The project keeps many standard terminal keybindings and is intuitive. Press <kbd>?</kbd> inside the application to pull up the help menu with a list of all the keybindings, and <kbd>Esc</kbd> to leave it. If you have used Bombadillo you will find it similar.
|
|
||||||
|
|
||||||
It is designed with large terminals in mind, but should look and work well at any reasonable terminal size.
|
|
||||||
|
|
||||||
It was tested with left-to-right languages, and will likely not work as well with right-to-left languages like Arabic.
|
|
||||||
|
|
||||||
## Features / Roadmap
|
## Features / Roadmap
|
||||||
Features in *italics* are in the master branch, but not in the latest release.
|
Features in *italics* are in the master branch, but not in the latest release.
|
||||||
@ -116,6 +132,7 @@ Features in *italics* are in the master branch, but not in the latest release.
|
|||||||
- [x] Bookmarks
|
- [x] Bookmarks
|
||||||
- [x] Download pages and arbitrary data
|
- [x] Download pages and arbitrary data
|
||||||
- [x] Theming
|
- [x] Theming
|
||||||
|
- Check out the [user contributed themes](https://github.com/makeworld-the-better-one/amfora/tree/master/contrib/themes)!
|
||||||
- [x] Emoji favicons
|
- [x] Emoji favicons
|
||||||
- See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details
|
- See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details
|
||||||
- Disabled by default, enable in config
|
- Disabled by default, enable in config
|
||||||
@ -127,29 +144,21 @@ Features in *italics* are in the master branch, but not in the latest release.
|
|||||||
- Manage and browse them
|
- Manage and browse them
|
||||||
- Similar to [Kristall](https://github.com/MasterQ32/kristall)
|
- Similar to [Kristall](https://github.com/MasterQ32/kristall)
|
||||||
- https://lists.orbitalfox.eu/archives/gemini/2020/001400.html
|
- https://lists.orbitalfox.eu/archives/gemini/2020/001400.html
|
||||||
- [ ] Subscribe to RSS and Atom feeds and display them
|
- [x] Subscriptions
|
||||||
- Subscribing to page changes, similar to how Spacewalk works, will also be supported
|
- Subscribing to RSS, Atom, and [JSON Feeds](https://jsonfeed.org/) are all supported
|
||||||
- *In progress on `feeds` branch*
|
- So is subscribing to a page, to know when it changes
|
||||||
|
- [x] *Open non-text files in another application*
|
||||||
|
- [x] *Ability to stream content instead of downloading it first*
|
||||||
- [ ] Stream support
|
- [ ] Stream support
|
||||||
- [ ] Table of contents for pages
|
- [ ] Table of contents for pages
|
||||||
- [ ] Search in pages with <kbd>Ctrl-F</kbd>
|
- [ ] Search in pages with <kbd>Ctrl-F</kbd>
|
||||||
- [ ] Support Markdown rendering
|
- [ ] Support Markdown rendering
|
||||||
- [ ] History browser
|
- [ ] Persistent history
|
||||||
|
|
||||||
## Configuration
|
|
||||||
The config file is written in the intuitive [TOML](https://github.com/toml-lang/toml) file format. See [default-config.toml](./default-config.toml) for details. By default this file is available at `~/.config/amfora/config.toml`, or `$XDG_CONFIG_HOME/amfora/config.toml`, if that variable is set.
|
|
||||||
|
|
||||||
On Windows, the file is in `%APPDATA%\amfora\config.toml`, which usually expands to `C:\Users\<username>\AppData\Roaming\amfora\config.toml`.
|
## Usage & Configuration
|
||||||
|
Please see [the wiki](https://github.com/makeworld-the-better-one/amfora/wiki) for an introduction on how to use Amfora and configure it.
|
||||||
|
|
||||||
## Client Certificates
|
|
||||||
|
|
||||||
Amfora has early support for client certs. Eventually Amfora will be able to generate them itself, but for you can do it by using OpenSSL (not Windows friendly):
|
|
||||||
|
|
||||||
```shell
|
|
||||||
openssl req -new -subj "/CN=username" -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -out cert.pem -keyout key.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create a certificate and key file, that can be renamed and moved as you like. See the configuration section above for how to edit your config file to tell Amfora about them.
|
|
||||||
|
|
||||||
## Known Bugs
|
## Known Bugs
|
||||||
|
|
||||||
@ -166,8 +175,9 @@ Amfora ❤️ open source!
|
|||||||
- It uses [tcell](https://github.com/gdamore/tcell) for low level terminal operations
|
- It uses [tcell](https://github.com/gdamore/tcell) for low level terminal operations
|
||||||
- [Viper](https://github.com/spf13/viper) for configuration and TOFU storing
|
- [Viper](https://github.com/spf13/viper) for configuration and TOFU storing
|
||||||
- [go-gemini](https://github.com/makeworld-the-better-one/go-gemini), my forked and updated Gemini client/server library
|
- [go-gemini](https://github.com/makeworld-the-better-one/go-gemini), my forked and updated Gemini client/server library
|
||||||
- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar)
|
- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar) - pull request [here](https://github.com/schollz/progressbar/pull/69)
|
||||||
- [go-humanize](https://github.com/dustin/go-humanize)
|
- [go-humanize](https://github.com/dustin/go-humanize)
|
||||||
|
- My [gofeed fork](https://github.com/makeworld-the-better-one/gofeed) - pull request [here](https://github.com/mmcdole/gofeed/pull/164)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details.
|
This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details.
|
||||||
|
@ -11,3 +11,7 @@ Thank you to the following contributors, who have helped make Amfora great. FOSS
|
|||||||
- Matt Caroll (@ohiolab)
|
- Matt Caroll (@ohiolab)
|
||||||
- Patryk Niedźwiedziński (@pniedzwiedzinski)
|
- Patryk Niedźwiedziński (@pniedzwiedzinski)
|
||||||
- Trevor Slocum (@tsclocum)
|
- Trevor Slocum (@tsclocum)
|
||||||
|
- Mattias Jadelius (@jedthehumanoid)
|
||||||
|
- Lokesh Krishna (@lokesh-krishna)
|
||||||
|
- Jeff (@phaedrus-jaf)
|
||||||
|
- Stephen Robinson (@sudobash1)
|
||||||
|
15
amfora.go
15
amfora.go
@ -4,12 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/makeworld-the-better-one/amfora/client"
|
||||||
"github.com/makeworld-the-better-one/amfora/config"
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
"github.com/makeworld-the-better-one/amfora/display"
|
"github.com/makeworld-the-better-one/amfora/display"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/subscriptions"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
version = "v1.6.0"
|
version = "v1.7.2"
|
||||||
commit = "unknown"
|
commit = "unknown"
|
||||||
builtBy = "unknown"
|
builtBy = "unknown"
|
||||||
)
|
)
|
||||||
@ -39,9 +41,16 @@ func main() {
|
|||||||
|
|
||||||
err := config.Init()
|
err := config.Init()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Config error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
err = subscriptions.Init()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "subscriptions.json error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Init()
|
||||||
|
|
||||||
// Initalize lower-level cview app
|
// Initalize lower-level cview app
|
||||||
if err = display.App.Init(); err != nil {
|
if err = display.App.Init(); err != nil {
|
||||||
@ -49,7 +58,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Amfora's settings
|
// Initialize Amfora's settings
|
||||||
display.Init()
|
display.Init(version, commit, builtBy)
|
||||||
display.NewTab()
|
display.NewTab()
|
||||||
if len(os.Args[1:]) > 0 {
|
if len(os.Args[1:]) > 0 {
|
||||||
display.URL(os.Args[1])
|
display.URL(os.Args[1])
|
||||||
|
0
cache/favicons.go → cache/favicon.go
vendored
0
cache/favicons.go → cache/favicon.go
vendored
26
cache/cache.go → cache/page.go
vendored
26
cache/cache.go → cache/page.go
vendored
@ -1,11 +1,10 @@
|
|||||||
// Package cache provides an interface for a cache of strings, aka text/gemini pages, and redirects.
|
// Package cache provides an interface for a cache of strings, aka text/gemini pages, and redirects.
|
||||||
// It is fully thread safe.
|
// It is fully thread safe.
|
||||||
// The redirect cache is not limited.
|
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/makeworld-the-better-one/amfora/structs"
|
"github.com/makeworld-the-better-one/amfora/structs"
|
||||||
)
|
)
|
||||||
@ -15,6 +14,7 @@ var urls = make([]string, 0) // Duplicate of the keys in the `page
|
|||||||
var maxPages = 0 // Max allowed number of pages in cache
|
var maxPages = 0 // Max allowed number of pages in cache
|
||||||
var maxSize = 0 // Max allowed cache size in bytes
|
var maxSize = 0 // Max allowed cache size in bytes
|
||||||
var lock = sync.RWMutex{}
|
var lock = sync.RWMutex{}
|
||||||
|
var timeout = time.Duration(0)
|
||||||
|
|
||||||
// SetMaxPages sets the max number of pages the cache can hold.
|
// SetMaxPages sets the max number of pages the cache can hold.
|
||||||
// A value <= 0 means infinite pages.
|
// A value <= 0 means infinite pages.
|
||||||
@ -22,12 +22,22 @@ func SetMaxPages(max int) {
|
|||||||
maxPages = max
|
maxPages = max
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMaxSize sets the max size the cache can be, in bytes.
|
// SetMaxSize sets the max size the page cache can be, in bytes.
|
||||||
// A value <= 0 means infinite size.
|
// A value <= 0 means infinite size.
|
||||||
func SetMaxSize(max int) {
|
func SetMaxSize(max int) {
|
||||||
maxSize = max
|
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 {
|
func removeIndex(s []string, i int) []string {
|
||||||
s[len(s)-1], s[i] = s[i], s[len(s)-1]
|
s[len(s)-1], s[i] = s[i], s[len(s)-1]
|
||||||
return s[:len(s)-1]
|
return s[:len(s)-1]
|
||||||
@ -48,7 +58,7 @@ func removeURL(url string) {
|
|||||||
// If your page is larger than the max cache size, the provided page
|
// If your page is larger than the max cache size, the provided page
|
||||||
// will silently not be added to the cache.
|
// will silently not be added to the cache.
|
||||||
func AddPage(p *structs.Page) {
|
func AddPage(p *structs.Page) {
|
||||||
if p.URL == "" || strings.HasPrefix(p.URL, "about:") {
|
if p.URL == "" {
|
||||||
// Just in case, these pages shouldn't be cached
|
// Just in case, these pages shouldn't be cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -112,10 +122,14 @@ func NumPages() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPage returns the page struct, and a bool indicating if the page was in the cache or not.
|
// 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) {
|
func GetPage(url string) (*structs.Page, bool) {
|
||||||
lock.RLock()
|
lock.RLock()
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
|
|
||||||
p, ok := pages[url]
|
p, ok := pages[url]
|
||||||
return p, ok
|
if ok && (timeout == 0 || time.Since(p.MadeAt) < timeout) {
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
@ -5,17 +5,34 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/makeworld-the-better-one/go-gemini"
|
"github.com/makeworld-the-better-one/go-gemini"
|
||||||
"github.com/mitchellh/go-homedir"
|
"github.com/mitchellh/go-homedir"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var certCache = make(map[string][][]byte)
|
var (
|
||||||
|
certCache = make(map[string][][]byte)
|
||||||
|
certCacheMu = &sync.RWMutex{}
|
||||||
|
|
||||||
|
fetchClient *gemini.Client
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
fetchClient = &gemini.Client{
|
||||||
|
ConnectTimeout: 10 * time.Second, // Default is 15
|
||||||
|
ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func clientCert(host string) ([]byte, []byte) {
|
func clientCert(host string) ([]byte, []byte) {
|
||||||
if cert := certCache[host]; cert != nil {
|
certCacheMu.RLock()
|
||||||
return cert[0], cert[1]
|
pair, ok := certCache[host]
|
||||||
|
certCacheMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return pair[0], pair[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand paths starting with ~/
|
// Expand paths starting with ~/
|
||||||
@ -28,22 +45,30 @@ func clientCert(host string) ([]byte, []byte) {
|
|||||||
keyPath = viper.GetString("auth.keys." + host)
|
keyPath = viper.GetString("auth.keys." + host)
|
||||||
}
|
}
|
||||||
if certPath == "" && keyPath == "" {
|
if certPath == "" && keyPath == "" {
|
||||||
|
certCacheMu.Lock()
|
||||||
certCache[host] = [][]byte{nil, nil}
|
certCache[host] = [][]byte{nil, nil}
|
||||||
|
certCacheMu.Unlock()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cert, err := ioutil.ReadFile(certPath)
|
cert, err := ioutil.ReadFile(certPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
certCacheMu.Lock()
|
||||||
certCache[host] = [][]byte{nil, nil}
|
certCache[host] = [][]byte{nil, nil}
|
||||||
|
certCacheMu.Unlock()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
key, err := ioutil.ReadFile(keyPath)
|
key, err := ioutil.ReadFile(keyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
certCacheMu.Lock()
|
||||||
certCache[host] = [][]byte{nil, nil}
|
certCache[host] = [][]byte{nil, nil}
|
||||||
|
certCacheMu.Unlock()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
certCacheMu.Lock()
|
||||||
certCache[host] = [][]byte{cert, key}
|
certCache[host] = [][]byte{cert, key}
|
||||||
|
certCacheMu.Unlock()
|
||||||
return cert, key
|
return cert, key
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,18 +78,16 @@ func HasClientCert(host string) bool {
|
|||||||
return cert != nil
|
return cert != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch returns response data and an error.
|
func fetch(u string, c *gemini.Client) (*gemini.Response, error) {
|
||||||
// The error text is human friendly and should be displayed.
|
|
||||||
func Fetch(u string) (*gemini.Response, error) {
|
|
||||||
parsed, _ := url.Parse(u)
|
parsed, _ := url.Parse(u)
|
||||||
cert, key := clientCert(parsed.Host)
|
cert, key := clientCert(parsed.Host)
|
||||||
|
|
||||||
var res *gemini.Response
|
var res *gemini.Response
|
||||||
var err error
|
var err error
|
||||||
if cert != nil {
|
if cert != nil {
|
||||||
res, err = gemini.FetchWithCert(u, cert, key)
|
res, err = c.FetchWithCert(u, cert, key)
|
||||||
} else {
|
} else {
|
||||||
res, err = gemini.Fetch(u)
|
res, err = c.Fetch(u)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -78,17 +101,22 @@ func Fetch(u string) (*gemini.Response, error) {
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchWithProxy is the same as Fetch, but uses a proxy.
|
// Fetch returns response data and an error.
|
||||||
func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) {
|
// The error text is human friendly and should be displayed.
|
||||||
|
func Fetch(u string) (*gemini.Response, error) {
|
||||||
|
return fetch(u, fetchClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) {
|
||||||
parsed, _ := url.Parse(u)
|
parsed, _ := url.Parse(u)
|
||||||
cert, key := clientCert(parsed.Host)
|
cert, key := clientCert(parsed.Host)
|
||||||
|
|
||||||
var res *gemini.Response
|
var res *gemini.Response
|
||||||
var err error
|
var err error
|
||||||
if cert != nil {
|
if cert != nil {
|
||||||
res, err = gemini.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key)
|
res, err = c.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key)
|
||||||
} else {
|
} else {
|
||||||
res, err = gemini.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u)
|
res, err = c.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -102,3 +130,8 @@ func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error
|
|||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchWithProxy is the same as Fetch, but uses a proxy.
|
||||||
|
func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) {
|
||||||
|
return fetchWithProxy(proxyHostname, proxyPort, u, fetchClient)
|
||||||
|
}
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/makeworld-the-better-one/amfora/config"
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
@ -21,6 +22,12 @@ var ErrTofu = errors.New("server cert does not match TOFU database")
|
|||||||
|
|
||||||
var tofuStore = config.TofuStore
|
var tofuStore = config.TofuStore
|
||||||
|
|
||||||
|
// tofuStoreMu protects tofuStore, since viper is not thread-safe.
|
||||||
|
// See this issue for details: https://github.com/spf13/viper/issues/268
|
||||||
|
// This is needed because Gemini requests may happen concurrently and
|
||||||
|
// call on the funcs on this file.
|
||||||
|
var tofuStoreMu = sync.RWMutex{}
|
||||||
|
|
||||||
// idKey returns the config/viper key needed to retrieve
|
// idKey returns the config/viper key needed to retrieve
|
||||||
// a cert's ID / fingerprint.
|
// a cert's ID / fingerprint.
|
||||||
func idKey(domain string, port string) string {
|
func idKey(domain string, port string) string {
|
||||||
@ -38,6 +45,9 @@ func expiryKey(domain string, port string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadTofuEntry(domain string, port string) (string, time.Time, error) {
|
func loadTofuEntry(domain string, port string) (string, time.Time, error) {
|
||||||
|
tofuStoreMu.RLock()
|
||||||
|
defer tofuStoreMu.RUnlock()
|
||||||
|
|
||||||
id := tofuStore.GetString(idKey(domain, port)) // Fingerprint
|
id := tofuStore.GetString(idKey(domain, port)) // Fingerprint
|
||||||
if len(id) != sha256.Size*2 {
|
if len(id) != sha256.Size*2 {
|
||||||
// Not set, or invalid
|
// Not set, or invalid
|
||||||
@ -68,6 +78,9 @@ func origCertID(cert *x509.Certificate) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveTofuEntry(domain, port string, cert *x509.Certificate) {
|
func saveTofuEntry(domain, port string, cert *x509.Certificate) {
|
||||||
|
tofuStoreMu.Lock()
|
||||||
|
defer tofuStoreMu.Unlock()
|
||||||
|
|
||||||
tofuStore.Set(idKey(domain, port), certID(cert))
|
tofuStore.Set(idKey(domain, port), certID(cert))
|
||||||
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
|
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
|
||||||
tofuStore.WriteConfig() //nolint:errcheck // Not an issue if it's not saved, only cached data
|
tofuStore.WriteConfig() //nolint:errcheck // Not an issue if it's not saved, only cached data
|
||||||
@ -90,9 +103,10 @@ func handleTofu(domain, port string, cert *x509.Certificate) bool {
|
|||||||
// Same cert as the one stored
|
// Same cert as the one stored
|
||||||
|
|
||||||
// Store expiry again in case it changed
|
// Store expiry again in case it changed
|
||||||
|
tofuStoreMu.Lock()
|
||||||
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
|
tofuStore.Set(expiryKey(domain, port), cert.NotAfter.UTC())
|
||||||
tofuStore.WriteConfig() //nolint:errcheck
|
tofuStore.WriteConfig() //nolint:errcheck
|
||||||
|
tofuStoreMu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if origCertID(cert) == id {
|
if origCertID(cert) == id {
|
||||||
@ -117,5 +131,8 @@ func ResetTofuEntry(domain, port string, cert *x509.Certificate) {
|
|||||||
// GetExpiry returns the stored expiry date for the given host.
|
// GetExpiry returns the stored expiry date for the given host.
|
||||||
// The time will be empty (zero) if there is not expiry date stored for that host.
|
// The time will be empty (zero) if there is not expiry date stored for that host.
|
||||||
func GetExpiry(domain, port string) time.Time {
|
func GetExpiry(domain, port string) time.Time {
|
||||||
|
tofuStoreMu.RLock()
|
||||||
|
defer tofuStoreMu.RUnlock()
|
||||||
|
|
||||||
return tofuStore.GetTime(expiryKey(domain, port))
|
return tofuStore.GetTime(expiryKey(domain, port))
|
||||||
}
|
}
|
||||||
|
235
config/config.go
235
config/config.go
@ -38,10 +38,23 @@ var bkmkDir string
|
|||||||
var bkmkPath string
|
var bkmkPath string
|
||||||
|
|
||||||
var DownloadsDir string
|
var DownloadsDir string
|
||||||
|
var TempDownloadsDir string
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
var subscriptionDir string
|
||||||
|
var SubscriptionPath string
|
||||||
|
|
||||||
// Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config.
|
// Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config.
|
||||||
var HTTPCommand []string
|
var HTTPCommand []string
|
||||||
|
|
||||||
|
type MediaHandler struct {
|
||||||
|
Cmd []string
|
||||||
|
NoPrompt bool
|
||||||
|
Stream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var MediaHandlers = make(map[string]MediaHandler)
|
||||||
|
|
||||||
func Init() error {
|
func Init() error {
|
||||||
|
|
||||||
// *** Set paths ***
|
// *** Set paths ***
|
||||||
@ -96,6 +109,22 @@ func Init() error {
|
|||||||
}
|
}
|
||||||
bkmkPath = filepath.Join(bkmkDir, "bookmarks.toml")
|
bkmkPath = filepath.Join(bkmkDir, "bookmarks.toml")
|
||||||
|
|
||||||
|
// Feeds dir and path
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// In APPDATA beside other Amfora files
|
||||||
|
subscriptionDir = amforaAppData
|
||||||
|
} else {
|
||||||
|
// XDG data dir on POSIX systems
|
||||||
|
xdg_data, ok := os.LookupEnv("XDG_DATA_HOME")
|
||||||
|
if ok && strings.TrimSpace(xdg_data) != "" {
|
||||||
|
subscriptionDir = filepath.Join(xdg_data, "amfora")
|
||||||
|
} else {
|
||||||
|
// Default to ~/.local/share/amfora
|
||||||
|
subscriptionDir = filepath.Join(home, ".local", "share", "amfora")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SubscriptionPath = filepath.Join(subscriptionDir, "subscriptions.json")
|
||||||
|
|
||||||
// *** Create necessary files and folders ***
|
// *** Create necessary files and folders ***
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
@ -131,13 +160,114 @@ func Init() error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
// Feeds
|
||||||
|
err = os.MkdirAll(subscriptionDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// *** Setup vipers ***
|
||||||
|
|
||||||
|
TofuStore.SetConfigFile(tofuDBPath)
|
||||||
|
TofuStore.SetConfigType("toml")
|
||||||
|
err = TofuStore.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
BkmkStore.SetConfigFile(bkmkPath)
|
||||||
|
BkmkStore.SetConfigType("toml")
|
||||||
|
err = BkmkStore.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
BkmkStore.Set("DO NOT TOUCH", true)
|
||||||
|
err = BkmkStore.WriteConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup main config
|
||||||
|
|
||||||
|
viper.SetDefault("a-general.home", "gemini://gemini.circumlunar.space")
|
||||||
|
viper.SetDefault("a-general.auto_redirect", false)
|
||||||
|
viper.SetDefault("a-general.http", "default")
|
||||||
|
viper.SetDefault("a-general.search", "gemini://gus.guru/search")
|
||||||
|
viper.SetDefault("a-general.color", true)
|
||||||
|
viper.SetDefault("a-general.ansi", true)
|
||||||
|
viper.SetDefault("a-general.bullets", true)
|
||||||
|
viper.SetDefault("a-general.show_link", false)
|
||||||
|
viper.SetDefault("a-general.left_margin", 0.15)
|
||||||
|
viper.SetDefault("a-general.max_width", 100)
|
||||||
|
viper.SetDefault("a-general.downloads", "")
|
||||||
|
viper.SetDefault("a-general.temp_downloads", "")
|
||||||
|
viper.SetDefault("a-general.page_max_size", 2097152)
|
||||||
|
viper.SetDefault("a-general.page_max_time", 10)
|
||||||
|
viper.SetDefault("a-general.emoji_favicons", false)
|
||||||
|
viper.SetDefault("keybindings.bind_reload", []string{"R", "Ctrl-R"})
|
||||||
|
viper.SetDefault("keybindings.bind_home", "Backspace")
|
||||||
|
viper.SetDefault("keybindings.bind_bookmarks", "Ctrl-B")
|
||||||
|
viper.SetDefault("keybindings.bind_add_bookmark", "Ctrl-D")
|
||||||
|
viper.SetDefault("keybindings.bind_sub", "Ctrl-A")
|
||||||
|
viper.SetDefault("keybindings.bind_add_sub", "Ctrl-X")
|
||||||
|
viper.SetDefault("keybindings.bind_save", "Ctrl-S")
|
||||||
|
viper.SetDefault("keybindings.bind_pgup", []string{"PgUp", "u"})
|
||||||
|
viper.SetDefault("keybindings.bind_pgdn", []string{"PgDn", "d"})
|
||||||
|
viper.SetDefault("keybindings.bind_bottom", "Space")
|
||||||
|
viper.SetDefault("keybindings.bind_edit", "e")
|
||||||
|
viper.SetDefault("keybindings.bind_back", []string{"b", "Alt-Left"})
|
||||||
|
viper.SetDefault("keybindings.bind_forward", []string{"f", "Alt-Right"})
|
||||||
|
viper.SetDefault("keybindings.bind_new_tab", "Ctrl-T")
|
||||||
|
viper.SetDefault("keybindings.bind_close_tab", "Ctrl-W")
|
||||||
|
viper.SetDefault("keybindings.bind_next_tab", "F2")
|
||||||
|
viper.SetDefault("keybindings.bind_prev_tab", "F1")
|
||||||
|
viper.SetDefault("keybindings.bind_quit", []string{"Ctrl-C", "Ctrl-Q", "q"})
|
||||||
|
viper.SetDefault("keybindings.bind_help", "?")
|
||||||
|
viper.SetDefault("keybindings.bind_link1", "1")
|
||||||
|
viper.SetDefault("keybindings.bind_link2", "2")
|
||||||
|
viper.SetDefault("keybindings.bind_link3", "3")
|
||||||
|
viper.SetDefault("keybindings.bind_link4", "4")
|
||||||
|
viper.SetDefault("keybindings.bind_link5", "5")
|
||||||
|
viper.SetDefault("keybindings.bind_link6", "6")
|
||||||
|
viper.SetDefault("keybindings.bind_link7", "7")
|
||||||
|
viper.SetDefault("keybindings.bind_link8", "8")
|
||||||
|
viper.SetDefault("keybindings.bind_link9", "9")
|
||||||
|
viper.SetDefault("keybindings.bind_link0", "0")
|
||||||
|
viper.SetDefault("keybindings.bind_tab1", "!")
|
||||||
|
viper.SetDefault("keybindings.bind_tab2", "@")
|
||||||
|
viper.SetDefault("keybindings.bind_tab3", "#")
|
||||||
|
viper.SetDefault("keybindings.bind_tab4", "$")
|
||||||
|
viper.SetDefault("keybindings.bind_tab5", "%")
|
||||||
|
viper.SetDefault("keybindings.bind_tab6", "^")
|
||||||
|
viper.SetDefault("keybindings.bind_tab7", "&")
|
||||||
|
viper.SetDefault("keybindings.bind_tab8", "*")
|
||||||
|
viper.SetDefault("keybindings.bind_tab9", "(")
|
||||||
|
viper.SetDefault("keybindings.bind_tab0", ")")
|
||||||
|
viper.SetDefault("keybindings.shift_numbers", "")
|
||||||
|
viper.SetDefault("url-handlers.other", "off")
|
||||||
|
viper.SetDefault("cache.max_size", 0)
|
||||||
|
viper.SetDefault("cache.max_pages", 20)
|
||||||
|
viper.SetDefault("cache.timeout", 1800)
|
||||||
|
viper.SetDefault("subscriptions.popup", true)
|
||||||
|
viper.SetDefault("subscriptions.update_interval", 1800)
|
||||||
|
viper.SetDefault("subscriptions.workers", 3)
|
||||||
|
viper.SetDefault("subscriptions.entries_per_page", 20)
|
||||||
|
|
||||||
|
viper.SetConfigFile(configPath)
|
||||||
|
viper.SetConfigType("toml")
|
||||||
|
err = viper.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the key bindings
|
||||||
|
KeyInit()
|
||||||
|
|
||||||
// *** Downloads paths, setup, and creation ***
|
// *** Downloads paths, setup, and creation ***
|
||||||
|
|
||||||
// Setup downloads dir
|
// Setup downloads dir
|
||||||
if viper.GetString("a-general.downloads") == "" {
|
if viper.GetString("a-general.downloads") == "" {
|
||||||
// Find default Downloads dir
|
// Find default Downloads dir
|
||||||
// This seems to work for all OSes?
|
|
||||||
if userdirs.Download == "" {
|
if userdirs.Download == "" {
|
||||||
DownloadsDir = filepath.Join(home, "Downloads")
|
DownloadsDir = filepath.Join(home, "Downloads")
|
||||||
} else {
|
} else {
|
||||||
@ -169,57 +299,40 @@ func Init() error {
|
|||||||
DownloadsDir = dDir
|
DownloadsDir = dDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// *** Setup vipers ***
|
// Setup temporary downloads dir
|
||||||
|
if viper.GetString("a-general.temp_downloads") == "" {
|
||||||
|
TempDownloadsDir = filepath.Join(os.TempDir(), "amfora_temp")
|
||||||
|
|
||||||
TofuStore.SetConfigFile(tofuDBPath)
|
// Make sure it exists
|
||||||
TofuStore.SetConfigType("toml")
|
err = os.MkdirAll(TempDownloadsDir, 0755)
|
||||||
err = TofuStore.ReadInConfig()
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("temp downloads path could not be created: %s", TempDownloadsDir)
|
||||||
return err
|
}
|
||||||
}
|
} else {
|
||||||
|
// Validate path
|
||||||
BkmkStore.SetConfigFile(bkmkPath)
|
dDir := viper.GetString("a-general.temp_downloads")
|
||||||
BkmkStore.SetConfigType("toml")
|
di, err := os.Stat(dDir)
|
||||||
err = BkmkStore.ReadInConfig()
|
if err == nil {
|
||||||
if err != nil {
|
if !di.IsDir() {
|
||||||
return err
|
return fmt.Errorf("temp downloads path specified is not a directory: %s", dDir)
|
||||||
}
|
}
|
||||||
BkmkStore.Set("DO NOT TOUCH", true)
|
} else if os.IsNotExist(err) {
|
||||||
err = BkmkStore.WriteConfig()
|
// Try to create path
|
||||||
if err != nil {
|
err = os.MkdirAll(dDir, 0755)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("temp downloads path could not be created: %s", dDir)
|
||||||
|
}
|
||||||
// Setup main config
|
} else {
|
||||||
|
// Some other error
|
||||||
viper.SetDefault("a-general.home", "gemini.circumlunar.space")
|
return fmt.Errorf("couldn't access temp downloads directory: %s", dDir)
|
||||||
viper.SetDefault("a-general.auto_redirect", false)
|
}
|
||||||
viper.SetDefault("a-general.http", "default")
|
TempDownloadsDir = dDir
|
||||||
viper.SetDefault("a-general.search", "gus.guru/search")
|
|
||||||
viper.SetDefault("a-general.color", true)
|
|
||||||
viper.SetDefault("a-general.ansi", true)
|
|
||||||
viper.SetDefault("a-general.bullets", true)
|
|
||||||
viper.SetDefault("a-general.left_margin", 0.15)
|
|
||||||
viper.SetDefault("a-general.max_width", 100)
|
|
||||||
viper.SetDefault("a-general.downloads", "")
|
|
||||||
viper.SetDefault("a-general.page_max_size", 2097152)
|
|
||||||
viper.SetDefault("a-general.page_max_time", 10)
|
|
||||||
viper.SetDefault("a-general.emoji_favicons", false)
|
|
||||||
viper.SetDefault("keybindings.shift_numbers", "!@#$%^&*()")
|
|
||||||
viper.SetDefault("url-handlers.other", "off")
|
|
||||||
viper.SetDefault("cache.max_size", 0)
|
|
||||||
viper.SetDefault("cache.max_pages", 20)
|
|
||||||
|
|
||||||
viper.SetConfigFile(configPath)
|
|
||||||
viper.SetConfigType("toml")
|
|
||||||
err = viper.ReadInConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup cache from config
|
// Setup cache from config
|
||||||
cache.SetMaxSize(viper.GetInt("cache.max_size"))
|
cache.SetMaxSize(viper.GetInt("cache.max_size"))
|
||||||
cache.SetMaxPages(viper.GetInt("cache.max_pages"))
|
cache.SetMaxPages(viper.GetInt("cache.max_pages"))
|
||||||
|
cache.SetTimeout(viper.GetInt("cache.timeout"))
|
||||||
|
|
||||||
// Setup theme
|
// Setup theme
|
||||||
configTheme := viper.Sub("theme")
|
configTheme := viper.Sub("theme")
|
||||||
@ -249,5 +362,35 @@ func Init() error {
|
|||||||
HTTPCommand = strings.Fields(viper.GetString("a-general.http"))
|
HTTPCommand = strings.Fields(viper.GetString("a-general.http"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rawMediaHandlers []struct {
|
||||||
|
Cmd []string `mapstructure:"cmd"`
|
||||||
|
Types []string `mapstructure:"types"`
|
||||||
|
NoPrompt bool `mapstructure:"no_prompt"`
|
||||||
|
Stream bool `mapstructure:"stream"`
|
||||||
|
}
|
||||||
|
err = viper.UnmarshalKey("mediatype-handlers", &rawMediaHandlers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't parse mediatype-handlers section in config: %w", err)
|
||||||
|
}
|
||||||
|
for _, rawMediaHandler := range rawMediaHandlers {
|
||||||
|
if len(rawMediaHandler.Cmd) == 0 {
|
||||||
|
return fmt.Errorf("empty cmd array in mediatype-handlers section")
|
||||||
|
}
|
||||||
|
if len(rawMediaHandler.Types) == 0 {
|
||||||
|
return fmt.Errorf("empty types array in mediatype-handlers section")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, typ := range rawMediaHandler.Types {
|
||||||
|
if _, ok := MediaHandlers[typ]; ok {
|
||||||
|
return fmt.Errorf("multiple mediatype-handlers defined for %v", typ)
|
||||||
|
}
|
||||||
|
MediaHandlers[typ] = MediaHandler{
|
||||||
|
Cmd: rawMediaHandler.Cmd,
|
||||||
|
NoPrompt: rawMediaHandler.NoPrompt,
|
||||||
|
Stream: rawMediaHandler.Stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -28,14 +28,15 @@ auto_redirect = false
|
|||||||
#
|
#
|
||||||
# The best to define a command is using a string array.
|
# The best to define a command is using a string array.
|
||||||
# Examples:
|
# Examples:
|
||||||
# http = ["firefox"]
|
# http = ['firefox']
|
||||||
# http = ["custom-browser", "--flag", "--option=2"]
|
# http = ['custom-browser', '--flag', '--option=2']
|
||||||
# http = ["/path/with spaces/in it/firefox"]
|
# http = ['/path/with spaces/in it/firefox']
|
||||||
#
|
#
|
||||||
# Using just a string will also work, but it is deprecated,
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
# and will degrade if you use paths with spaces.
|
# Using just a string will also work, but it is deprecated, and will degrade if
|
||||||
|
# you use paths with spaces.
|
||||||
|
|
||||||
http = "default"
|
http = 'default'
|
||||||
|
|
||||||
# Any URL that will accept a query string can be put here
|
# Any URL that will accept a query string can be put here
|
||||||
search = "gemini://gus.guru/search"
|
search = "gemini://gus.guru/search"
|
||||||
@ -49,6 +50,9 @@ ansi = true
|
|||||||
# Whether to replace list asterisks with unicode bullets
|
# Whether to replace list asterisks with unicode bullets
|
||||||
bullets = true
|
bullets = true
|
||||||
|
|
||||||
|
# Whether to show link after link text
|
||||||
|
show_link = false
|
||||||
|
|
||||||
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
|
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
|
||||||
left_margin = 0.15
|
left_margin = 0.15
|
||||||
|
|
||||||
@ -58,7 +62,8 @@ max_width = 100
|
|||||||
# 'downloads' is the path to a downloads folder.
|
# 'downloads' is the path to a downloads folder.
|
||||||
# An empty value means the code will find the default downloads folder for your system.
|
# An empty value means the code will find the default downloads folder for your system.
|
||||||
# If the path does not exist it will be created.
|
# If the path does not exist it will be created.
|
||||||
downloads = ""
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
|
downloads = ''
|
||||||
|
|
||||||
# Max size for displayable content in bytes - after that size a download window pops up
|
# Max size for displayable content in bytes - after that size a download window pops up
|
||||||
page_max_size = 2097152 # 2 MiB
|
page_max_size = 2097152 # 2 MiB
|
||||||
@ -71,51 +76,163 @@ emoji_favicons = false
|
|||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
|
# Note the use of single quotes for values, so that backslashes will not be escaped.
|
||||||
|
|
||||||
[auth.certs]
|
[auth.certs]
|
||||||
# Client certificates
|
# Client certificates
|
||||||
# Set domain name equal to path to client cert
|
# Set domain name equal to path to client cert
|
||||||
# "example.com" = "mycert.crt"
|
# "example.com" = 'mycert.crt'
|
||||||
|
|
||||||
[auth.keys]
|
[auth.keys]
|
||||||
# Client certificate keys
|
# Client certificate keys
|
||||||
# Set domain name equal to path to key for the client cert above
|
# Set domain name equal to path to key for the client cert above
|
||||||
# "example.com" = "mycert.key"
|
# "example.com" = 'mycert.key'
|
||||||
|
|
||||||
|
|
||||||
[keybindings]
|
[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.
|
# Whitespace is not allowed in any of the keybindings! Use 'Space' and 'Tab' to bind to those keys.
|
||||||
# It is default set to be accurate for US keyboards.
|
# Multiple keys can be bound to one command, just use a TOML array.
|
||||||
shift_numbers = "!@#$%^&*()"
|
# 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]
|
[url-handlers]
|
||||||
# Allows setting the commands to run for various URL schemes.
|
# Allows setting the commands to run for various URL schemes.
|
||||||
# E.g. to open FTP URLs with FileZilla set the following key:
|
# E.g. to open FTP URLs with FileZilla set the following key:
|
||||||
# ftp = "filezilla"
|
# ftp = 'filezilla'
|
||||||
# You can set any scheme to "off" or "" to disable handling it, or
|
# You can set any scheme to "off" or "" to disable handling it, or
|
||||||
# just leave the key unset.
|
# just leave the key unset.
|
||||||
#
|
#
|
||||||
# DO NOT use this for setting the HTTP command.
|
# DO NOT use this for setting the HTTP command.
|
||||||
# Use the http setting in the "a-general" section above.
|
# Use the http setting in the "a-general" section above.
|
||||||
#
|
#
|
||||||
# NOTE: These settings are override by the ones in the proxies section.
|
# NOTE: These settings are overrided by the ones in the proxies section.
|
||||||
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
|
|
||||||
# This is a special key that defines the handler for all URL schemes for which
|
# This is a special key that defines the handler for all URL schemes for which
|
||||||
# no handler is defined.
|
# no handler is defined.
|
||||||
other = "off"
|
other = 'off'
|
||||||
|
|
||||||
|
|
||||||
|
# [[mediatype-handlers]] section
|
||||||
|
# ---------------------------------
|
||||||
|
#
|
||||||
|
# Specify what applications will open certain media types.
|
||||||
|
# By default your default application will be used to open the file when you select "Open".
|
||||||
|
# You only need to configure this section if you want to override your default application,
|
||||||
|
# or do special things like streaming.
|
||||||
|
#
|
||||||
|
# Note the use of single quotes for commands, so that backslashes will not be escaped.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# To open jpeg files with the feh command:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['feh']
|
||||||
|
# types = ["image/jpeg"]
|
||||||
|
#
|
||||||
|
# Each command that you specify must come under its own [[mediatype-handlers]]. You may
|
||||||
|
# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
|
||||||
|
#
|
||||||
|
# If the subtype is omitted then the specified command will be used for the
|
||||||
|
# entire type:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# command = ['vlc', '--flag']
|
||||||
|
# types = ["audio", "video"]
|
||||||
|
#
|
||||||
|
# A catch-all handler can by specified with "*".
|
||||||
|
# Note that there are already catch-all handlers in place for all OSes,
|
||||||
|
# that open the file using your default application. This is only if you
|
||||||
|
# want to override that.
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['some-command']
|
||||||
|
# types = [
|
||||||
|
# "application/pdf",
|
||||||
|
# "*",
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# You can also choose to stream the data instead of downloading it all before
|
||||||
|
# opening it. This is especially useful for large video or audio files, as
|
||||||
|
# well as radio streams, which will never complete. You can do this like so:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['vlc', '-']
|
||||||
|
# types = ["audio", "video"]
|
||||||
|
# stream = true
|
||||||
|
#
|
||||||
|
# This uses vlc to stream all video and audio content.
|
||||||
|
# By default stream is set to off for all handlers
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# If you want to always open a type in its viewer without the download or open
|
||||||
|
# prompt appearing, you can add no_prompt = true
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['feh']
|
||||||
|
# types = ["image"]
|
||||||
|
# no_prompt = true
|
||||||
|
#
|
||||||
|
# Note: Multiple handlers cannot be defined for the same full media type, but
|
||||||
|
# still there needs to be an order for which handlers are used. The following
|
||||||
|
# order applies regardless of the order written in the config:
|
||||||
|
#
|
||||||
|
# 1. Full media type: "image/jpeg"
|
||||||
|
# 2. Just type: "image"
|
||||||
|
# 3. Catch-all: "*"
|
||||||
|
|
||||||
|
|
||||||
[cache]
|
[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
|
# Increase the cache size to speed up browsing at the expense of memory
|
||||||
|
|
||||||
# Zero values mean there is no limit
|
# Zero values mean there is no limit
|
||||||
|
|
||||||
max_size = 0 # Size in bytes
|
max_size = 0 # Size in bytes
|
||||||
max_pages = 30 # The maximum number of pages the cache will store
|
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]
|
[proxies]
|
||||||
# Allows setting a Gemini proxy for different schemes.
|
# Allows setting a Gemini proxy for different schemes.
|
||||||
@ -131,6 +248,28 @@ max_pages = 30 # The maximum number of pages the cache will store
|
|||||||
# Note that HTTP and HTTPS are treated as separate protocols here.
|
# Note that HTTP and HTTPS are treated as separate protocols here.
|
||||||
|
|
||||||
|
|
||||||
|
[subscriptions]
|
||||||
|
# For tracking feeds and pages
|
||||||
|
|
||||||
|
# Whether a pop-up appears when viewing a potential feed
|
||||||
|
popup = true
|
||||||
|
|
||||||
|
# How often to check for updates to subscriptions in the background, in seconds.
|
||||||
|
# Set it to 0 to disable this feature. You can still update individual feeds
|
||||||
|
# manually, or restart the browser.
|
||||||
|
#
|
||||||
|
# Note Amfora will check for updates on browser start no matter what this setting is.
|
||||||
|
update_interval = 1800 # 30 mins
|
||||||
|
|
||||||
|
# How many subscriptions can be checked at the same time when updating.
|
||||||
|
# If you have many subscriptions you may want to increase this for faster
|
||||||
|
# update times. Any value below 1 will be corrected to 1.
|
||||||
|
workers = 3
|
||||||
|
|
||||||
|
# The number of subscription updates displayed per page.
|
||||||
|
entries_per_page = 20
|
||||||
|
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
# This section is for changing the COLORS used in Amfora.
|
# This section is for changing the COLORS used in Amfora.
|
||||||
# These colors only apply if 'color' is enabled above.
|
# These colors only apply if 'color' is enabled above.
|
||||||
@ -189,6 +328,8 @@ max_pages = 30 # The maximum number of pages the cache will store
|
|||||||
# yesno_modal_text
|
# yesno_modal_text
|
||||||
# tofu_modal_bg
|
# tofu_modal_bg
|
||||||
# tofu_modal_text
|
# tofu_modal_text
|
||||||
|
# subscription_modal_bg
|
||||||
|
# subscription_modal_text
|
||||||
|
|
||||||
# input_modal_bg
|
# input_modal_bg
|
||||||
# input_modal_text
|
# input_modal_text
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
head -n 3 default.go | tee default.go > /dev/null
|
cat > default.go <<-EOF
|
||||||
|
package config
|
||||||
|
|
||||||
|
//go:generate ./default.sh
|
||||||
|
EOF
|
||||||
echo -n 'var defaultConf = []byte(`' >> default.go
|
echo -n 'var defaultConf = []byte(`' >> default.go
|
||||||
cat ../default-config.toml >> default.go
|
cat ../default-config.toml >> default.go
|
||||||
echo '`)' >> default.go
|
echo '`)' >> default.go
|
@ -1,24 +1,237 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyToNum returns the number on the user's keyboard they pressed,
|
// NOTE: CmdLink[1-90] and CmdTab[1-90] need to be in-order and consecutive
|
||||||
// using the rune returned when when they press Shift+Num.
|
// This property is used to simplify key handling in display/display.go
|
||||||
// The error is not nil if the provided key is invalid.
|
type Command int
|
||||||
func KeyToNum(key rune) (int, error) {
|
|
||||||
runes := []rune(viper.GetString("keybindings.shift_numbers"))
|
const (
|
||||||
for i := range runes {
|
CmdInvalid Command = 0
|
||||||
if key == runes[i] {
|
CmdLink1 = 1
|
||||||
if i == len(runes)-1 {
|
CmdLink2 = 2
|
||||||
// Last key is 0, not 10
|
CmdLink3 = 3
|
||||||
return 0, nil
|
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
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Functions to allow themeing configuration.
|
// Functions to allow themeing configuration.
|
||||||
// UI element colors are mapped to a string key, such as "error" or "tab_background"
|
// UI element colors are mapped to a string key, such as "error" or "tab_bg"
|
||||||
// These are the same keys used in the config file.
|
// These are the same keys used in the config file.
|
||||||
|
|
||||||
var themeMu = sync.RWMutex{}
|
var themeMu = sync.RWMutex{}
|
||||||
@ -26,18 +26,20 @@ var theme = map[string]tcell.Color{
|
|||||||
"btn_bg": tcell.ColorNavy, // All modal buttons
|
"btn_bg": tcell.ColorNavy, // All modal buttons
|
||||||
"btn_text": tcell.ColorWhite,
|
"btn_text": tcell.ColorWhite,
|
||||||
|
|
||||||
"dl_choice_modal_bg": tcell.ColorPurple,
|
"dl_choice_modal_bg": tcell.ColorPurple,
|
||||||
"dl_choice_modal_text": tcell.ColorWhite,
|
"dl_choice_modal_text": tcell.ColorWhite,
|
||||||
"dl_modal_bg": tcell.Color130, // xterm:DarkOrange3, #af5f00
|
"dl_modal_bg": tcell.Color130, // xterm:DarkOrange3, #af5f00
|
||||||
"dl_modal_text": tcell.ColorWhite,
|
"dl_modal_text": tcell.ColorWhite,
|
||||||
"info_modal_bg": tcell.ColorGray,
|
"info_modal_bg": tcell.ColorGray,
|
||||||
"info_modal_text": tcell.ColorWhite,
|
"info_modal_text": tcell.ColorWhite,
|
||||||
"error_modal_bg": tcell.ColorMaroon,
|
"error_modal_bg": tcell.ColorMaroon,
|
||||||
"error_modal_text": tcell.ColorWhite,
|
"error_modal_text": tcell.ColorWhite,
|
||||||
"yesno_modal_bg": tcell.ColorPurple,
|
"yesno_modal_bg": tcell.ColorPurple,
|
||||||
"yesno_modal_text": tcell.ColorWhite,
|
"yesno_modal_text": tcell.ColorWhite,
|
||||||
"tofu_modal_bg": tcell.ColorMaroon,
|
"tofu_modal_bg": tcell.ColorMaroon,
|
||||||
"tofu_modal_text": tcell.ColorWhite,
|
"tofu_modal_text": tcell.ColorWhite,
|
||||||
|
"subscription_modal_bg": tcell.Color61, // xterm:SlateBlue3, #5f5faf
|
||||||
|
"subscription_modal_text": tcell.ColorWhite,
|
||||||
|
|
||||||
"input_modal_bg": tcell.ColorGreen,
|
"input_modal_bg": tcell.ColorGreen,
|
||||||
"input_modal_text": tcell.ColorWhite,
|
"input_modal_text": tcell.ColorWhite,
|
||||||
@ -58,14 +60,14 @@ var theme = map[string]tcell.Color{
|
|||||||
"link_number": tcell.ColorSilver,
|
"link_number": tcell.ColorSilver,
|
||||||
"regular_text": tcell.ColorWhite,
|
"regular_text": tcell.ColorWhite,
|
||||||
"quote_text": tcell.ColorWhite,
|
"quote_text": tcell.ColorWhite,
|
||||||
"preformatted_text": tcell.ColorWhite,
|
"preformatted_text": tcell.Color229, // xterm:Wheat1, #ffffaf
|
||||||
"list_text": tcell.ColorWhite,
|
"list_text": tcell.ColorWhite,
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetColor(key string, color tcell.Color) {
|
func SetColor(key string, color tcell.Color) {
|
||||||
themeMu.Lock()
|
themeMu.Lock()
|
||||||
defer themeMu.Unlock()
|
|
||||||
theme[key] = color
|
theme[key] = color
|
||||||
|
themeMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetColor will return tcell.ColorBlack if there is no color for the provided key.
|
// GetColor will return tcell.ColorBlack if there is no color for the provided key.
|
||||||
|
8
contrib/gemini-wiki/README.md
Normal file
8
contrib/gemini-wiki/README.md
Normal 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
111
contrib/gemini-wiki/main.py
Normal 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)
|
1
contrib/gemini-wiki/requirements.txt
Normal file
1
contrib/gemini-wiki/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
md2gemini<2
|
76
contrib/themes/README.md
Normal file
76
contrib/themes/README.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# User Contributed Themes
|
||||||
|
|
||||||
|
You can use these themes by replacing the `[theme]` section of your config with their contents. Some themes won't display properly on terminals that do not have truecolor support.
|
||||||
|
|
||||||
|
## Nord
|
||||||
|
|
||||||
|
Contributed by **[@lokesh-krishna](https://github.com/lokesh-krishna)**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Dracula
|
||||||
|
|
||||||
|
Contributed by **[@crdpa](https://github.com/crdpa)**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Gruvbox
|
||||||
|
|
||||||
|
Contributed by **[@Skraylet](https://github.com/Skraylet)**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Another screenshot</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Solarized
|
||||||
|
|
||||||
|
Contributed by **[@bnthor](https://github.com/bnthor)**.
|
||||||
|
|
||||||
|
### Dark
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Another screenshot</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Light
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Another screenshot</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
### One Dark
|
||||||
|
|
||||||
|
Contributed by **[@sergetymo](https://github.com/sergetymo)**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Yours?
|
||||||
|
|
||||||
|
Contribute your own theme by opening a PR.
|
105
contrib/themes/dracula.toml
Normal file
105
contrib/themes/dracula.toml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
|
||||||
|
bg = "#282a36"
|
||||||
|
fg = "#f8f8f2"
|
||||||
|
tab_num = "#50fa7b"
|
||||||
|
tab_divider = "#f8f8f2"
|
||||||
|
bottombar_bg = "#282a36"
|
||||||
|
bottombar_text = "#f8f8f2"
|
||||||
|
bottombar_label = "#9aedfe"
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
|
||||||
|
hdg_1 = "#5af78e"
|
||||||
|
hdg_2 = "#9aedfe"
|
||||||
|
hdg_3 = "#caa9fa"
|
||||||
|
amfora_link = "#f4f99d"
|
||||||
|
foreign_link = "#d4d989"
|
||||||
|
link_number = "#ff5555"
|
||||||
|
regular_text = "#f8f8f2"
|
||||||
|
quote_text = "#E6E6E6"
|
||||||
|
preformatted_text = "#f8f8f2"
|
||||||
|
list_text = "#f8f8f2"
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
|
||||||
|
btn_bg = "#bfbfbf"
|
||||||
|
btn_text = "#4d4d4d"
|
||||||
|
|
||||||
|
dl_choice_modal_bg = "#282a36"
|
||||||
|
dl_choice_modal_text = "#f8f8f2"
|
||||||
|
dl_modal_bg = "#282a36"
|
||||||
|
dl_modal_text = "#f8f8f2"
|
||||||
|
info_modal_bg = "#282a36"
|
||||||
|
info_modal_text = "#f8f8f2"
|
||||||
|
error_modal_bg = "#282a36"
|
||||||
|
error_modal_text = "#ff5555"
|
||||||
|
yesno_modal_bg = "#282a36"
|
||||||
|
yesno_modal_text = "#f1fa8c"
|
||||||
|
tofu_modal_bg = "#282a36"
|
||||||
|
tofu_modal_text = "#f8f8f2"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
|
||||||
|
input_modal_bg = "#282a36"
|
||||||
|
input_modal_text = "#f8f8f2"
|
||||||
|
input_modal_field_bg = "#4d4d4d"
|
||||||
|
input_modal_field_text ="#f8f8f2"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
|
||||||
|
bkmk_modal_bg = "#282a36"
|
||||||
|
bkmk_modal_text = "#f8f8f2"
|
||||||
|
bkmk_modal_label = "#f8f8f2"
|
||||||
|
bkmk_modal_field_bg = "#000000"
|
||||||
|
bkmk_modal_field_text = "#f8f8f2"
|
||||||
|
|
||||||
|
subscription_modal_bg = "#282a36"
|
||||||
|
subscription_modal_text = "#f8f8f2"
|
101
contrib/themes/gruvbox.toml
Normal file
101
contrib/themes/gruvbox.toml
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
bg = "#1d2021"
|
||||||
|
fg = "#ebdbb2"
|
||||||
|
tab_num = "#928374"
|
||||||
|
tab_divider = "#928374"
|
||||||
|
bottombar_bg = "#1d2021"
|
||||||
|
bottombar_text = "#ebdbb2"
|
||||||
|
bottombar_label = "#ebdbb2"
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
hdg_1 = "#b8bb26"
|
||||||
|
hdg_2 = "#8ec07c"
|
||||||
|
hdg_3 = "#689d6a"
|
||||||
|
amfora_link = "#ebdbb2"
|
||||||
|
foreign_link = "#bdae93"
|
||||||
|
link_number = "#83a598"
|
||||||
|
regular_text = "#ebdbb2"
|
||||||
|
quote_text = "#928374"
|
||||||
|
preformatted_text = "#ebdbb2"
|
||||||
|
list_text = "#ebdbb2"
|
||||||
|
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
|
||||||
|
btn_bg = "#3c3836"
|
||||||
|
btn_text = "#ebdbb2"
|
||||||
|
|
||||||
|
dl_choice_modal_bg = "#3c3836"
|
||||||
|
dl_choice_modal_text = "#ebdbb2"
|
||||||
|
dl_modal_bg = "#3c3836"
|
||||||
|
dl_modal_text = "#ebdbb2"
|
||||||
|
info_modal_bg = "#3c3836"
|
||||||
|
info_modal_text = "#ebdbb2"
|
||||||
|
error_modal_bg = "#3c3836"
|
||||||
|
error_modal_text = "#fb4934"
|
||||||
|
yesno_modal_bg = "#3c3836"
|
||||||
|
yesno_modal_text = "#ebdbb2"
|
||||||
|
tofu_modal_bg = "#3c3836"
|
||||||
|
tofu_modal_text = "#ebdbb2"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
|
||||||
|
input_modal_bg = "#3c3836"
|
||||||
|
input_modal_text = "#ebdbb2"
|
||||||
|
input_modal_field_bg = "#1d2021"
|
||||||
|
input_modal_field_text = "#ebdbb2"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
|
||||||
|
bkmk_modal_bg = "#3c3836"
|
||||||
|
bkmk_modal_text = "#ebdbb2"
|
||||||
|
bkmk_modal_label = "#ebdbb2"
|
||||||
|
bkmk_modal_field_bg = "#1d2021"
|
||||||
|
bkmk_modal_field_text = "#f8f8f2"
|
112
contrib/themes/nord.toml
Normal file
112
contrib/themes/nord.toml
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
bg = "#2e3440"
|
||||||
|
tab_num = "#88c0d0"
|
||||||
|
tab_divider = "#4c566a"
|
||||||
|
bottombar_label = "#88c0d0"
|
||||||
|
bottombar_text = "#eceff4"
|
||||||
|
bottombar_bg = "#3b4252"
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
hdg_1 = "#5e81ac"
|
||||||
|
hdg_2 = "#81a1c1"
|
||||||
|
hdg_3 = "#8fbcbb"
|
||||||
|
amfora_link = "#88c0d0"
|
||||||
|
foreign_link = "#b48ead"
|
||||||
|
link_number = "#a3be8c"
|
||||||
|
regular_text = "#eceff4"
|
||||||
|
quote_text = "#81a1c1"
|
||||||
|
preformatted_text = "#8fbcbb"
|
||||||
|
list_text = "#d8dee9"
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
btn_bg = "#4c566a"
|
||||||
|
btn_text = "#eceff4"
|
||||||
|
|
||||||
|
# dl_choice_modal_bg
|
||||||
|
# dl_choice_modal_text
|
||||||
|
# dl_modal_bg
|
||||||
|
# dl_modal_text
|
||||||
|
# info_modal_bg
|
||||||
|
# info_modal_text
|
||||||
|
# error_modal_bg
|
||||||
|
# error_modal_text
|
||||||
|
# yesno_modal_bg
|
||||||
|
# yesno_modal_text
|
||||||
|
# tofu_modal_bg
|
||||||
|
# tofu_modal_text
|
||||||
|
# subscription_modal_bg
|
||||||
|
# subscription_modal_text
|
||||||
|
dl_choice_modal_bg = "#3b4252"
|
||||||
|
dl_choice_modal_text = "#eceff4"
|
||||||
|
dl_modal_bg = "#3b4252"
|
||||||
|
dl_modal_text = "#eceff4"
|
||||||
|
info_modal_bg = "#3b4252"
|
||||||
|
info_modal_text = "#eceff4"
|
||||||
|
error_modal_bg = "#bf616a"
|
||||||
|
error_modal_text = "#eceff4"
|
||||||
|
yesno_modal_bg = "#3b4252"
|
||||||
|
yesno_modal_text = "#eceff4"
|
||||||
|
tofu_modal_bg = "#3b4252"
|
||||||
|
tofu_modal_text = "#eceff4"
|
||||||
|
subscription_modal_bg = "#3b4252"
|
||||||
|
subscription_modal_text = "#eceff4"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
input_modal_bg = "#3b4252"
|
||||||
|
input_modal_text = "#eceff4"
|
||||||
|
input_modal_field_bg = "#4c566a"
|
||||||
|
input_modal_field_text = "#eceff4"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
bkmk_modal_bg = "#3b4252"
|
||||||
|
bkmk_modal_text = "#eceff4"
|
||||||
|
bkmk_modal_label = "#eceff4"
|
||||||
|
bkmk_modal_field_bg = "#4c566a"
|
||||||
|
bkmk_modal_field_text = "#eceff4"
|
128
contrib/themes/one_dark.toml
Normal file
128
contrib/themes/one_dark.toml
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Atom One Dark theme ported to Amfora
|
||||||
|
# by Serge Tymoshenko <serge@tymo.name>
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
|
||||||
|
bg = "#282c34"
|
||||||
|
fg = "#abb2bf"
|
||||||
|
tab_num = "#abb2bf"
|
||||||
|
tab_divider = "#abb2bf"
|
||||||
|
bottombar_bg = "#abb2bf"
|
||||||
|
bottombar_text = "#282c34"
|
||||||
|
bottombar_label = "#282c34"
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
|
||||||
|
hdg_1 = "#e06c75"
|
||||||
|
hdg_2 = "#c678dd"
|
||||||
|
hdg_3 = "#c678dd"
|
||||||
|
amfora_link = "#61afef"
|
||||||
|
foreign_link = "#56b6c2"
|
||||||
|
link_number = "#abb2bf"
|
||||||
|
regular_text = "#abb2bf"
|
||||||
|
quote_text = "#98c379"
|
||||||
|
preformatted_text = "#e5c07b"
|
||||||
|
list_text = "#abb2bf"
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
|
||||||
|
btn_bg = "#282c34"
|
||||||
|
btn_text = "#abb2bf"
|
||||||
|
|
||||||
|
# dl_choice_modal_bg
|
||||||
|
# dl_choice_modal_text
|
||||||
|
# dl_modal_bg
|
||||||
|
# dl_modal_text
|
||||||
|
# info_modal_bg
|
||||||
|
# info_modal_text
|
||||||
|
# error_modal_bg
|
||||||
|
# error_modal_text
|
||||||
|
# yesno_modal_bg
|
||||||
|
# yesno_modal_text
|
||||||
|
# tofu_modal_bg
|
||||||
|
# tofu_modal_text
|
||||||
|
|
||||||
|
dl_choice_modal_bg = "#98c379"
|
||||||
|
dl_choice_modal_text = "#282c34"
|
||||||
|
|
||||||
|
dl_modal_bg = "#98c379"
|
||||||
|
dl_modal_text = "#282c34"
|
||||||
|
|
||||||
|
info_modal_bg = "#98c379"
|
||||||
|
info_modal_text = "#282c34"
|
||||||
|
|
||||||
|
error_modal_bg = "#e06c75"
|
||||||
|
error_modal_text = "#282c34"
|
||||||
|
|
||||||
|
yesno_modal_bg = "#e5c07b"
|
||||||
|
yesno_modal_text = "#282c34"
|
||||||
|
|
||||||
|
tofu_modal_bg = "#e5c07b"
|
||||||
|
tofu_modal_text = "#282c34"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
|
||||||
|
input_modal_bg = "#98c379"
|
||||||
|
input_modal_text = "#282c34"
|
||||||
|
input_modal_field_bg = "#282c34"
|
||||||
|
input_modal_field_text = "#abb2bf"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
|
||||||
|
bkmk_modal_bg = "#98c379"
|
||||||
|
bkmk_modal_text = "#282c34"
|
||||||
|
bkmk_modal_label = "#282c34"
|
||||||
|
bkmk_modal_field_bg = "#282c34"
|
||||||
|
bkmk_modal_field_text = "#abb2bf"
|
||||||
|
|
||||||
|
# subscription_modal_bg
|
||||||
|
# subscription_modal_text
|
||||||
|
subscription_modal_bg = "#c678dd"
|
||||||
|
subscription_modal_text = "#282c34"
|
102
contrib/themes/solarized_dark.toml
Normal file
102
contrib/themes/solarized_dark.toml
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
|
||||||
|
bg = "#002b36"
|
||||||
|
fg = "#EDE8D5"
|
||||||
|
tab_num = "#3889D2"
|
||||||
|
tab_divider = "#0F3642"
|
||||||
|
bottombar_bg = "#0F3642"
|
||||||
|
bottombar_text = "#93a1a1"
|
||||||
|
bottombar_label = "#3ea197"
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
|
||||||
|
hdg_1 = "#3EA197"
|
||||||
|
hdg_2 = "#3889D2"
|
||||||
|
hdg_3 = "#6D6EC4"
|
||||||
|
amfora_link = "#94A1A1"
|
||||||
|
foreign_link = "#849496"
|
||||||
|
link_number = "#869B00"
|
||||||
|
regular_text = "#EDE8D5"
|
||||||
|
quote_text = "#EDE8D5"
|
||||||
|
preformatted_text = "#EDE8D5"
|
||||||
|
list_text = "#EDE8D5"
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
|
||||||
|
btn_bg = "#3889D2"
|
||||||
|
btn_text = "#FCF6E3"
|
||||||
|
|
||||||
|
dl_choice_modal_bg = "#073642"
|
||||||
|
dl_choice_modal_text = "#93a1a1"
|
||||||
|
dl_modal_bg = "#073642"
|
||||||
|
dl_modal_text = "#94a1a1"
|
||||||
|
info_modal_bg = "#073642"
|
||||||
|
info_modal_text = "#94a1a1"
|
||||||
|
error_modal_bg = "#073642"
|
||||||
|
error_modal_text = "#D53234"
|
||||||
|
yesno_modal_bg = "#073642"
|
||||||
|
yesno_modal_text = "#94a1a1"
|
||||||
|
tofu_modal_bg = "#073642"
|
||||||
|
tofu_modal_text = "#94a1a1"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
|
||||||
|
input_modal_bg = "#073642"
|
||||||
|
input_modal_text = "#94a1a1"
|
||||||
|
input_modal_field_bg = "#062B36"
|
||||||
|
input_modal_field_text ="#94a1a1"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
|
||||||
|
bkmk_modal_bg = "#073642"
|
||||||
|
bkmk_modal_text = "#94a1a1"
|
||||||
|
bkmk_modal_label = "#3ea197"
|
||||||
|
bkmk_modal_field_bg = "#062B36"
|
||||||
|
bkmk_modal_field_text = "#94a1a1"
|
102
contrib/themes/solarized_light.toml
Normal file
102
contrib/themes/solarized_light.toml
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
[theme]
|
||||||
|
# This section is for changing the COLORS used in Amfora.
|
||||||
|
# These colors only apply if 'color' is enabled above.
|
||||||
|
# Colors can be set using a W3C color name, or a hex value such as "#ffffff".
|
||||||
|
|
||||||
|
# Note that not all colors will work on terminals that do not have truecolor support.
|
||||||
|
# If you want to stick to the standard 16 or 256 colors, you can get
|
||||||
|
# a list of those here: https://jonasjacek.github.io/colors/
|
||||||
|
# DO NOT use the names from that site, just the hex codes.
|
||||||
|
|
||||||
|
# Definitions:
|
||||||
|
# bg = background
|
||||||
|
# fg = foreground
|
||||||
|
# dl = download
|
||||||
|
# btn = button
|
||||||
|
# hdg = heading
|
||||||
|
# bkmk = bookmark
|
||||||
|
# modal = a popup window/box in the middle of the screen
|
||||||
|
|
||||||
|
# EXAMPLES:
|
||||||
|
# hdg_1 = "green"
|
||||||
|
# hdg_2 = "#5f0000"
|
||||||
|
|
||||||
|
# Available keys to set:
|
||||||
|
|
||||||
|
# bg: background for pages, tab row, app in general
|
||||||
|
# tab_num: The number/highlight of the tabs at the top
|
||||||
|
# tab_divider: The color of the divider character between tab numbers: |
|
||||||
|
# bottombar_label: The color of the prompt that appears when you press space
|
||||||
|
# bottombar_text: The color of the text you type
|
||||||
|
# bottombar_bg
|
||||||
|
|
||||||
|
bg = "#FCF6E3"
|
||||||
|
fg = "#5A6E75"
|
||||||
|
tab_num = "#3889D2"
|
||||||
|
tab_divider = "#EDE8D5"
|
||||||
|
bottombar_bg = "#EDE8D5"
|
||||||
|
bottombar_text = "#5A6E75"
|
||||||
|
bottombar_label = "#3ea197"
|
||||||
|
|
||||||
|
# hdg_1
|
||||||
|
# hdg_2
|
||||||
|
# hdg_3
|
||||||
|
# amfora_link: A link that Amfora supports viewing. For now this is only gemini://
|
||||||
|
# foreign_link: HTTP(S), Gopher, etc
|
||||||
|
# link_number: The silver number that appears to the left of a link
|
||||||
|
# regular_text: Normal gemini text, and plaintext documents
|
||||||
|
# quote_text
|
||||||
|
# preformatted_text
|
||||||
|
# list_text
|
||||||
|
|
||||||
|
hdg_1 = "#3EA197"
|
||||||
|
hdg_2 = "#3889D2"
|
||||||
|
hdg_3 = "#6D6EC4"
|
||||||
|
amfora_link = "#5A6E75"
|
||||||
|
foreign_link = "#677B83"
|
||||||
|
link_number = "#CC3283"
|
||||||
|
regular_text = "#0F3642"
|
||||||
|
quote_text = "#0F3642"
|
||||||
|
preformatted_text = "#0F3642"
|
||||||
|
list_text = "#0F3642"
|
||||||
|
|
||||||
|
# btn_bg: The bg color for all modal buttons
|
||||||
|
# btn_text: The text color for all modal buttons
|
||||||
|
|
||||||
|
btn_bg = "#3889D2"
|
||||||
|
btn_text = "#FCF6E3"
|
||||||
|
|
||||||
|
dl_choice_modal_bg = "#EDE8D5"
|
||||||
|
dl_choice_modal_text = "#0F3642"
|
||||||
|
dl_modal_bg = "#EDE8D5"
|
||||||
|
dl_modal_text = "#0F3642"
|
||||||
|
info_modal_bg = "#EDE8D5"
|
||||||
|
info_modal_text = "#0F3642"
|
||||||
|
error_modal_bg = "#EDE8D5"
|
||||||
|
error_modal_text = "#D53234"
|
||||||
|
yesno_modal_bg = "#EDE8D5"
|
||||||
|
yesno_modal_text = "#0F3642"
|
||||||
|
tofu_modal_bg = "#EDE8D5"
|
||||||
|
tofu_modal_text = "#0F3642"
|
||||||
|
|
||||||
|
# input_modal_bg
|
||||||
|
# input_modal_text
|
||||||
|
# input_modal_field_bg: The bg of the input field, where you type the text
|
||||||
|
# input_modal_field_text: The color of the text you type
|
||||||
|
|
||||||
|
input_modal_bg = "#EDE8D5"
|
||||||
|
input_modal_text = "#0F3642"
|
||||||
|
input_modal_field_bg = "#FCF6E3"
|
||||||
|
input_modal_field_text ="#0F3642"
|
||||||
|
|
||||||
|
# bkmk_modal_bg
|
||||||
|
# bkmk_modal_text
|
||||||
|
# bkmk_modal_label
|
||||||
|
# bkmk_modal_field_bg
|
||||||
|
# bkmk_modal_field_text
|
||||||
|
|
||||||
|
bkmk_modal_bg = "#EDE8D5"
|
||||||
|
bkmk_modal_text = "#0F3642"
|
||||||
|
bkmk_modal_label = "#3ea197"
|
||||||
|
bkmk_modal_field_bg = "#FCF6E3"
|
||||||
|
bkmk_modal_field_text = "#0F3642"
|
@ -25,14 +25,15 @@ auto_redirect = false
|
|||||||
#
|
#
|
||||||
# The best to define a command is using a string array.
|
# The best to define a command is using a string array.
|
||||||
# Examples:
|
# Examples:
|
||||||
# http = ["firefox"]
|
# http = ['firefox']
|
||||||
# http = ["custom-browser", "--flag", "--option=2"]
|
# http = ['custom-browser', '--flag', '--option=2']
|
||||||
# http = ["/path/with spaces/in it/firefox"]
|
# http = ['/path/with spaces/in it/firefox']
|
||||||
#
|
#
|
||||||
# Using just a string will also work, but it is deprecated,
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
# and will degrade if you use paths with spaces.
|
# Using just a string will also work, but it is deprecated, and will degrade if
|
||||||
|
# you use paths with spaces.
|
||||||
|
|
||||||
http = "default"
|
http = 'default'
|
||||||
|
|
||||||
# Any URL that will accept a query string can be put here
|
# Any URL that will accept a query string can be put here
|
||||||
search = "gemini://gus.guru/search"
|
search = "gemini://gus.guru/search"
|
||||||
@ -46,6 +47,9 @@ ansi = true
|
|||||||
# Whether to replace list asterisks with unicode bullets
|
# Whether to replace list asterisks with unicode bullets
|
||||||
bullets = true
|
bullets = true
|
||||||
|
|
||||||
|
# Whether to show link after link text
|
||||||
|
show_link = false
|
||||||
|
|
||||||
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
|
# A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up.
|
||||||
left_margin = 0.15
|
left_margin = 0.15
|
||||||
|
|
||||||
@ -55,7 +59,8 @@ max_width = 100
|
|||||||
# 'downloads' is the path to a downloads folder.
|
# 'downloads' is the path to a downloads folder.
|
||||||
# An empty value means the code will find the default downloads folder for your system.
|
# An empty value means the code will find the default downloads folder for your system.
|
||||||
# If the path does not exist it will be created.
|
# If the path does not exist it will be created.
|
||||||
downloads = ""
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
|
downloads = ''
|
||||||
|
|
||||||
# Max size for displayable content in bytes - after that size a download window pops up
|
# Max size for displayable content in bytes - after that size a download window pops up
|
||||||
page_max_size = 2097152 # 2 MiB
|
page_max_size = 2097152 # 2 MiB
|
||||||
@ -68,51 +73,163 @@ emoji_favicons = false
|
|||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
|
# Note the use of single quotes for values, so that backslashes will not be escaped.
|
||||||
|
|
||||||
[auth.certs]
|
[auth.certs]
|
||||||
# Client certificates
|
# Client certificates
|
||||||
# Set domain name equal to path to client cert
|
# Set domain name equal to path to client cert
|
||||||
# "example.com" = "mycert.crt"
|
# "example.com" = 'mycert.crt'
|
||||||
|
|
||||||
[auth.keys]
|
[auth.keys]
|
||||||
# Client certificate keys
|
# Client certificate keys
|
||||||
# Set domain name equal to path to key for the client cert above
|
# Set domain name equal to path to key for the client cert above
|
||||||
# "example.com" = "mycert.key"
|
# "example.com" = 'mycert.key'
|
||||||
|
|
||||||
|
|
||||||
[keybindings]
|
[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.
|
# Whitespace is not allowed in any of the keybindings! Use 'Space' and 'Tab' to bind to those keys.
|
||||||
# It is default set to be accurate for US keyboards.
|
# Multiple keys can be bound to one command, just use a TOML array.
|
||||||
shift_numbers = "!@#$%^&*()"
|
# 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]
|
[url-handlers]
|
||||||
# Allows setting the commands to run for various URL schemes.
|
# Allows setting the commands to run for various URL schemes.
|
||||||
# E.g. to open FTP URLs with FileZilla set the following key:
|
# E.g. to open FTP URLs with FileZilla set the following key:
|
||||||
# ftp = "filezilla"
|
# ftp = 'filezilla'
|
||||||
# You can set any scheme to "off" or "" to disable handling it, or
|
# You can set any scheme to "off" or "" to disable handling it, or
|
||||||
# just leave the key unset.
|
# just leave the key unset.
|
||||||
#
|
#
|
||||||
# DO NOT use this for setting the HTTP command.
|
# DO NOT use this for setting the HTTP command.
|
||||||
# Use the http setting in the "a-general" section above.
|
# Use the http setting in the "a-general" section above.
|
||||||
#
|
#
|
||||||
# NOTE: These settings are override by the ones in the proxies section.
|
# NOTE: These settings are overrided by the ones in the proxies section.
|
||||||
|
# Note the use of single quotes, so that backslashes will not be escaped.
|
||||||
|
|
||||||
# This is a special key that defines the handler for all URL schemes for which
|
# This is a special key that defines the handler for all URL schemes for which
|
||||||
# no handler is defined.
|
# no handler is defined.
|
||||||
other = "off"
|
other = 'off'
|
||||||
|
|
||||||
|
|
||||||
|
# [[mediatype-handlers]] section
|
||||||
|
# ---------------------------------
|
||||||
|
#
|
||||||
|
# Specify what applications will open certain media types.
|
||||||
|
# By default your default application will be used to open the file when you select "Open".
|
||||||
|
# You only need to configure this section if you want to override your default application,
|
||||||
|
# or do special things like streaming.
|
||||||
|
#
|
||||||
|
# Note the use of single quotes for commands, so that backslashes will not be escaped.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# To open jpeg files with the feh command:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['feh']
|
||||||
|
# types = ["image/jpeg"]
|
||||||
|
#
|
||||||
|
# Each command that you specify must come under its own [[mediatype-handlers]]. You may
|
||||||
|
# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
|
||||||
|
#
|
||||||
|
# If the subtype is omitted then the specified command will be used for the
|
||||||
|
# entire type:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# command = ['vlc', '--flag']
|
||||||
|
# types = ["audio", "video"]
|
||||||
|
#
|
||||||
|
# A catch-all handler can by specified with "*".
|
||||||
|
# Note that there are already catch-all handlers in place for all OSes,
|
||||||
|
# that open the file using your default application. This is only if you
|
||||||
|
# want to override that.
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['some-command']
|
||||||
|
# types = [
|
||||||
|
# "application/pdf",
|
||||||
|
# "*",
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# You can also choose to stream the data instead of downloading it all before
|
||||||
|
# opening it. This is especially useful for large video or audio files, as
|
||||||
|
# well as radio streams, which will never complete. You can do this like so:
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['vlc', '-']
|
||||||
|
# types = ["audio", "video"]
|
||||||
|
# stream = true
|
||||||
|
#
|
||||||
|
# This uses vlc to stream all video and audio content.
|
||||||
|
# By default stream is set to off for all handlers
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# If you want to always open a type in its viewer without the download or open
|
||||||
|
# prompt appearing, you can add no_prompt = true
|
||||||
|
#
|
||||||
|
# [[mediatype-handlers]]
|
||||||
|
# cmd = ['feh']
|
||||||
|
# types = ["image"]
|
||||||
|
# no_prompt = true
|
||||||
|
#
|
||||||
|
# Note: Multiple handlers cannot be defined for the same full media type, but
|
||||||
|
# still there needs to be an order for which handlers are used. The following
|
||||||
|
# order applies regardless of the order written in the config:
|
||||||
|
#
|
||||||
|
# 1. Full media type: "image/jpeg"
|
||||||
|
# 2. Just type: "image"
|
||||||
|
# 3. Catch-all: "*"
|
||||||
|
|
||||||
|
|
||||||
[cache]
|
[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
|
# Increase the cache size to speed up browsing at the expense of memory
|
||||||
|
|
||||||
# Zero values mean there is no limit
|
# Zero values mean there is no limit
|
||||||
|
|
||||||
max_size = 0 # Size in bytes
|
max_size = 0 # Size in bytes
|
||||||
max_pages = 30 # The maximum number of pages the cache will store
|
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]
|
[proxies]
|
||||||
# Allows setting a Gemini proxy for different schemes.
|
# Allows setting a Gemini proxy for different schemes.
|
||||||
@ -128,6 +245,28 @@ max_pages = 30 # The maximum number of pages the cache will store
|
|||||||
# Note that HTTP and HTTPS are treated as separate protocols here.
|
# Note that HTTP and HTTPS are treated as separate protocols here.
|
||||||
|
|
||||||
|
|
||||||
|
[subscriptions]
|
||||||
|
# For tracking feeds and pages
|
||||||
|
|
||||||
|
# Whether a pop-up appears when viewing a potential feed
|
||||||
|
popup = true
|
||||||
|
|
||||||
|
# How often to check for updates to subscriptions in the background, in seconds.
|
||||||
|
# Set it to 0 to disable this feature. You can still update individual feeds
|
||||||
|
# manually, or restart the browser.
|
||||||
|
#
|
||||||
|
# Note Amfora will check for updates on browser start no matter what this setting is.
|
||||||
|
update_interval = 1800 # 30 mins
|
||||||
|
|
||||||
|
# How many subscriptions can be checked at the same time when updating.
|
||||||
|
# If you have many subscriptions you may want to increase this for faster
|
||||||
|
# update times. Any value below 1 will be corrected to 1.
|
||||||
|
workers = 3
|
||||||
|
|
||||||
|
# The number of subscription updates displayed per page.
|
||||||
|
entries_per_page = 20
|
||||||
|
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
# This section is for changing the COLORS used in Amfora.
|
# This section is for changing the COLORS used in Amfora.
|
||||||
# These colors only apply if 'color' is enabled above.
|
# These colors only apply if 'color' is enabled above.
|
||||||
@ -186,6 +325,8 @@ max_pages = 30 # The maximum number of pages the cache will store
|
|||||||
# yesno_modal_text
|
# yesno_modal_text
|
||||||
# tofu_modal_bg
|
# tofu_modal_bg
|
||||||
# tofu_modal_text
|
# tofu_modal_text
|
||||||
|
# subscription_modal_bg
|
||||||
|
# subscription_modal_text
|
||||||
|
|
||||||
# input_modal_bg
|
# input_modal_bg
|
||||||
# input_modal_text
|
# input_modal_text
|
||||||
|
@ -110,16 +110,17 @@ func openBkmkModal(name string, exists bool, favicon string) (string, int) {
|
|||||||
|
|
||||||
// Bookmarks displays the bookmarks page on the current tab.
|
// Bookmarks displays the bookmarks page on the current tab.
|
||||||
func Bookmarks(t *tab) {
|
func Bookmarks(t *tab) {
|
||||||
|
bkmkPageRaw := "# Bookmarks\r\n\r\n"
|
||||||
|
|
||||||
// Gather bookmarks
|
// Gather bookmarks
|
||||||
rawContent := "# Bookmarks\r\n\r\n"
|
|
||||||
m, keys := bookmarks.All()
|
m, keys := bookmarks.All()
|
||||||
for i := range keys {
|
for i := range keys {
|
||||||
rawContent += fmt.Sprintf("=> %s %s\r\n", keys[i], m[keys[i]])
|
bkmkPageRaw += fmt.Sprintf("=> %s %s\r\n", keys[i], m[keys[i]])
|
||||||
}
|
}
|
||||||
// Render and display
|
// Render and display
|
||||||
content, links := renderer.RenderGemini(rawContent, textWidth(), leftMargin(), false)
|
content, links := renderer.RenderGemini(bkmkPageRaw, textWidth(), leftMargin(), false)
|
||||||
page := structs.Page{
|
page := structs.Page{
|
||||||
Raw: rawContent,
|
Raw: bkmkPageRaw,
|
||||||
Content: content,
|
Content: content,
|
||||||
Links: links,
|
Links: links,
|
||||||
URL: "about:bookmarks",
|
URL: "about:bookmarks",
|
||||||
|
@ -3,6 +3,7 @@ package display
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -26,8 +27,17 @@ var termH int
|
|||||||
// The user input and URL display bar at the bottom
|
// The user input and URL display bar at the bottom
|
||||||
var bottomBar = cview.NewInputField()
|
var bottomBar = cview.NewInputField()
|
||||||
|
|
||||||
// Viewer for primitives
|
// When the bottom bar string has a space, this regex decides whether it's
|
||||||
// This contains the browser and any modals modals drawn on top of it.
|
// a non-encoded URL or a search string.
|
||||||
|
// See this comment for details:
|
||||||
|
// https://github.com/makeworld-the-better-one/amfora/issues/138#issuecomment-740961292
|
||||||
|
var hasSpaceisURL = regexp.MustCompile(`[^ ]+\.[^ ].*/.`)
|
||||||
|
|
||||||
|
// Viewer for the tab primitives
|
||||||
|
// Pages are named as strings of tab numbers - so the textview for the first tab
|
||||||
|
// is held in the page named "0".
|
||||||
|
// The only pages that don't confine to this scheme are those named after modals,
|
||||||
|
// which are used to draw modals on top the current tab.
|
||||||
// Ex: "info", "error", "input", "yesno"
|
// Ex: "info", "error", "input", "yesno"
|
||||||
var panels = cview.NewPanels()
|
var panels = cview.NewPanels()
|
||||||
|
|
||||||
@ -40,10 +50,25 @@ var browser = cview.NewTabbedPanels()
|
|||||||
var layout = cview.NewFlex()
|
var layout = cview.NewFlex()
|
||||||
|
|
||||||
var newTabPage structs.Page
|
var newTabPage structs.Page
|
||||||
|
var versionPage structs.Page
|
||||||
|
|
||||||
var App = cview.NewApplication()
|
var App = cview.NewApplication()
|
||||||
|
|
||||||
func Init() {
|
func Init(version, commit, builtBy string) {
|
||||||
|
versionContent := fmt.Sprintf(
|
||||||
|
"# Amfora Version Info\n\nAmfora: %s\nCommit: %s\nBuilt by: %s",
|
||||||
|
version, commit, builtBy,
|
||||||
|
)
|
||||||
|
renderVersionContent, versionLinks := renderer.RenderGemini(versionContent, textWidth(), leftMargin(), false)
|
||||||
|
versionPage = structs.Page{
|
||||||
|
Raw: versionContent,
|
||||||
|
Content: renderVersionContent,
|
||||||
|
Links: versionLinks,
|
||||||
|
URL: "about:version",
|
||||||
|
Width: -1, // Force reformatting on first display
|
||||||
|
Mediatype: structs.TextGemini,
|
||||||
|
}
|
||||||
|
|
||||||
App.EnableMouse(false)
|
App.EnableMouse(false)
|
||||||
App.SetRoot(layout, true)
|
App.SetRoot(layout, true)
|
||||||
App.SetAfterResizeFunc(func(width int, height int) {
|
App.SetAfterResizeFunc(func(width int, height int) {
|
||||||
@ -62,6 +87,10 @@ func Init() {
|
|||||||
|
|
||||||
panels.AddPanel("browser", browser, true, true)
|
panels.AddPanel("browser", browser, true, true)
|
||||||
|
|
||||||
|
tabRow.SetChangedFunc(func() {
|
||||||
|
App.Draw()
|
||||||
|
})
|
||||||
|
|
||||||
helpInit()
|
helpInit()
|
||||||
|
|
||||||
layout.SetDirection(cview.FlexRow)
|
layout.SetDirection(cview.FlexRow)
|
||||||
@ -157,14 +186,21 @@ func Init() {
|
|||||||
} else {
|
} else {
|
||||||
// It's a full URL or search term
|
// It's a full URL or search term
|
||||||
// Detect if it's a search or URL
|
// Detect if it's a search or URL
|
||||||
if strings.Contains(query, " ") ||
|
if (strings.Contains(query, " ") && !hasSpaceisURL.MatchString(query)) ||
|
||||||
(!strings.Contains(query, "//") && !strings.Contains(query, ".") && !strings.HasPrefix(query, "about:")) {
|
(!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)
|
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)
|
URL(u)
|
||||||
} else {
|
} else {
|
||||||
// Full URL
|
// 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)
|
URL(query)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -188,6 +224,7 @@ func Init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Render the default new tab content ONCE and store it for later
|
// Render the default new tab content ONCE and store it for later
|
||||||
|
// This code is repeated in Reload()
|
||||||
newTabContent := getNewTabContent()
|
newTabContent := getNewTabContent()
|
||||||
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), leftMargin(), false)
|
renderedNewTabContent, newTabLinks := renderer.RenderGemini(newTabContent, textWidth(), leftMargin(), false)
|
||||||
newTabPage = structs.Page{
|
newTabPage = structs.Page{
|
||||||
@ -225,43 +262,36 @@ func Init() {
|
|||||||
return event
|
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 {
|
if tabs[curTab].mode == tabModeDone {
|
||||||
// All the keys and operations that can only work while NOT loading
|
// 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
|
//nolint:exhaustive
|
||||||
switch event.Key() {
|
switch cmd {
|
||||||
case tcell.KeyCtrlR:
|
case config.CmdReload:
|
||||||
Reload()
|
Reload()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlH:
|
case config.CmdHome:
|
||||||
URL(viper.GetString("a-general.home"))
|
URL(viper.GetString("a-general.home"))
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlB:
|
case config.CmdBookmarks:
|
||||||
Bookmarks(tabs[curTab])
|
Bookmarks(tabs[curTab])
|
||||||
tabs[curTab].addToHistory("about:bookmarks")
|
tabs[curTab].addToHistory("about:bookmarks")
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlD:
|
case config.CmdAddBookmark:
|
||||||
go addBookmark()
|
go addBookmark()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyPgUp:
|
case config.CmdPgup:
|
||||||
tabs[curTab].pageUp()
|
tabs[curTab].pageUp()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyPgDn:
|
case config.CmdPgdn:
|
||||||
tabs[curTab].pageDown()
|
tabs[curTab].pageDown()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlS:
|
case config.CmdSave:
|
||||||
if tabs[curTab].hasContent() {
|
if tabs[curTab].hasContent() {
|
||||||
savePath, err := downloadPage(tabs[curTab].page)
|
savePath, err := downloadPage(tabs[curTab].page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -273,59 +303,48 @@ func Init() {
|
|||||||
Info("The current page has no content, so it couldn't be downloaded.")
|
Info("The current page has no content, so it couldn't be downloaded.")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyRune:
|
case config.CmdBottom:
|
||||||
// Regular key was sent
|
// Space starts typing, like Bombadillo
|
||||||
switch string(event.Rune()) {
|
bottomBar.SetLabel("[::b]URL/Num./Search: [::-]")
|
||||||
case " ":
|
bottomBar.SetText("")
|
||||||
// Space starts typing, like Bombadillo
|
// Don't save bottom bar, so that whenever you switch tabs, it's not in that mode
|
||||||
bottomBar.SetLabel("[::b]URL/Num./Search: [::-]")
|
App.SetFocus(bottomBar)
|
||||||
bottomBar.SetText("")
|
return nil
|
||||||
// Don't save bottom bar, so that whenever you switch tabs, it's not in that mode
|
case config.CmdEdit:
|
||||||
App.SetFocus(bottomBar)
|
// Letter e allows to edit current URL
|
||||||
return nil
|
bottomBar.SetLabel("[::b]Edit URL: [::-]")
|
||||||
case "e":
|
bottomBar.SetText(tabs[curTab].page.URL)
|
||||||
// Letter e allows to edit current URL
|
App.SetFocus(bottomBar)
|
||||||
bottomBar.SetLabel("[::b]Edit URL: [::-]")
|
return nil
|
||||||
bottomBar.SetText(tabs[curTab].page.URL)
|
case config.CmdBack:
|
||||||
App.SetFocus(bottomBar)
|
histBack(tabs[curTab])
|
||||||
return nil
|
return nil
|
||||||
case "R":
|
case config.CmdForward:
|
||||||
Reload()
|
histForward(tabs[curTab])
|
||||||
return nil
|
return nil
|
||||||
case "b":
|
case config.CmdSub:
|
||||||
histBack(tabs[curTab])
|
Subscriptions(tabs[curTab], "about:subscriptions")
|
||||||
return nil
|
tabs[curTab].addToHistory("about:subscriptions")
|
||||||
case "f":
|
return nil
|
||||||
histForward(tabs[curTab])
|
case config.CmdAddSub:
|
||||||
return nil
|
go addSubscription()
|
||||||
case "u":
|
return nil
|
||||||
tabs[curTab].pageUp()
|
}
|
||||||
return nil
|
|
||||||
case "d":
|
|
||||||
tabs[curTab].pageDown()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number key: 1-9, 0
|
// Number key: 1-9, 0, LINK1-LINK10
|
||||||
i, err := strconv.Atoi(string(event.Rune()))
|
if cmd >= config.CmdLink1 && cmd <= config.CmdLink0 {
|
||||||
if err == nil {
|
if int(cmd) <= len(tabs[curTab].page.Links) {
|
||||||
if i == 0 {
|
// It's a valid link number
|
||||||
i = 10 // 0 key is for link 10
|
followLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Links[cmd-1])
|
||||||
}
|
return nil
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All the keys and operations that can work while a tab IS loading
|
// All the keys and operations that can work while a tab IS loading
|
||||||
|
|
||||||
//nolint:exhaustive
|
//nolint:exhaustive
|
||||||
switch event.Key() {
|
switch cmd {
|
||||||
case tcell.KeyCtrlT:
|
case config.CmdNewTab:
|
||||||
if tabs[curTab].page.Mode == structs.ModeLinkSelect {
|
if tabs[curTab].page.Mode == structs.ModeLinkSelect {
|
||||||
next, err := resolveRelLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Selected)
|
next, err := resolveRelLink(tabs[curTab], tabs[curTab].page.URL, tabs[curTab].page.Selected)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -338,45 +357,33 @@ func Init() {
|
|||||||
NewTab()
|
NewTab()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlW:
|
case config.CmdCloseTab:
|
||||||
CloseTab()
|
CloseTab()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlQ:
|
case config.CmdQuit:
|
||||||
Stop()
|
Stop()
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyCtrlC:
|
case config.CmdPrevTab:
|
||||||
Stop()
|
|
||||||
return nil
|
|
||||||
case tcell.KeyF1:
|
|
||||||
// Wrap around, allow for modulo with negative numbers
|
// Wrap around, allow for modulo with negative numbers
|
||||||
n := NumTabs()
|
n := NumTabs()
|
||||||
SwitchTab((((curTab - 1) % n) + n) % n)
|
SwitchTab((((curTab - 1) % n) + n) % n)
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyF2:
|
case config.CmdNextTab:
|
||||||
SwitchTab((curTab + 1) % NumTabs())
|
SwitchTab((curTab + 1) % NumTabs())
|
||||||
return nil
|
return nil
|
||||||
case tcell.KeyRune:
|
case config.CmdHelp:
|
||||||
// Regular key was sent
|
Help()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if num, err := config.KeyToNum(event.Rune()); err == nil {
|
if cmd >= config.CmdTab1 && cmd <= config.CmdTab0 {
|
||||||
// It's a Shift+Num key
|
if cmd == config.CmdTab0 {
|
||||||
if num == 0 {
|
// Zero key goes to the last tab
|
||||||
// Zero key goes to the last tab
|
SwitchTab(NumTabs() - 1)
|
||||||
SwitchTab(NumTabs() - 1)
|
} else {
|
||||||
} else {
|
SwitchTab(int(cmd - config.CmdTab1))
|
||||||
SwitchTab(num - 1)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch string(event.Rune()) {
|
|
||||||
case "q":
|
|
||||||
Stop()
|
|
||||||
return nil
|
|
||||||
case "?":
|
|
||||||
Help()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let another element handle the event, it's not a special global key
|
// Let another element handle the event, it's not a special global key
|
||||||
@ -412,10 +419,8 @@ func NewTab() {
|
|||||||
tabs = append(tabs, makeNewTab())
|
tabs = append(tabs, makeNewTab())
|
||||||
temp := newTabPage // Copy
|
temp := newTabPage // Copy
|
||||||
setPage(tabs[curTab], &temp)
|
setPage(tabs[curTab], &temp)
|
||||||
|
tabs[curTab].addToHistory("about:newtab")
|
||||||
// Can't go backwards, but this isn't the first page either.
|
tabs[curTab].history.pos = 0 // Manually set as first page
|
||||||
// The first page will be the next one the user goes to.
|
|
||||||
tabs[curTab].history.pos = -1
|
|
||||||
|
|
||||||
browser.AddTab(strconv.Itoa(curTab), strconv.Itoa(curTab+1), tabs[curTab].view)
|
browser.AddTab(strconv.Itoa(curTab), strconv.Itoa(curTab+1), tabs[curTab].view)
|
||||||
browser.SetCurrentTab(strconv.Itoa(curTab))
|
browser.SetCurrentTab(strconv.Itoa(curTab))
|
||||||
@ -534,28 +539,15 @@ func Reload() {
|
|||||||
// URL loads and handles the provided URL for the current tab.
|
// URL loads and handles the provided URL for the current tab.
|
||||||
// It should be an absolute URL.
|
// It should be an absolute URL.
|
||||||
func URL(u string) {
|
func URL(u string) {
|
||||||
// Some code is copied in followLink()
|
t := tabs[curTab]
|
||||||
|
|
||||||
if u == "about:bookmarks" { //nolint:goconst
|
|
||||||
Bookmarks(tabs[curTab])
|
|
||||||
tabs[curTab].addToHistory("about:bookmarks")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u == "about:newtab" {
|
|
||||||
temp := newTabPage // Copy
|
|
||||||
setPage(tabs[curTab], &temp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(u, "about:") {
|
if strings.HasPrefix(u, "about:") {
|
||||||
Error("Error", "Not a valid 'about:' URL.")
|
if final, ok := handleAbout(t, u); ok {
|
||||||
|
t.addToHistory(final)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") {
|
go goURL(t, fixUserURL(u))
|
||||||
// Assume it's a Gemini URL
|
|
||||||
u = "gemini://" + u
|
|
||||||
}
|
|
||||||
go goURL(tabs[curTab], u)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NumTabs() int {
|
func NumTabs() int {
|
||||||
|
@ -4,8 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"mime"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -15,8 +17,9 @@ import (
|
|||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/makeworld-the-better-one/amfora/config"
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
"github.com/makeworld-the-better-one/amfora/structs"
|
"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/go-gemini"
|
||||||
"github.com/makeworld-the-better-one/progressbar/v3"
|
"github.com/schollz/progressbar/v3"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gitlab.com/tslocum/cview"
|
"gitlab.com/tslocum/cview"
|
||||||
)
|
)
|
||||||
@ -65,7 +68,7 @@ func dlInit() {
|
|||||||
frame.SetTitleColor(tcell.ColorWhite)
|
frame.SetTitleColor(tcell.ColorWhite)
|
||||||
}
|
}
|
||||||
|
|
||||||
chm.AddButtons([]string{"Download", "Open in portal", "Cancel"})
|
chm.AddButtons([]string{"Open", "Download", "Cancel"})
|
||||||
chm.SetBorder(true)
|
chm.SetBorder(true)
|
||||||
chm.GetFrame().SetTitleAlign(cview.AlignCenter)
|
chm.GetFrame().SetTitleAlign(cview.AlignCenter)
|
||||||
chm.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
chm.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
@ -85,55 +88,127 @@ func dlInit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getMediaHandler(resp *gemini.Response) config.MediaHandler {
|
||||||
|
def := config.MediaHandler{
|
||||||
|
Cmd: nil,
|
||||||
|
NoPrompt: false,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
mediatype, _, err := mime.ParseMediaType(resp.Meta)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret, ok := config.MediaHandlers[mediatype]; ok {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
splitType := strings.Split(mediatype, "/")[0]
|
||||||
|
if ret, ok := config.MediaHandlers[splitType]; ok {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret, ok := config.MediaHandlers["*"]; ok {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// dlChoice displays the download choice modal and acts on the user's choice.
|
// dlChoice displays the download choice modal and acts on the user's choice.
|
||||||
// It should run in a goroutine.
|
// It should run in a goroutine.
|
||||||
func dlChoice(text, u string, resp *gemini.Response) {
|
func dlChoice(text, u string, resp *gemini.Response) {
|
||||||
defer resp.Body.Close()
|
mediaHandler := getMediaHandler(resp)
|
||||||
|
var choice string
|
||||||
|
|
||||||
parsed, err := url.Parse(u)
|
if mediaHandler.NoPrompt {
|
||||||
if err != nil {
|
choice = "Open"
|
||||||
Error("URL Error", err.Error())
|
} else {
|
||||||
return
|
dlChoiceModal.SetText(text)
|
||||||
|
tabPages.ShowPage("dlChoice")
|
||||||
|
tabPages.SendToFront("dlChoice")
|
||||||
|
App.SetFocus(dlChoiceModal)
|
||||||
|
App.Draw()
|
||||||
|
choice = <-dlChoiceCh
|
||||||
}
|
}
|
||||||
|
|
||||||
dlChoiceModal.SetText(text)
|
|
||||||
panels.ShowPanel("dlChoice")
|
|
||||||
App.SetFocus(dlChoiceModal)
|
|
||||||
App.Draw()
|
|
||||||
|
|
||||||
choice := <-dlChoiceCh
|
|
||||||
if choice == "Download" {
|
if choice == "Download" {
|
||||||
panels.HidePanel("dlChoice")
|
panels.HidePanel("dlChoice")
|
||||||
App.Draw()
|
App.Draw()
|
||||||
downloadURL(u, resp)
|
downloadURL(config.DownloadsDir, u, resp)
|
||||||
|
resp.Body.Close() // Only close when the file is downloaded
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if choice == "Open in portal" {
|
if choice == "Open" {
|
||||||
// Open in mozz's proxy
|
tabPages.HidePage("dlChoice")
|
||||||
portalURL := u
|
App.Draw()
|
||||||
if parsed.RawQuery != "" {
|
open(u, resp)
|
||||||
// Remove query and add encoded version on the end
|
return
|
||||||
query := parsed.RawQuery
|
}
|
||||||
parsed.RawQuery = ""
|
tabPages.SwitchToPage(strconv.Itoa(curTab))
|
||||||
portalURL = parsed.String() + "%3F" + query
|
App.SetFocus(tabs[curTab].view)
|
||||||
|
App.Draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// open performs the same actions as downloadURL except it also opens the file.
|
||||||
|
// If there is no system viewer configured for the particular mediatype, it opens it
|
||||||
|
// with the default system viewer.
|
||||||
|
func open(u string, resp *gemini.Response) {
|
||||||
|
mediaHandler := getMediaHandler(resp)
|
||||||
|
|
||||||
|
if mediaHandler.Stream {
|
||||||
|
// Run command with downloaded data from stdin
|
||||||
|
|
||||||
|
cmd := mediaHandler.Cmd
|
||||||
|
var proc *exec.Cmd
|
||||||
|
if len(cmd) == 1 {
|
||||||
|
proc = exec.Command(cmd[0])
|
||||||
|
} else {
|
||||||
|
proc = exec.Command(cmd[0], cmd[1:]...)
|
||||||
}
|
}
|
||||||
portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1"
|
proc.Stdin = resp.Body
|
||||||
ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false)
|
|
||||||
if ok {
|
err := proc.Start()
|
||||||
browser.SetCurrentTab(strconv.Itoa(curTab))
|
if err != nil {
|
||||||
App.SetFocus(tabs[curTab].view)
|
Error("File Opening Error", "Error executing custom command: "+err.Error())
|
||||||
App.Draw()
|
return
|
||||||
}
|
}
|
||||||
|
Info("Opened with " + cmd[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := downloadURL(config.TempDownloadsDir, u, resp)
|
||||||
|
if path == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
browser.SetCurrentTab(strconv.Itoa(curTab))
|
browser.SetCurrentTab(strconv.Itoa(curTab))
|
||||||
App.SetFocus(tabs[curTab].view)
|
App.SetFocus(tabs[curTab].view)
|
||||||
App.Draw()
|
App.Draw()
|
||||||
|
if mediaHandler.Cmd == nil {
|
||||||
|
// Open with system default viewer
|
||||||
|
_, err := sysopen.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
Error("System Viewer Error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Info("Opened in default system viewer")
|
||||||
|
} else {
|
||||||
|
cmd := mediaHandler.Cmd
|
||||||
|
err := exec.Command(cmd[0], append(cmd[1:], path)...).Start()
|
||||||
|
if err != nil {
|
||||||
|
Error("File Opening Error", "Error executing custom command: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Info("Opened with " + cmd[0])
|
||||||
|
}
|
||||||
|
App.Draw()
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadURL pulls up a modal to show download progress and saves the URL content.
|
// downloadURL pulls up a modal to show download progress and saves the URL content.
|
||||||
// downloadPage should be used for Page content.
|
// downloadPage should be used for Page content.
|
||||||
func downloadURL(u string, resp *gemini.Response) {
|
// Returns location downloaded to or an empty string on error.
|
||||||
|
func downloadURL(dir, u string, resp *gemini.Response) string {
|
||||||
_, _, width, _ := dlModal.GetInnerRect()
|
_, _, width, _ := dlModal.GetInnerRect()
|
||||||
// Copy of progressbar.DefaultBytesSilent with custom width
|
// Copy of progressbar.DefaultBytesSilent with custom width
|
||||||
bar := progressbar.NewOptions64(
|
bar := progressbar.NewOptions64(
|
||||||
@ -147,15 +222,15 @@ func downloadURL(u string, resp *gemini.Response) {
|
|||||||
)
|
)
|
||||||
bar.RenderBlank() //nolint:errcheck
|
bar.RenderBlank() //nolint:errcheck
|
||||||
|
|
||||||
savePath, err := downloadNameFromURL(u, "")
|
savePath, err := downloadNameFromURL(dir, u, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Error("Download Error", "Error deciding on file name: "+err.Error())
|
Error("Download Error", "Error deciding on file name: "+err.Error())
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Error("Download Error", "Error creating download file: "+err.Error())
|
Error("Download Error", "Error creating download file: "+err.Error())
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@ -184,7 +259,7 @@ func downloadURL(u string, resp *gemini.Response) {
|
|||||||
Error("Download Error", err.Error())
|
Error("Download Error", err.Error())
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(savePath) // Remove partial file
|
os.Remove(savePath) // Remove partial file
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath))
|
dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath))
|
||||||
dlModal.ClearButtons()
|
dlModal.ClearButtons()
|
||||||
@ -192,6 +267,8 @@ func downloadURL(u string, resp *gemini.Response) {
|
|||||||
dlModal.GetForm().SetFocus(100)
|
dlModal.GetForm().SetFocus(100)
|
||||||
App.SetFocus(dlModal)
|
App.SetFocus(dlModal)
|
||||||
App.Draw()
|
App.Draw()
|
||||||
|
|
||||||
|
return savePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadPage saves the passed Page to a file.
|
// downloadPage saves the passed Page to a file.
|
||||||
@ -202,9 +279,9 @@ func downloadPage(p *structs.Page) (string, error) {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if p.Mediatype == structs.TextGemini {
|
if p.Mediatype == structs.TextGemini {
|
||||||
savePath, err = downloadNameFromURL(p.URL, ".gmi")
|
savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".gmi")
|
||||||
} else {
|
} else {
|
||||||
savePath, err = downloadNameFromURL(p.URL, ".txt")
|
savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".txt")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -221,13 +298,13 @@ func downloadPage(p *structs.Page) (string, error) {
|
|||||||
// downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file.
|
// downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file.
|
||||||
// ext is an extension that will be added if the file has no extension, and for domain only URLs.
|
// ext is an extension that will be added if the file has no extension, and for domain only URLs.
|
||||||
// It should include the dot.
|
// It should include the dot.
|
||||||
func downloadNameFromURL(u string, ext string) (string, error) {
|
func downloadNameFromURL(dir, u, ext string) (string, error) {
|
||||||
var name string
|
var name string
|
||||||
var err error
|
var err error
|
||||||
parsed, _ := url.Parse(u)
|
parsed, _ := url.Parse(u)
|
||||||
if parsed.Path == "" || path.Base(parsed.Path) == "/" {
|
if parsed.Path == "" || path.Base(parsed.Path) == "/" {
|
||||||
// No file, just the root domain
|
// No file, just the root domain
|
||||||
name, err = getSafeDownloadName(parsed.Hostname()+ext, true, 0)
|
name, err = getSafeDownloadName(dir, parsed.Hostname()+ext, true, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -238,23 +315,23 @@ func downloadNameFromURL(u string, ext string) (string, error) {
|
|||||||
// No extension
|
// No extension
|
||||||
name += ext
|
name += ext
|
||||||
}
|
}
|
||||||
name, err = getSafeDownloadName(name, false, 0)
|
name, err = getSafeDownloadName(dir, name, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filepath.Join(config.DownloadsDir, name), nil
|
return filepath.Join(dir, name), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSafeDownloadName is used by downloads.go only.
|
// getSafeDownloadName is used by downloads.go only.
|
||||||
// It returns a modified name that is unique for the downloads folder.
|
// It returns a modified name that is unique for the specified folder.
|
||||||
// This way duplicate saved files will not overwrite each other.
|
// This way duplicate saved files will not overwrite each other.
|
||||||
//
|
//
|
||||||
// lastDot should be set to true if the number added to the name should come before
|
// lastDot should be set to true if the number added to the name should come before
|
||||||
// the last dot in the filename instead of the first.
|
// the last dot in the filename instead of the first.
|
||||||
//
|
//
|
||||||
// n should be set to 0, it is used for recursiveness.
|
// n should be set to 0, it is used for recursiveness.
|
||||||
func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
|
func getSafeDownloadName(dir, name string, lastDot bool, n int) (string, error) {
|
||||||
// newName("test.txt", 3) -> "test(3).txt"
|
// newName("test.txt", 3) -> "test(3).txt"
|
||||||
newName := func() string {
|
newName := func() string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
@ -271,7 +348,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
|
|||||||
return name[:idx] + "(" + strconv.Itoa(n) + ")" + name[idx:]
|
return name[:idx] + "(" + strconv.Itoa(n) + ")" + name[idx:]
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := os.Open(config.DownloadsDir)
|
d, err := os.Open(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -285,7 +362,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
|
|||||||
for i := range files {
|
for i := range files {
|
||||||
if nn == files[i] {
|
if nn == files[i] {
|
||||||
d.Close()
|
d.Close()
|
||||||
return getSafeDownloadName(name, lastDot, n+1)
|
return getSafeDownloadName(dir, name, lastDot, n+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.Close()
|
d.Close()
|
||||||
|
120
display/file.go
Normal file
120
display/file.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/makeworld-the-better-one/amfora/renderer"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/structs"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleFile handles urls using file:// protocol
|
||||||
|
func handleFile(u string) (*structs.Page, bool) {
|
||||||
|
page := &structs.Page{}
|
||||||
|
|
||||||
|
uri, err := url.ParseRequestURI(u)
|
||||||
|
if err != nil {
|
||||||
|
Error("File Error", "Cannot parse URI: "+err.Error())
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(uri.Path)
|
||||||
|
if err != nil {
|
||||||
|
Error("File Error", "Cannot open local file: "+err.Error())
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode := fi.Mode(); {
|
||||||
|
case mode.IsDir():
|
||||||
|
// Must end in slash
|
||||||
|
if u[len(u)-1] != '/' {
|
||||||
|
u += "/"
|
||||||
|
}
|
||||||
|
return createDirectoryListing(u)
|
||||||
|
case mode.IsRegular():
|
||||||
|
if fi.Size() > viper.GetInt64("a-general.page_max_size") {
|
||||||
|
Error("File Error", "Cannot open local file, exceeds page max size")
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
|
||||||
|
mimetype := mime.TypeByExtension(filepath.Ext(uri.Path))
|
||||||
|
if strings.HasSuffix(u, ".gmi") || strings.HasSuffix(u, ".gemini") {
|
||||||
|
mimetype = "text/gemini"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(mimetype, "text/") {
|
||||||
|
Error("File Error", "Cannot open file, not recognized as text.")
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := ioutil.ReadFile(uri.Path)
|
||||||
|
if err != nil {
|
||||||
|
Error("File Error", "Cannot open local file: "+err.Error())
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if mimetype == "text/gemini" {
|
||||||
|
rendered, links := renderer.RenderGemini(string(content), textWidth(), leftMargin(), false)
|
||||||
|
page = &structs.Page{
|
||||||
|
Mediatype: structs.TextGemini,
|
||||||
|
URL: u,
|
||||||
|
Raw: string(content),
|
||||||
|
Content: rendered,
|
||||||
|
Links: links,
|
||||||
|
Width: termW,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
page = &structs.Page{
|
||||||
|
Mediatype: structs.TextPlain,
|
||||||
|
URL: u,
|
||||||
|
Raw: string(content),
|
||||||
|
Content: renderer.RenderPlainText(string(content), leftMargin()),
|
||||||
|
Links: []string{},
|
||||||
|
Width: termW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return page, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDirectoryListing creates a text/gemini page for a directory
|
||||||
|
// that lists all the files as links.
|
||||||
|
func createDirectoryListing(u string) (*structs.Page, bool) {
|
||||||
|
page := &structs.Page{}
|
||||||
|
|
||||||
|
uri, err := url.ParseRequestURI(u)
|
||||||
|
if err != nil {
|
||||||
|
Error("Directory Error", "Cannot parse URI: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := ioutil.ReadDir(uri.Path)
|
||||||
|
if err != nil {
|
||||||
|
Error("Directory error", "Cannot open local directory: "+err.Error())
|
||||||
|
return page, false
|
||||||
|
}
|
||||||
|
content := "Index of " + uri.Path + "\n"
|
||||||
|
content += "=> ../ ../\n"
|
||||||
|
for _, f := range files {
|
||||||
|
separator := ""
|
||||||
|
if f.IsDir() {
|
||||||
|
separator = "/"
|
||||||
|
}
|
||||||
|
content += fmt.Sprintf("=> %s%s %s%s\n", f.Name(), separator, f.Name(), separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, links := renderer.RenderGemini(content, textWidth(), leftMargin(), false)
|
||||||
|
page = &structs.Page{
|
||||||
|
Mediatype: structs.TextGemini,
|
||||||
|
URL: u,
|
||||||
|
Raw: content,
|
||||||
|
Content: rendered,
|
||||||
|
Links: links,
|
||||||
|
Width: termW,
|
||||||
|
}
|
||||||
|
return page, true
|
||||||
|
}
|
522
display/handlers.go
Normal file
522
display/handlers.go
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/makeworld-the-better-one/amfora/cache"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/client"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/renderer"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/rr"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/structs"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/subscriptions"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/webbrowser"
|
||||||
|
"github.com/makeworld-the-better-one/go-gemini"
|
||||||
|
"github.com/makeworld-the-better-one/go-isemoji"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleHTTP is used by handleURL.
|
||||||
|
// It opens HTTP links and displays Info and Error modals.
|
||||||
|
// Returns false if there was an error.
|
||||||
|
func handleHTTP(u string, showInfo bool) bool {
|
||||||
|
if len(config.HTTPCommand) == 1 {
|
||||||
|
// Possibly a non-command
|
||||||
|
|
||||||
|
switch strings.TrimSpace(config.HTTPCommand[0]) {
|
||||||
|
case "", "off":
|
||||||
|
Error("HTTP Error", "Opening HTTP URLs is turned off.")
|
||||||
|
return false
|
||||||
|
case "default":
|
||||||
|
s, err := webbrowser.Open(u)
|
||||||
|
if err != nil {
|
||||||
|
Error("Webbrowser Error", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if showInfo {
|
||||||
|
Info(s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom command
|
||||||
|
var err error = nil
|
||||||
|
if len(config.HTTPCommand) > 1 {
|
||||||
|
err = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...).Start()
|
||||||
|
} else {
|
||||||
|
err = exec.Command(config.HTTPCommand[0], u).Start()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
Error("HTTP Error", "Error executing custom browser command: "+err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Draw()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOther is used by handleURL.
|
||||||
|
// It opens links other than Gemini and HTTP and displays Error modals.
|
||||||
|
func handleOther(u string) {
|
||||||
|
// The URL should have a scheme due to a previous call to normalizeURL
|
||||||
|
parsed, _ := url.Parse(u)
|
||||||
|
|
||||||
|
// Search for a handler for the URL scheme
|
||||||
|
handler := strings.TrimSpace(viper.GetString("url-handlers." + parsed.Scheme))
|
||||||
|
if len(handler) == 0 {
|
||||||
|
handler = strings.TrimSpace(viper.GetString("url-handlers.other"))
|
||||||
|
}
|
||||||
|
switch handler {
|
||||||
|
case "", "off":
|
||||||
|
Error("URL Error", "Opening "+parsed.Scheme+" URLs is turned off.")
|
||||||
|
default:
|
||||||
|
// The config has a custom command to execute for URLs
|
||||||
|
fields := strings.Fields(handler)
|
||||||
|
err := exec.Command(fields[0], append(fields[1:], u)...).Start()
|
||||||
|
if err != nil {
|
||||||
|
Error("URL Error", "Error executing custom command: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
App.Draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFavicon handles getting and displaying a favicon.
|
||||||
|
// `old` is the previous favicon for the tab.
|
||||||
|
func handleFavicon(t *tab, host, old string) {
|
||||||
|
defer func() {
|
||||||
|
// Update display if needed
|
||||||
|
if t.page.Favicon != old && isValidTab(t) {
|
||||||
|
rewriteTabRow()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !viper.GetBool("a-general.emoji_favicons") {
|
||||||
|
// Not enabled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.page.Favicon != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fav := cache.GetFavicon(host)
|
||||||
|
if fav == cache.KnownNoFavicon {
|
||||||
|
// It's been cached that this host doesn't have a favicon
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fav != "" {
|
||||||
|
t.page.Favicon = fav
|
||||||
|
rewriteTabRow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No favicon cached
|
||||||
|
res, err := client.Fetch("gemini://" + host + "/favicon.txt")
|
||||||
|
if err != nil {
|
||||||
|
if res != nil {
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
cache.AddFavicon(host, cache.KnownNoFavicon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.Status != 20 {
|
||||||
|
cache.AddFavicon(host, cache.KnownNoFavicon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(res.Meta, "text/") {
|
||||||
|
cache.AddFavicon(host, cache.KnownNoFavicon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// It's a regular plain response
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, err = io.CopyN(buf, res.Body, 29+2+1) // 29 is the max emoji length, +2 for CRLF, +1 so that the right size will EOF
|
||||||
|
if err == nil {
|
||||||
|
// Content was too large
|
||||||
|
cache.AddFavicon(host, cache.KnownNoFavicon)
|
||||||
|
return
|
||||||
|
} else if err != io.EOF {
|
||||||
|
// Some network reading error
|
||||||
|
// No favicon is NOT known, could be a temporary error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// EOF, which is what we want.
|
||||||
|
emoji := strings.TrimRight(buf.String(), "\r\n")
|
||||||
|
if !isemoji.IsEmoji(emoji) {
|
||||||
|
cache.AddFavicon(host, cache.KnownNoFavicon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Valid favicon found
|
||||||
|
t.page.Favicon = emoji
|
||||||
|
cache.AddFavicon(host, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbout can be called to deal with any URLs that start with
|
||||||
|
// 'about:'. It will display errors if the URL is not recognized,
|
||||||
|
// but not display anything if an 'about:' URL is not passed.
|
||||||
|
//
|
||||||
|
// It does not add the displayed page to history.
|
||||||
|
//
|
||||||
|
// It returns the URL displayed, and a bool indicating if the provided
|
||||||
|
// URL could be handled. The string returned will always be empty
|
||||||
|
// if the bool is false.
|
||||||
|
func handleAbout(t *tab, u string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(u, "about:") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u {
|
||||||
|
case "about:bookmarks":
|
||||||
|
Bookmarks(t)
|
||||||
|
return u, true
|
||||||
|
case "about:newtab":
|
||||||
|
temp := newTabPage // Copy
|
||||||
|
setPage(t, &temp)
|
||||||
|
t.applyBottomBar()
|
||||||
|
return u, true
|
||||||
|
case "about:version":
|
||||||
|
temp := versionPage
|
||||||
|
setPage(t, &temp)
|
||||||
|
t.applyBottomBar()
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if u == "about:subscriptions" || (len(u) > 20 && u[:20] == "about:subscriptions?") {
|
||||||
|
// about:subscriptions?2 views page 2
|
||||||
|
return Subscriptions(t, u), true
|
||||||
|
}
|
||||||
|
if u == "about:manage-subscriptions" || (len(u) > 27 && u[:27] == "about:manage-subscriptions?") {
|
||||||
|
ManageSubscriptions(t, u)
|
||||||
|
// Don't count remove command in history
|
||||||
|
if u == "about:manage-subscriptions" {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
Error("Error", "Not a valid 'about:' URL.")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleURL displays whatever action is needed for the provided URL,
|
||||||
|
// and applies it to the current tab.
|
||||||
|
// It loads documents, handles errors, brings up a download prompt, etc.
|
||||||
|
//
|
||||||
|
// The string returned is the final URL, if redirects were involved.
|
||||||
|
// In most cases it will be the same as the passed URL.
|
||||||
|
// If there is some error, it will return "".
|
||||||
|
// The second returned item is a bool indicating if page content was displayed.
|
||||||
|
// It returns false for Errors, other protocols, etc.
|
||||||
|
//
|
||||||
|
// The bottomBar is not actually changed in this func, except during loading.
|
||||||
|
// The func that calls this one should apply the bottomBar values if necessary.
|
||||||
|
//
|
||||||
|
// numRedirects is the number of redirects that resulted in the provided URL.
|
||||||
|
// It should typically be 0.
|
||||||
|
func handleURL(t *tab, u string, numRedirects int) (string, bool) {
|
||||||
|
defer App.Draw() // Just in case
|
||||||
|
|
||||||
|
// Save for resetting on error
|
||||||
|
oldLable := t.barLabel
|
||||||
|
oldText := t.barText
|
||||||
|
|
||||||
|
// Custom return function
|
||||||
|
ret := func(s string, b bool) (string, bool) {
|
||||||
|
if !b {
|
||||||
|
// Reset bottomBar if page wasn't loaded
|
||||||
|
t.barLabel = oldLable
|
||||||
|
t.barText = oldText
|
||||||
|
}
|
||||||
|
t.mode = tabModeDone
|
||||||
|
|
||||||
|
go func(p *structs.Page) {
|
||||||
|
if b && t.hasContent() && viper.GetBool("subscriptions.popup") {
|
||||||
|
// The current page might be an untracked feed, and the user wants
|
||||||
|
// to be notified in such cases.
|
||||||
|
|
||||||
|
feed, isFeed := getFeedFromPage(p)
|
||||||
|
if isFeed && isValidTab(t) && t.page == p {
|
||||||
|
// After parsing and track-checking time, the page is still being displayed
|
||||||
|
addFeedDirect(p.URL, feed, subscriptions.IsSubscribed(p.URL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(t.page)
|
||||||
|
|
||||||
|
return s, b
|
||||||
|
}
|
||||||
|
|
||||||
|
t.barLabel = ""
|
||||||
|
bottomBar.SetLabel("")
|
||||||
|
|
||||||
|
App.SetFocus(t.view)
|
||||||
|
|
||||||
|
if strings.HasPrefix(u, "about:") {
|
||||||
|
return ret(handleAbout(t, u))
|
||||||
|
}
|
||||||
|
|
||||||
|
u = normalizeURL(u)
|
||||||
|
u = cache.Redirect(u)
|
||||||
|
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
Error("URL Error", err.Error())
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := strings.TrimSpace(viper.GetString("proxies." + parsed.Scheme))
|
||||||
|
usingProxy := false
|
||||||
|
|
||||||
|
proxyHostname, proxyPort, err := net.SplitHostPort(proxy)
|
||||||
|
if err != nil {
|
||||||
|
// Error likely means there's no port in the host
|
||||||
|
proxyHostname = proxy
|
||||||
|
proxyPort = "1965"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(u, "http") {
|
||||||
|
if proxy == "" || proxy == "off" {
|
||||||
|
// No proxy available
|
||||||
|
handleHTTP(u, true)
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
usingProxy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(u, "file") {
|
||||||
|
page, ok := handleFile(u)
|
||||||
|
if !ok {
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
setPage(t, page)
|
||||||
|
return ret(u, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") && !strings.HasPrefix(u, "file") {
|
||||||
|
// Not a Gemini URL
|
||||||
|
if proxy == "" || proxy == "off" {
|
||||||
|
// No proxy available
|
||||||
|
handleOther(u)
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
usingProxy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini URL, or one with a Gemini proxy available
|
||||||
|
|
||||||
|
// Load page from cache if it exists,
|
||||||
|
// and this isn't a page that was redirected to by the server (indicates dynamic content)
|
||||||
|
if numRedirects == 0 {
|
||||||
|
page, ok := cache.GetPage(u)
|
||||||
|
if ok {
|
||||||
|
setPage(t, page)
|
||||||
|
return ret(u, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise download it
|
||||||
|
bottomBar.SetText("Loading...")
|
||||||
|
t.barText = "Loading..." // Save it too, in case the tab switches during loading
|
||||||
|
t.mode = tabModeLoading
|
||||||
|
App.Draw()
|
||||||
|
|
||||||
|
var res *gemini.Response
|
||||||
|
if usingProxy {
|
||||||
|
res, err = client.FetchWithProxy(proxyHostname, proxyPort, u)
|
||||||
|
} else {
|
||||||
|
res, err = client.Fetch(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading may have taken a while, make sure tab is still valid
|
||||||
|
if !isValidTab(t) {
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, client.ErrTofu) {
|
||||||
|
if usingProxy {
|
||||||
|
// They are using a proxy
|
||||||
|
if Tofu(proxy, client.GetExpiry(proxyHostname, proxyPort)) {
|
||||||
|
// They want to continue anyway
|
||||||
|
client.ResetTofuEntry(proxyHostname, proxyPort, res.Cert)
|
||||||
|
// Response can be used further down, no need to reload
|
||||||
|
} else {
|
||||||
|
// They don't want to continue
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
|
||||||
|
// They want to continue anyway
|
||||||
|
client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
|
||||||
|
// Response can be used further down, no need to reload
|
||||||
|
} else {
|
||||||
|
// They don't want to continue
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
Error("URL Fetch Error", err.Error())
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch happened successfully, use RestartReader to buffer read data
|
||||||
|
res.Body = rr.NewRestartReader(res.Body)
|
||||||
|
|
||||||
|
if renderer.CanDisplay(res) {
|
||||||
|
page, err := renderer.MakePage(u, res, textWidth(), leftMargin(), usingProxy)
|
||||||
|
// Rendering may have taken a while, make sure tab is still valid
|
||||||
|
if !isValidTab(t) {
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, renderer.ErrTooLarge) {
|
||||||
|
// Downloading now
|
||||||
|
// Disable read timeout and go back to start
|
||||||
|
res.SetReadTimeout(0) //nolint: errcheck
|
||||||
|
res.Body.(*rr.RestartReader).Restart()
|
||||||
|
go dlChoice("That page is too large. What would you like to do?", u, res)
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
if errors.Is(err, renderer.ErrTimedOut) {
|
||||||
|
// Downloading now
|
||||||
|
// Disable read timeout and go back to start
|
||||||
|
res.SetReadTimeout(0) //nolint: errcheck
|
||||||
|
res.Body.(*rr.RestartReader).Restart()
|
||||||
|
go dlChoice("Loading that page timed out. What would you like to do?", u, res)
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
Error("Page Error", "Issuing creating page: "+err.Error())
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Width = termW
|
||||||
|
|
||||||
|
if !client.HasClientCert(parsed.Host) {
|
||||||
|
// Don't cache pages with client certs
|
||||||
|
go cache.AddPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(t, page)
|
||||||
|
return ret(u, true)
|
||||||
|
}
|
||||||
|
// Not displayable
|
||||||
|
// Could be a non 20 status code, or a different kind of document
|
||||||
|
|
||||||
|
// Handle each status code
|
||||||
|
switch res.Status {
|
||||||
|
case 10, 11:
|
||||||
|
userInput, ok := Input(res.Meta)
|
||||||
|
if ok {
|
||||||
|
// Make another request with the query string added
|
||||||
|
parsed.RawQuery = gemini.QueryEscape(userInput)
|
||||||
|
if len(parsed.String()) > gemini.URLMaxLength {
|
||||||
|
Error("Input Error", "URL for that input would be too long.")
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
return ret(handleURL(t, parsed.String(), 0))
|
||||||
|
}
|
||||||
|
return ret("", false)
|
||||||
|
case 30, 31:
|
||||||
|
parsedMeta, err := url.Parse(res.Meta)
|
||||||
|
if err != nil {
|
||||||
|
Error("Redirect Error", "Invalid URL: "+err.Error())
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
redir := parsed.ResolveReference(parsedMeta).String()
|
||||||
|
// Prompt before redirecting to non-Gemini protocol
|
||||||
|
redirect := false
|
||||||
|
if !strings.HasPrefix(redir, "gemini") {
|
||||||
|
if YesNo("Follow redirect to non-Gemini URL?\n" + redir) {
|
||||||
|
redirect = true
|
||||||
|
} else {
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prompt before redirecting
|
||||||
|
autoRedirect := viper.GetBool("a-general.auto_redirect")
|
||||||
|
if redirect || (autoRedirect && numRedirects < 5) || YesNo("Follow redirect?\n"+redir) {
|
||||||
|
if res.Status == gemini.StatusRedirectPermanent {
|
||||||
|
go cache.AddRedir(u, redir)
|
||||||
|
}
|
||||||
|
return ret(handleURL(t, redir, numRedirects+1))
|
||||||
|
}
|
||||||
|
return ret("", false)
|
||||||
|
case 40:
|
||||||
|
Error("Temporary Failure", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 41:
|
||||||
|
Error("Server Unavailable", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 42:
|
||||||
|
Error("CGI Error", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 43:
|
||||||
|
Error("Proxy Failure", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 44:
|
||||||
|
Error("Slow Down", "You should wait "+escapeMeta(res.Meta)+" seconds before making another request.")
|
||||||
|
return ret("", false)
|
||||||
|
case 50:
|
||||||
|
Error("Permanent Failure", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 51:
|
||||||
|
Error("Not Found", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 52:
|
||||||
|
Error("Gone", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 53:
|
||||||
|
Error("Proxy Request Refused", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 59:
|
||||||
|
Error("Bad Request", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 60:
|
||||||
|
Error("Client Certificate Required", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 61:
|
||||||
|
Error("Certificate Not Authorised", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
case 62:
|
||||||
|
Error("Certificate Not Valid", escapeMeta(res.Meta))
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status code 20, but not a document that can be displayed
|
||||||
|
|
||||||
|
// First see if it's a feed, and ask the user about adding it if it is
|
||||||
|
filename := path.Base(parsed.Path)
|
||||||
|
mediatype, _, _ := mime.ParseMediaType(res.Meta)
|
||||||
|
feed, ok := subscriptions.GetFeed(mediatype, filename, res.Body)
|
||||||
|
if ok {
|
||||||
|
go func() {
|
||||||
|
added := addFeedDirect(u, feed, subscriptions.IsSubscribed(u))
|
||||||
|
if !added {
|
||||||
|
// Otherwise offer download choices
|
||||||
|
// Disable read timeout and go back to start
|
||||||
|
res.SetReadTimeout(0) //nolint: errcheck
|
||||||
|
res.Body.(*rr.RestartReader).Restart()
|
||||||
|
go dlChoice("That file could not be displayed. What would you like to do?", u, res)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ret("", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise offer download choices
|
||||||
|
// Disable read timeout and go back to start
|
||||||
|
res.SetReadTimeout(0) //nolint: errcheck
|
||||||
|
res.Body.(*rr.RestartReader).Restart()
|
||||||
|
go dlChoice("That file could not be displayed. What would you like to do?", u, res)
|
||||||
|
return ret("", false)
|
||||||
|
}
|
106
display/help.go
106
display/help.go
@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/makeworld-the-better-one/amfora/config"
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
@ -15,39 +14,39 @@ var helpCells = strings.TrimSpace(`
|
|||||||
?|Bring up this help. You can scroll!
|
?|Bring up this help. You can scroll!
|
||||||
Esc|Leave the help
|
Esc|Leave the help
|
||||||
Arrow keys, h/j/k/l|Scroll and move a page.
|
Arrow keys, h/j/k/l|Scroll and move a page.
|
||||||
PgUp, u|Go up a page in document
|
%s|Go up a page in document
|
||||||
PgDn, d|Go down a page in document
|
%s|Go down a page in document
|
||||||
g|Go to top of document
|
g|Go to top of document
|
||||||
G|Go to bottom of document
|
G|Go to bottom of document
|
||||||
Tab|Navigate to the next item in a popup.
|
Tab|Navigate to the next item in a popup.
|
||||||
Shift-Tab|Navigate to the previous item in a popup.
|
Shift-Tab|Navigate to the previous item in a popup.
|
||||||
b, Alt-Left|Go back in the history
|
%s|Go back in the history
|
||||||
f, Alt-Right|Go forward in the history
|
%s|Go forward in the history
|
||||||
spacebar|Open bar at the bottom - type a URL, link number, search term.
|
%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.
|
|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
|
|Typing new:N will open link number N in a new tab
|
||||||
|instead of the current one.
|
|instead of the current one.
|
||||||
Numbers|Go to links 1-10 respectively.
|
%s|Go to links 1-10 respectively.
|
||||||
e|Edit current URL
|
%s|Edit current URL
|
||||||
Enter, Tab|On a page this will start link highlighting.
|
Enter, Tab|On a page this will start link highlighting.
|
||||||
|Press Tab and Shift-Tab to pick different links.
|
|Press Tab and Shift-Tab to pick different links.
|
||||||
|Press Enter again to go to one, or Esc to stop.
|
|Press Enter again to go to one, or Esc to stop.
|
||||||
Shift-NUMBER|Go to a specific tab.
|
%s|Go to a specific tab. (Default: Shift-NUMBER)
|
||||||
Shift-0, )|Go to the last tab.
|
%s|Go to the last tab.
|
||||||
F1|Previous tab
|
%s|Previous tab
|
||||||
F2|Next tab
|
%s|Next tab
|
||||||
Ctrl-H|Go home
|
%s|Go home
|
||||||
Ctrl-T|New tab, or if a link is selected,
|
%s|New tab, or if a link is selected,
|
||||||
|this will open the link in a new tab.
|
|this will open the link in a new tab.
|
||||||
Ctrl-W|Close tab. For now, only the right-most tab can be closed.
|
%s|Close tab. For now, only the right-most tab can be closed.
|
||||||
Ctrl-R, R|Reload a page, discarding the cached version.
|
%s|Reload a page, discarding the cached version.
|
||||||
|This can also be used if you resize your terminal.
|
|This can also be used if you resize your terminal.
|
||||||
Ctrl-B|View bookmarks
|
%s|View bookmarks
|
||||||
Ctrl-D|Add, change, or remove a bookmark for the current page.
|
%s|Add, change, or remove a bookmark for the current page.
|
||||||
Ctrl-S|Save the current page to your downloads.
|
%s|Save the current page to your downloads.
|
||||||
q, Ctrl-Q|Quit
|
%s|View subscriptions
|
||||||
Ctrl-C|Hard quit. This can be used when in the middle of downloading,
|
%s|Add or update a subscription
|
||||||
|for example.
|
%s|Quit
|
||||||
`)
|
`)
|
||||||
|
|
||||||
var helpTable = cview.NewTextView()
|
var helpTable = cview.NewTextView()
|
||||||
@ -74,12 +73,63 @@ func helpInit() {
|
|||||||
App.Draw()
|
App.Draw()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
lines := strings.Split(helpCells, "\n")
|
|
||||||
w := tabwriter.NewWriter(helpTable, 0, 8, 2, ' ', 0)
|
tabKeys := fmt.Sprintf("%s to %s", strings.Split(config.GetKeyBinding(config.CmdTab1), ",")[0],
|
||||||
for i, line := range lines {
|
strings.Split(config.GetKeyBinding(config.CmdTab9), ",")[0])
|
||||||
cells := strings.Split(line, "|")
|
linkKeys := fmt.Sprintf("%s to %s", strings.Split(config.GetKeyBinding(config.CmdLink1), ",")[0],
|
||||||
if i > 0 && len(cells[0]) > 0 {
|
strings.Split(config.GetKeyBinding(config.CmdLink0), ",")[0])
|
||||||
fmt.Fprintln(w, "\t")
|
|
||||||
|
helpCells = fmt.Sprintf(helpCells,
|
||||||
|
config.GetKeyBinding(config.CmdPgup),
|
||||||
|
config.GetKeyBinding(config.CmdPgdn),
|
||||||
|
config.GetKeyBinding(config.CmdBack),
|
||||||
|
config.GetKeyBinding(config.CmdForward),
|
||||||
|
config.GetKeyBinding(config.CmdBottom),
|
||||||
|
linkKeys,
|
||||||
|
config.GetKeyBinding(config.CmdEdit),
|
||||||
|
tabKeys,
|
||||||
|
config.GetKeyBinding(config.CmdTab0),
|
||||||
|
config.GetKeyBinding(config.CmdPrevTab),
|
||||||
|
config.GetKeyBinding(config.CmdNextTab),
|
||||||
|
config.GetKeyBinding(config.CmdHome),
|
||||||
|
config.GetKeyBinding(config.CmdNewTab),
|
||||||
|
config.GetKeyBinding(config.CmdCloseTab),
|
||||||
|
config.GetKeyBinding(config.CmdReload),
|
||||||
|
config.GetKeyBinding(config.CmdBookmarks),
|
||||||
|
config.GetKeyBinding(config.CmdAddBookmark),
|
||||||
|
config.GetKeyBinding(config.CmdSave),
|
||||||
|
config.GetKeyBinding(config.CmdSub),
|
||||||
|
config.GetKeyBinding(config.CmdAddSub),
|
||||||
|
config.GetKeyBinding(config.CmdQuit),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows := strings.Count(helpCells, "\n") + 1
|
||||||
|
cells := strings.Split(
|
||||||
|
strings.ReplaceAll(helpCells, "\n", "|"),
|
||||||
|
"|")
|
||||||
|
cell := 0
|
||||||
|
extraRows := 0 // Rows continued from the previous, without spacing
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
for c := 0; c < 2; c++ {
|
||||||
|
var tableCell *cview.TableCell
|
||||||
|
if c == 0 {
|
||||||
|
// First column, the keybinding
|
||||||
|
tableCell = cview.NewTableCell(" " + cells[cell]).
|
||||||
|
SetAttributes(tcell.AttrBold).
|
||||||
|
SetAlign(cview.AlignLeft)
|
||||||
|
} else {
|
||||||
|
tableCell = cview.NewTableCell(" " + cells[cell])
|
||||||
|
}
|
||||||
|
if c == 0 && cells[cell] == "" || (cell > 0 && cells[cell-1] == "" && c == 1) {
|
||||||
|
// The keybinding column for this row was blank, meaning the explanation
|
||||||
|
// column is continued from the previous row.
|
||||||
|
// The row should be added without any spacing rows
|
||||||
|
helpTable.SetCell(((2*r)-extraRows/2)-1, c, tableCell)
|
||||||
|
extraRows++
|
||||||
|
} else {
|
||||||
|
helpTable.SetCell((2*r)-extraRows/2, c, tableCell) // Every other row, for readability
|
||||||
|
}
|
||||||
|
cell++
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\n", cells[0], cells[1])
|
fmt.Fprintf(w, "%s\t%s\n", cells[0], cells[1])
|
||||||
}
|
}
|
||||||
|
@ -17,10 +17,18 @@ You can customize this page by creating a gemtext file called newtab.gmi, in Amf
|
|||||||
|
|
||||||
Happy browsing!
|
Happy browsing!
|
||||||
|
|
||||||
|
## Internal Pages
|
||||||
|
|
||||||
=> about:bookmarks Bookmarks
|
=> 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
|
=> //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.
|
// Read the new tab content from a file if it exists or fallback to a default page.
|
||||||
|
@ -1,24 +1,15 @@
|
|||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"fmt"
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/makeworld-the-better-one/amfora/cache"
|
|
||||||
"github.com/makeworld-the-better-one/amfora/client"
|
|
||||||
"github.com/makeworld-the-better-one/amfora/config"
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
"github.com/makeworld-the-better-one/amfora/renderer"
|
"github.com/makeworld-the-better-one/amfora/renderer"
|
||||||
"github.com/makeworld-the-better-one/amfora/structs"
|
"github.com/makeworld-the-better-one/amfora/structs"
|
||||||
"github.com/makeworld-the-better-one/amfora/webbrowser"
|
|
||||||
"github.com/makeworld-the-better-one/go-gemini"
|
|
||||||
"github.com/makeworld-the-better-one/go-isemoji"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gitlab.com/tslocum/cview"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file contains the functions that aren't part of the public API.
|
// This file contains the functions that aren't part of the public API.
|
||||||
@ -28,15 +19,10 @@ import (
|
|||||||
// Not when a URL is opened on a new tab for the first time.
|
// Not when a URL is opened on a new tab for the first time.
|
||||||
// It will handle setting the bottomBar.
|
// It will handle setting the bottomBar.
|
||||||
func followLink(t *tab, prev, next string) {
|
func followLink(t *tab, prev, next string) {
|
||||||
|
|
||||||
// Copied from URL()
|
|
||||||
if next == "about:bookmarks" {
|
|
||||||
Bookmarks(t)
|
|
||||||
t.addToHistory("about:bookmarks")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(next, "about:") {
|
if strings.HasPrefix(next, "about:") {
|
||||||
Error("Error", "Not a valid 'about:' URL for linking")
|
if final, ok := handleAbout(t, next); ok {
|
||||||
|
t.addToHistory(final)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +63,9 @@ func reformatPage(p *structs.Page) {
|
|||||||
case structs.TextGemini:
|
case structs.TextGemini:
|
||||||
// Links are not recorded because they won't change
|
// Links are not recorded because they won't change
|
||||||
proxied := true
|
proxied := true
|
||||||
if strings.HasPrefix(p.URL, "gemini") || strings.HasPrefix(p.URL, "about") {
|
if strings.HasPrefix(p.URL, "gemini") ||
|
||||||
|
strings.HasPrefix(p.URL, "about") ||
|
||||||
|
strings.HasPrefix(p.URL, "file") {
|
||||||
proxied = false
|
proxied = false
|
||||||
}
|
}
|
||||||
rendered, _ = renderer.RenderGemini(p.Raw, textWidth(), leftMargin(), proxied)
|
rendered, _ = renderer.RenderGemini(p.Raw, textWidth(), leftMargin(), proxied)
|
||||||
@ -141,146 +129,6 @@ func setPage(t *tab, p *structs.Page) {
|
|||||||
t.barText = p.URL
|
t.barText = p.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHTTP is used by handleURL.
|
|
||||||
// It opens HTTP links and displays Info and Error modals.
|
|
||||||
// Returns false if there was an error.
|
|
||||||
func handleHTTP(u string, showInfo bool) bool {
|
|
||||||
if len(config.HTTPCommand) == 1 {
|
|
||||||
// Possibly a non-command
|
|
||||||
|
|
||||||
switch strings.TrimSpace(config.HTTPCommand[0]) {
|
|
||||||
case "", "off":
|
|
||||||
Error("HTTP Error", "Opening HTTP URLs is turned off.")
|
|
||||||
return false
|
|
||||||
case "default":
|
|
||||||
s, err := webbrowser.Open(u)
|
|
||||||
if err != nil {
|
|
||||||
Error("Webbrowser Error", err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if showInfo {
|
|
||||||
Info(s)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom command
|
|
||||||
var err error = nil
|
|
||||||
if len(config.HTTPCommand) > 1 {
|
|
||||||
err = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...).Start()
|
|
||||||
} else {
|
|
||||||
err = exec.Command(config.HTTPCommand[0], u).Start()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
Error("HTTP Error", "Error executing custom browser command: "+err.Error())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
App.Draw()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOther is used by handleURL.
|
|
||||||
// It opens links other than Gemini and HTTP and displays Error modals.
|
|
||||||
func handleOther(u string) {
|
|
||||||
// The URL should have a scheme due to a previous call to normalizeURL
|
|
||||||
parsed, _ := url.Parse(u)
|
|
||||||
|
|
||||||
// Search for a handler for the URL scheme
|
|
||||||
handler := strings.TrimSpace(viper.GetString("url-handlers." + parsed.Scheme))
|
|
||||||
if len(handler) == 0 {
|
|
||||||
handler = strings.TrimSpace(viper.GetString("url-handlers.other"))
|
|
||||||
}
|
|
||||||
switch handler {
|
|
||||||
case "", "off":
|
|
||||||
Error("URL Error", "Opening "+parsed.Scheme+" URLs is turned off.")
|
|
||||||
default:
|
|
||||||
// The config has a custom command to execute for URLs
|
|
||||||
fields := strings.Fields(handler)
|
|
||||||
err := exec.Command(fields[0], append(fields[1:], u)...).Start()
|
|
||||||
if err != nil {
|
|
||||||
Error("URL Error", "Error executing custom command: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
App.Draw()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleFavicon handles getting and displaying a favicon.
|
|
||||||
// `old` is the previous favicon for the tab.
|
|
||||||
func handleFavicon(t *tab, host, old string) {
|
|
||||||
defer func() {
|
|
||||||
// Update display if needed
|
|
||||||
if t.page.Favicon != old && isValidTab(t) {
|
|
||||||
// TODO update browser tab label
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if !viper.GetBool("a-general.emoji_favicons") {
|
|
||||||
// Not enabled
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if t.page.Favicon != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if host == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fav := cache.GetFavicon(host)
|
|
||||||
if fav == cache.KnownNoFavicon {
|
|
||||||
// It's been cached that this host doesn't have a favicon
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if fav != "" {
|
|
||||||
t.page.Favicon = fav
|
|
||||||
// TODO update browser tab label
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// No favicon cached
|
|
||||||
res, err := client.Fetch("gemini://" + host + "/favicon.txt")
|
|
||||||
if err != nil {
|
|
||||||
if res != nil {
|
|
||||||
res.Body.Close()
|
|
||||||
}
|
|
||||||
cache.AddFavicon(host, cache.KnownNoFavicon)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.Status != 20 {
|
|
||||||
cache.AddFavicon(host, cache.KnownNoFavicon)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(res.Meta, "text/") {
|
|
||||||
cache.AddFavicon(host, cache.KnownNoFavicon)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// It's a regular plain response
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
_, err = io.CopyN(buf, res.Body, 29+2+1) // 29 is the max emoji length, +2 for CRLF, +1 so that the right size will EOF
|
|
||||||
if err == nil {
|
|
||||||
// Content was too large
|
|
||||||
cache.AddFavicon(host, cache.KnownNoFavicon)
|
|
||||||
return
|
|
||||||
} else if err != io.EOF {
|
|
||||||
// Some network reading error
|
|
||||||
// No favicon is NOT known, could be a temporary error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// EOF, which is what we want.
|
|
||||||
emoji := strings.TrimRight(buf.String(), "\r\n")
|
|
||||||
if !isemoji.IsEmoji(emoji) {
|
|
||||||
cache.AddFavicon(host, cache.KnownNoFavicon)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Valid favicon found
|
|
||||||
t.page.Favicon = emoji
|
|
||||||
cache.AddFavicon(host, emoji)
|
|
||||||
}
|
|
||||||
|
|
||||||
// goURL is like handleURL, but takes care of history and the bottomBar.
|
// goURL is like handleURL, but takes care of history and the bottomBar.
|
||||||
// It should be preferred over handleURL in most cases.
|
// It should be preferred over handleURL in most cases.
|
||||||
// It has no return values to be processed.
|
// It has no return values to be processed.
|
||||||
@ -297,269 +145,31 @@ func goURL(t *tab, u string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleURL displays whatever action is needed for the provided URL,
|
// rewriteTabRow clears the tabRow and writes all the tabs number/favicons into it.
|
||||||
// and applies it to the current tab.
|
func rewriteTabRow() {
|
||||||
// It loads documents, handles errors, brings up a download prompt, etc.
|
tabRow.Clear()
|
||||||
//
|
if viper.GetBool("a-general.color") {
|
||||||
// The string returned is the final URL, if redirects were involved.
|
for i := 0; i < NumTabs(); i++ {
|
||||||
// In most cases it will be the same as the passed URL.
|
char := strconv.Itoa(i + 1)
|
||||||
// If there is some error, it will return "".
|
if tabs[i].page.Favicon != "" {
|
||||||
// The second returned item is a bool indicating if page content was displayed.
|
char = tabs[i].page.Favicon
|
||||||
// It returns false for Errors, other protocols, etc.
|
}
|
||||||
//
|
fmt.Fprintf(tabRow, `["%d"][%s] %s [%s][""]|`,
|
||||||
// The bottomBar is not actually changed in this func, except during loading.
|
i,
|
||||||
// The func that calls this one should apply the bottomBar values if necessary.
|
config.GetColorString("tab_num"),
|
||||||
//
|
char,
|
||||||
// numRedirects is the number of redirects that resulted in the provided URL.
|
config.GetColorString("tab_divider"),
|
||||||
// It should typically be 0.
|
)
|
||||||
func handleURL(t *tab, u string, numRedirects int) (string, bool) {
|
|
||||||
defer App.Draw() // Just in case
|
|
||||||
|
|
||||||
// Save for resetting on error
|
|
||||||
oldLable := t.barLabel
|
|
||||||
oldText := t.barText
|
|
||||||
|
|
||||||
// Custom return function
|
|
||||||
ret := func(s string, b bool) (string, bool) {
|
|
||||||
if !b {
|
|
||||||
// Reset bottomBar if page wasn't loaded
|
|
||||||
t.barLabel = oldLable
|
|
||||||
t.barText = oldText
|
|
||||||
}
|
}
|
||||||
t.mode = tabModeDone
|
|
||||||
return s, b
|
|
||||||
}
|
|
||||||
|
|
||||||
t.barLabel = ""
|
|
||||||
bottomBar.SetLabel("")
|
|
||||||
|
|
||||||
App.SetFocus(t.view)
|
|
||||||
|
|
||||||
// To allow linking to the bookmarks page, and history browsing
|
|
||||||
if u == "about:bookmarks" {
|
|
||||||
Bookmarks(t)
|
|
||||||
return ret("about:bookmarks", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
u = normalizeURL(u)
|
|
||||||
u = cache.Redirect(u)
|
|
||||||
|
|
||||||
parsed, err := url.Parse(u)
|
|
||||||
if err != nil {
|
|
||||||
Error("URL Error", err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy := strings.TrimSpace(viper.GetString("proxies." + parsed.Scheme))
|
|
||||||
usingProxy := false
|
|
||||||
|
|
||||||
proxyHostname, proxyPort, err := net.SplitHostPort(proxy)
|
|
||||||
if err != nil {
|
|
||||||
// Error likely means there's no port in the host
|
|
||||||
proxyHostname = proxy
|
|
||||||
proxyPort = "1965"
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(u, "http") {
|
|
||||||
if proxy == "" || proxy == "off" {
|
|
||||||
// No proxy available
|
|
||||||
handleHTTP(u, true)
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
usingProxy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") {
|
|
||||||
// Not a Gemini URL
|
|
||||||
if proxy == "" || proxy == "off" {
|
|
||||||
// No proxy available
|
|
||||||
handleOther(u)
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
usingProxy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini URL, or one with a Gemini proxy available
|
|
||||||
|
|
||||||
// Load page from cache if it exists,
|
|
||||||
// and this isn't a page that was redirected to by the server (indicates dynamic content)
|
|
||||||
if numRedirects == 0 {
|
|
||||||
page, ok := cache.GetPage(u)
|
|
||||||
if ok {
|
|
||||||
setPage(t, page)
|
|
||||||
return ret(u, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise download it
|
|
||||||
bottomBar.SetText("Loading...")
|
|
||||||
t.barText = "Loading..." // Save it too, in case the tab switches during loading
|
|
||||||
t.mode = tabModeLoading
|
|
||||||
App.Draw()
|
|
||||||
|
|
||||||
var res *gemini.Response
|
|
||||||
if usingProxy {
|
|
||||||
res, err = client.FetchWithProxy(proxyHostname, proxyPort, u)
|
|
||||||
} else {
|
} else {
|
||||||
res, err = client.Fetch(u)
|
for i := 0; i < NumTabs(); i++ {
|
||||||
|
char := strconv.Itoa(i + 1)
|
||||||
|
if tabs[i].page.Favicon != "" {
|
||||||
|
char = tabs[i].page.Favicon
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tabRow, `["%d"] %s [""]|`, i, char)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight()
|
||||||
// Loading may have taken a while, make sure tab is still valid
|
App.Draw()
|
||||||
if !isValidTab(t) {
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, client.ErrTofu) {
|
|
||||||
if usingProxy {
|
|
||||||
// They are using a proxy
|
|
||||||
if Tofu(proxy, client.GetExpiry(proxyHostname, proxyPort)) {
|
|
||||||
// They want to continue anyway
|
|
||||||
client.ResetTofuEntry(proxyHostname, proxyPort, res.Cert)
|
|
||||||
// Response can be used further down, no need to reload
|
|
||||||
} else {
|
|
||||||
// They don't want to continue
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
|
|
||||||
// They want to continue anyway
|
|
||||||
client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
|
|
||||||
// Response can be used further down, no need to reload
|
|
||||||
} else {
|
|
||||||
// They don't want to continue
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
Error("URL Fetch Error", err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
if renderer.CanDisplay(res) {
|
|
||||||
page, err := renderer.MakePage(u, res, textWidth(), leftMargin(), usingProxy)
|
|
||||||
// Rendering may have taken a while, make sure tab is still valid
|
|
||||||
if !isValidTab(t) {
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, renderer.ErrTooLarge) {
|
|
||||||
// Make new request for downloading purposes
|
|
||||||
res, clientErr := client.Fetch(u)
|
|
||||||
if clientErr != nil && !errors.Is(clientErr, client.ErrTofu) {
|
|
||||||
Error("URL Fetch Error", err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
go dlChoice("That page is too large. What would you like to do?", u, res)
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
if errors.Is(err, renderer.ErrTimedOut) {
|
|
||||||
// Make new request for downloading purposes
|
|
||||||
res, clientErr := client.Fetch(u)
|
|
||||||
if clientErr != nil && !errors.Is(clientErr, client.ErrTofu) {
|
|
||||||
Error("URL Fetch Error", err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
go dlChoice("Loading that page timed out. What would you like to do?", u, res)
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
Error("Page Error", "Issuing creating page: "+err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
page.Width = termW
|
|
||||||
|
|
||||||
if !client.HasClientCert(parsed.Host) {
|
|
||||||
// Don't cache pages with client certs
|
|
||||||
go cache.AddPage(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
setPage(t, page)
|
|
||||||
return ret(u, true)
|
|
||||||
}
|
|
||||||
// Not displayable
|
|
||||||
// Could be a non 20 (or 21) status code, or a different kind of document
|
|
||||||
|
|
||||||
// Handle each status code
|
|
||||||
switch res.Status {
|
|
||||||
case 10, 11:
|
|
||||||
userInput, ok := Input(res.Meta)
|
|
||||||
if ok {
|
|
||||||
// Make another request with the query string added
|
|
||||||
// + chars are replaced because PathEscape doesn't do that
|
|
||||||
parsed.RawQuery = gemini.QueryEscape(userInput)
|
|
||||||
if len(parsed.String()) > gemini.URLMaxLength {
|
|
||||||
Error("Input Error", "URL for that input would be too long.")
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
return ret(handleURL(t, parsed.String(), 0))
|
|
||||||
}
|
|
||||||
return ret("", false)
|
|
||||||
case 30, 31:
|
|
||||||
parsedMeta, err := url.Parse(res.Meta)
|
|
||||||
if err != nil {
|
|
||||||
Error("Redirect Error", "Invalid URL: "+err.Error())
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
redir := parsed.ResolveReference(parsedMeta).String()
|
|
||||||
// Prompt before redirecting to non-Gemini protocol
|
|
||||||
redirect := false
|
|
||||||
if !strings.HasPrefix(redir, "gemini") {
|
|
||||||
if YesNo("Follow redirect to non-Gemini URL?\n" + redir) {
|
|
||||||
redirect = true
|
|
||||||
} else {
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Prompt before redirecting
|
|
||||||
autoRedirect := viper.GetBool("a-general.auto_redirect")
|
|
||||||
if redirect || (autoRedirect && numRedirects < 5) || YesNo("Follow redirect?\n"+redir) {
|
|
||||||
if res.Status == gemini.StatusRedirectPermanent {
|
|
||||||
go cache.AddRedir(u, redir)
|
|
||||||
}
|
|
||||||
return ret(handleURL(t, redir, numRedirects+1))
|
|
||||||
}
|
|
||||||
return ret("", false)
|
|
||||||
case 40:
|
|
||||||
Error("Temporary Failure", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 41:
|
|
||||||
Error("Server Unavailable", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 42:
|
|
||||||
Error("CGI Error", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 43:
|
|
||||||
Error("Proxy Failure", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 44:
|
|
||||||
Error("Slow Down", "You should wait "+cview.Escape(res.Meta)+" seconds before making another request.")
|
|
||||||
return ret("", false)
|
|
||||||
case 50:
|
|
||||||
Error("Permanent Failure", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 51:
|
|
||||||
Error("Not Found", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 52:
|
|
||||||
Error("Gone", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 53:
|
|
||||||
Error("Proxy Request Refused", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 59:
|
|
||||||
Error("Bad Request", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 60:
|
|
||||||
Error("Client Certificate Required", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 61:
|
|
||||||
Error("Certificate Not Authorised", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
case 62:
|
|
||||||
Error("Certificate Not Valid", cview.Escape(res.Meta))
|
|
||||||
return ret("", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status code 20, but not a document that can be displayed
|
|
||||||
go dlChoice("That file could not be displayed. What would you like to do?", u, res)
|
|
||||||
return ret("", false)
|
|
||||||
}
|
}
|
||||||
|
328
display/subscriptions.go
Normal file
328
display/subscriptions.go
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/cache"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/renderer"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/structs"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/subscriptions"
|
||||||
|
"github.com/makeworld-the-better-one/go-gemini"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Map page number (zero-indexed) to the time it was made at.
|
||||||
|
// This allows for caching the pages until there's an update.
|
||||||
|
var subscriptionPageUpdated = make(map[int]time.Time)
|
||||||
|
|
||||||
|
// toLocalDay truncates the provided time to a date only,
|
||||||
|
// but converts to the local time first.
|
||||||
|
func toLocalDay(t time.Time) time.Time {
|
||||||
|
t = t.Local()
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions displays the subscriptions page on the current tab.
|
||||||
|
func Subscriptions(t *tab, u string) string {
|
||||||
|
pageN := 0 // Pages are zero-indexed internally
|
||||||
|
|
||||||
|
// Correct URL if query string exists
|
||||||
|
// The only valid query string is an int above 1.
|
||||||
|
// Anything "redirects" to the first page, with no query string.
|
||||||
|
// This is done over just serving the first page content for
|
||||||
|
// invalid query strings so that there won't be duplicate caches.
|
||||||
|
correctURL := func(u2 string) string {
|
||||||
|
if len(u2) > 20 && u2[:20] == "about:subscriptions?" {
|
||||||
|
query, err := gemini.QueryUnescape(u2[20:])
|
||||||
|
if err != nil {
|
||||||
|
return "about:subscriptions"
|
||||||
|
}
|
||||||
|
// Valid query string
|
||||||
|
i, err := strconv.Atoi(query)
|
||||||
|
if err != nil {
|
||||||
|
// Not an int
|
||||||
|
return "about:subscriptions"
|
||||||
|
}
|
||||||
|
if i < 2 {
|
||||||
|
return "about:subscriptions"
|
||||||
|
}
|
||||||
|
// Valid int above 1
|
||||||
|
pageN = i - 1 // Pages are zero-indexed internally
|
||||||
|
return u2
|
||||||
|
}
|
||||||
|
return u2
|
||||||
|
}
|
||||||
|
u = correctURL(u)
|
||||||
|
|
||||||
|
// Retrieve cached version if there hasn't been any updates
|
||||||
|
p, ok := cache.GetPage(u)
|
||||||
|
if subscriptionPageUpdated[pageN].After(subscriptions.LastUpdated) && ok {
|
||||||
|
setPage(t, p)
|
||||||
|
t.applyBottomBar()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
pe := subscriptions.GetPageEntries()
|
||||||
|
|
||||||
|
// Figure out where the entries for this page start, if at all.
|
||||||
|
epp := viper.GetInt("subscriptions.entries_per_page")
|
||||||
|
if epp <= 0 {
|
||||||
|
epp = 1
|
||||||
|
}
|
||||||
|
start := pageN * epp // Index of the first page entry to be displayed
|
||||||
|
end := start + epp
|
||||||
|
if end > len(pe.Entries) {
|
||||||
|
end = len(pe.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawPage string
|
||||||
|
if pageN == 0 {
|
||||||
|
rawPage = "# Subscriptions\n\n" + rawPage
|
||||||
|
} else {
|
||||||
|
rawPage = fmt.Sprintf("# Subscriptions (page %d)\n\n", pageN+1) + rawPage
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > len(pe.Entries)-1 && len(pe.Entries) != 0 {
|
||||||
|
// The page is out of range, doesn't exist
|
||||||
|
rawPage += "This page does not exist.\n\n=> about:subscriptions Subscriptions\n"
|
||||||
|
} else {
|
||||||
|
// Render page
|
||||||
|
|
||||||
|
rawPage += "You can use Ctrl-X to subscribe to a page, or to an Atom/RSS/JSON feed. See the online wiki for more.\n" +
|
||||||
|
"If you just opened Amfora then updates may appear incrementally. Reload the page to see them.\n\n" +
|
||||||
|
"=> about:manage-subscriptions Manage subscriptions\n\n"
|
||||||
|
|
||||||
|
// curDay represents what day of posts the loop is on.
|
||||||
|
// It only goes backwards in time.
|
||||||
|
// Its initial setting means:
|
||||||
|
// Only display posts older than 26 hours in the future, nothing further in the future.
|
||||||
|
//
|
||||||
|
// 26 hours was chosen because it is the largest timezone difference
|
||||||
|
// currently in the world. Posts may be dated in the future
|
||||||
|
// due to software bugs, where the local user's date is used, but
|
||||||
|
// the UTC timezone is specified. Gemfeed does this at the time of
|
||||||
|
// writing, but will not after #3 gets merged on its repo. Still,
|
||||||
|
// the older version will be used for a while.
|
||||||
|
curDay := toLocalDay(time.Now()).Add(26 * time.Hour)
|
||||||
|
|
||||||
|
for _, entry := range pe.Entries[start:end] { // From new to old
|
||||||
|
// Convert to local time, remove sub-day info
|
||||||
|
pub := toLocalDay(entry.Published)
|
||||||
|
|
||||||
|
if pub.Before(curDay) {
|
||||||
|
// This post is on a new day, add a day header
|
||||||
|
curDay = pub
|
||||||
|
rawPage += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006"))
|
||||||
|
}
|
||||||
|
if entry.Title == "" || entry.Title == "/" {
|
||||||
|
// Just put author/title
|
||||||
|
// Mainly used for when you're tracking the root domain of a site
|
||||||
|
rawPage += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix)
|
||||||
|
} else {
|
||||||
|
// Include title and dash
|
||||||
|
rawPage += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pageN == 0 && len(pe.Entries) > epp {
|
||||||
|
// First page, and there's more than can fit
|
||||||
|
rawPage += "\n\n=> about:subscriptions?2 Next Page\n"
|
||||||
|
} else if pageN > 0 {
|
||||||
|
// A later page
|
||||||
|
rawPage += fmt.Sprintf(
|
||||||
|
"\n\n=> about:subscriptions?%d Previous Page\n",
|
||||||
|
pageN, // pageN is zero-indexed but the query string is one-indexed
|
||||||
|
)
|
||||||
|
if end != len(pe.Entries) {
|
||||||
|
// There's more
|
||||||
|
rawPage += fmt.Sprintf("=> about:subscriptions?%d Next Page\n", pageN+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
|
||||||
|
page := structs.Page{
|
||||||
|
Raw: rawPage,
|
||||||
|
Content: content,
|
||||||
|
Links: links,
|
||||||
|
URL: u,
|
||||||
|
Width: termW,
|
||||||
|
Mediatype: structs.TextGemini,
|
||||||
|
}
|
||||||
|
go cache.AddPage(&page)
|
||||||
|
setPage(t, &page)
|
||||||
|
t.applyBottomBar()
|
||||||
|
|
||||||
|
subscriptionPageUpdated[pageN] = time.Now()
|
||||||
|
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageSubscriptions displays the subscription managing page in
|
||||||
|
// the current tab. `u` is the URL entered by the user.
|
||||||
|
func ManageSubscriptions(t *tab, u string) {
|
||||||
|
if len(u) > 27 && u[:27] == "about:manage-subscriptions?" {
|
||||||
|
// There's a query string, aka a URL to unsubscribe from
|
||||||
|
manageSubscriptionQuery(t, u)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPage := "# Manage Subscriptions\n\n" +
|
||||||
|
"Below is list of URLs you are subscribed to, both feeds and pages. " +
|
||||||
|
"Navigate to the link to unsubscribe from that feed or page.\n\n"
|
||||||
|
|
||||||
|
urls := subscriptions.AllURLS()
|
||||||
|
sort.Strings(urls)
|
||||||
|
|
||||||
|
for _, u2 := range urls {
|
||||||
|
rawPage += fmt.Sprintf(
|
||||||
|
"=>%s %s\n",
|
||||||
|
"about:manage-subscriptions?"+gemini.QueryEscape(u2),
|
||||||
|
u2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
|
||||||
|
page := structs.Page{
|
||||||
|
Raw: rawPage,
|
||||||
|
Content: content,
|
||||||
|
Links: links,
|
||||||
|
URL: "about:manage-subscriptions",
|
||||||
|
Width: termW,
|
||||||
|
Mediatype: structs.TextGemini,
|
||||||
|
}
|
||||||
|
go cache.AddPage(&page)
|
||||||
|
setPage(t, &page)
|
||||||
|
t.applyBottomBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageSubscriptionQuery(t *tab, u string) {
|
||||||
|
sub, err := gemini.QueryUnescape(u[27:])
|
||||||
|
if err != nil {
|
||||||
|
Error("URL Error", "Invalid query string: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = subscriptions.Remove(sub)
|
||||||
|
if err != nil {
|
||||||
|
ManageSubscriptions(t, "about:manage-subscriptions") // Reload
|
||||||
|
Error("Save Error", "Error saving the unsubscription to disk: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ManageSubscriptions(t, "about:manage-subscriptions") // Reload
|
||||||
|
Info("Unsubscribed from " + sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSubscriptionModal displays the "Add subscription" modal
|
||||||
|
// It returns whether the user wanted to subscribe to feed/page.
|
||||||
|
// The subscribed arg specifies whether this feed/page is already
|
||||||
|
// subscribed to.
|
||||||
|
func openSubscriptionModal(validFeed, subscribed bool) bool {
|
||||||
|
// Reuses yesNoModal
|
||||||
|
|
||||||
|
if viper.GetBool("a-general.color") {
|
||||||
|
yesNoModal.
|
||||||
|
SetBackgroundColor(config.GetColor("subscription_modal_bg")).
|
||||||
|
SetTextColor(config.GetColor("subscription_modal_text"))
|
||||||
|
yesNoModal.GetFrame().
|
||||||
|
SetBorderColor(config.GetColor("subscription_modal_text")).
|
||||||
|
SetTitleColor(config.GetColor("subscription_modal_text"))
|
||||||
|
} else {
|
||||||
|
yesNoModal.
|
||||||
|
SetBackgroundColor(tcell.ColorBlack).
|
||||||
|
SetTextColor(tcell.ColorWhite)
|
||||||
|
yesNoModal.GetFrame().
|
||||||
|
SetBorderColor(tcell.ColorWhite).
|
||||||
|
SetTitleColor(tcell.ColorWhite)
|
||||||
|
}
|
||||||
|
if validFeed {
|
||||||
|
yesNoModal.GetFrame().SetTitle("Feed Subscription")
|
||||||
|
if subscribed {
|
||||||
|
yesNoModal.SetText("You are already subscribed to this feed. Would you like to manually update it?")
|
||||||
|
} else {
|
||||||
|
yesNoModal.SetText("Would you like to subscribe to this feed?")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yesNoModal.GetFrame().SetTitle("Page Subscription")
|
||||||
|
if subscribed {
|
||||||
|
yesNoModal.SetText("You are already subscribed to this page. Would you like to manually update it?")
|
||||||
|
} else {
|
||||||
|
yesNoModal.SetText("Would you like to subscribe to this page?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabPages.ShowPage("yesno")
|
||||||
|
tabPages.SendToFront("yesno")
|
||||||
|
App.SetFocus(yesNoModal)
|
||||||
|
App.Draw()
|
||||||
|
|
||||||
|
resp := <-yesNoCh
|
||||||
|
tabPages.SwitchToPage(strconv.Itoa(curTab))
|
||||||
|
App.SetFocus(tabs[curTab].view)
|
||||||
|
App.Draw()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFeedFromPage is like subscriptions.GetFeed but takes a structs.Page as input.
|
||||||
|
func getFeedFromPage(p *structs.Page) (*gofeed.Feed, bool) {
|
||||||
|
parsed, _ := url.Parse(p.URL)
|
||||||
|
filename := path.Base(parsed.Path)
|
||||||
|
r := strings.NewReader(p.Raw)
|
||||||
|
return subscriptions.GetFeed(p.RawMediatype, filename, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFeedDirect is only for adding feeds, not pages.
|
||||||
|
// It's for when you already have a feed and know if it's tracked.
|
||||||
|
// Used mainly by handleURL because it already did a lot of the work.
|
||||||
|
// It returns a bool indicating whether the user actually wanted to
|
||||||
|
// add the feed or not.
|
||||||
|
//
|
||||||
|
// Like addFeed, it should be called in a goroutine.
|
||||||
|
func addFeedDirect(u string, feed *gofeed.Feed, tracked bool) bool {
|
||||||
|
if openSubscriptionModal(true, tracked) {
|
||||||
|
err := subscriptions.AddFeed(u, feed)
|
||||||
|
if err != nil {
|
||||||
|
Error("Feed Error", err.Error())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFeed goes through the process of subscribing to the current page/feed.
|
||||||
|
// It is the high-level way of doing it. It should be called in a goroutine.
|
||||||
|
func addSubscription() {
|
||||||
|
t := tabs[curTab]
|
||||||
|
p := t.page
|
||||||
|
|
||||||
|
if !t.hasContent() {
|
||||||
|
// It's an about: page, or a malformed one
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, isFeed := getFeedFromPage(p)
|
||||||
|
tracked := subscriptions.IsSubscribed(p.URL)
|
||||||
|
|
||||||
|
if openSubscriptionModal(isFeed, tracked) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if isFeed {
|
||||||
|
err = subscriptions.AddFeed(p.URL, feed)
|
||||||
|
} else {
|
||||||
|
err = subscriptions.AddPage(p.URL, strings.NewReader(p.Raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
Error("Feed/Page Error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -148,9 +148,8 @@ func (t *tab) pageDown() {
|
|||||||
t.view.ScrollTo(row+(termH/4)*3, col)
|
t.view.ScrollTo(row+(termH/4)*3, col)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasContent returns true when the tab has a page that could be displayed.
|
// hasContent returns false when the tab's page is malformed,
|
||||||
// The most likely situation where false would be returned is when the default
|
// has no content or URL, or if it's an 'about:' page.
|
||||||
// new tab content is being displayed.
|
|
||||||
func (t *tab) hasContent() bool {
|
func (t *tab) hasContent() bool {
|
||||||
if t.page == nil || t.view == nil {
|
if t.page == nil || t.view == nil {
|
||||||
return false
|
return false
|
||||||
|
@ -3,12 +3,21 @@ package display
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/makeworld-the-better-one/go-gemini"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"gitlab.com/tslocum/cview"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file contains funcs that are small, self-contained utilities.
|
// This file contains funcs that are small, self-contained utilities.
|
||||||
|
|
||||||
|
// escapeMeta santizes a META string for use within a cview modal.
|
||||||
|
func escapeMeta(meta string) string {
|
||||||
|
return cview.Escape(strings.ReplaceAll(meta, "\n", ""))
|
||||||
|
}
|
||||||
|
|
||||||
// isValidTab indicates whether the passed tab is still being used, even if it's not currently displayed.
|
// isValidTab indicates whether the passed tab is still being used, even if it's not currently displayed.
|
||||||
func isValidTab(t *tab) bool {
|
func isValidTab(t *tab) bool {
|
||||||
tempTabs := tabs
|
tempTabs := tabs
|
||||||
@ -66,15 +75,23 @@ func resolveRelLink(t *tab, prev, next string) (string, error) {
|
|||||||
// Example: gemini://gus.guru:1965/ and //gus.guru/.
|
// Example: gemini://gus.guru:1965/ and //gus.guru/.
|
||||||
// This function will take both output the same URL each time.
|
// 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.
|
// The string passed must already be confirmed to be a URL.
|
||||||
// Detection of a search string vs. a URL must happen elsewhere.
|
// Detection of a search string vs. a URL must happen elsewhere.
|
||||||
//
|
//
|
||||||
// It only works with absolute URLs.
|
// It only works with absolute URLs.
|
||||||
func normalizeURL(u string) string {
|
func normalizeURL(u string) string {
|
||||||
parsed, err := url.Parse(u)
|
u = norm.NFC.String(u)
|
||||||
|
|
||||||
|
tmp, err := gemini.GetPunycodeURL(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
u = tmp
|
||||||
|
parsed, _ := url.Parse(u)
|
||||||
|
|
||||||
if parsed.Scheme == "" {
|
if parsed.Scheme == "" {
|
||||||
// Always add scheme
|
// Always add scheme
|
||||||
@ -95,7 +112,32 @@ func normalizeURL(u string) string {
|
|||||||
// gemini://example.com -> gemini://example.com/
|
// gemini://example.com -> gemini://example.com/
|
||||||
if parsed.Path == "" {
|
if parsed.Path == "" {
|
||||||
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()
|
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
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
//nolint: lll
|
||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -21,6 +22,11 @@ var normalizeURLTests = []struct {
|
|||||||
{"mailto:example@example.com", "mailto:example@example.com"},
|
{"mailto:example@example.com", "mailto:example@example.com"},
|
||||||
{"magnet:?xt=urn:btih:test", "magnet:?xt=urn:btih:test"},
|
{"magnet:?xt=urn:btih:test", "magnet:?xt=urn:btih:test"},
|
||||||
{"https://example.com", "https://example.com"},
|
{"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) {
|
func TestNormalizeURL(t *testing.T) {
|
||||||
|
20
go.mod
20
go.mod
@ -5,25 +5,29 @@ go 1.14
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.0
|
github.com/dustin/go-humanize v1.0.0
|
||||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
||||||
|
github.com/gdamore/tcell v1.4.0
|
||||||
github.com/gdamore/tcell/v2 v2.1.0
|
github.com/gdamore/tcell/v2 v2.1.0
|
||||||
github.com/google/go-cmp v0.5.0 // indirect
|
github.com/google/go-cmp v0.5.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.4 // indirect
|
github.com/makeworld-the-better-one/go-gemini v0.11.0
|
||||||
github.com/makeworld-the-better-one/go-gemini v0.9.0
|
|
||||||
github.com/makeworld-the-better-one/go-isemoji v1.1.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/go-homedir v1.1.0
|
||||||
github.com/mitchellh/mapstructure v1.3.3 // indirect
|
github.com/mitchellh/mapstructure v1.3.1 // indirect
|
||||||
github.com/pelletier/go-toml v1.8.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/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b
|
||||||
github.com/spf13/afero v1.4.1 // indirect
|
github.com/schollz/progressbar/v3 v3.7.2
|
||||||
|
github.com/spf13/afero v1.2.2 // indirect
|
||||||
github.com/spf13/cast v1.3.1 // indirect
|
github.com/spf13/cast v1.3.1 // indirect
|
||||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/spf13/viper v1.7.1
|
github.com/spf13/viper v1.7.1
|
||||||
github.com/stretchr/testify v1.6.1
|
github.com/stretchr/testify v1.6.1
|
||||||
gitlab.com/tslocum/cview v1.5.3-0.20201215184006-1af0da7606b8
|
gitlab.com/tslocum/cview v1.5.3-0.20201215184006-1af0da7606b8
|
||||||
golang.org/x/text v0.3.4
|
golang.org/x/text v0.3.5-0.20201208001344-75a595aef632
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/mmcdole/gofeed => github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe
|
||||||
|
|
||||||
|
replace github.com/schollz/progressbar/v3 => github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568
|
||||||
|
65
go.sum
65
go.sum
@ -16,8 +16,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
|||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
|
||||||
|
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
@ -32,6 +36,7 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
|
|||||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -45,6 +50,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
|
|||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
|
github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
|
||||||
|
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
|
||||||
github.com/gdamore/tcell/v2 v2.0.0-dev/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
github.com/gdamore/tcell/v2 v2.0.0-dev/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
||||||
github.com/gdamore/tcell/v2 v2.1.0 h1:UnSmozHgBkQi2PGsFr+rpdXuAPRRucMegpQp3Z3kDro=
|
github.com/gdamore/tcell/v2 v2.1.0 h1:UnSmozHgBkQi2PGsFr+rpdXuAPRRucMegpQp3Z3kDro=
|
||||||
github.com/gdamore/tcell/v2 v2.1.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
github.com/gdamore/tcell/v2 v2.1.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
||||||
@ -71,6 +78,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
|||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
@ -106,6 +114,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
|
|||||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
|
||||||
|
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
@ -114,7 +124,6 @@ github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1
|
|||||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@ -125,16 +134,17 @@ github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tW
|
|||||||
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/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 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
|
||||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||||
github.com/magiconair/properties v1.8.4 h1:8KGKTcQQGm0Kv7vEbKFErAoAOFyyacLStRtQSeYtvkY=
|
github.com/makeworld-the-better-one/go-gemini v0.11.0 h1:MNGiULJFvcqls9oCy40tE897hDeKvNmEK9i5kRucgQk=
|
||||||
github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
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-gemini v0.9.0 h1:Iz4ywRDrfsyoR8xZOkSKGXXftMR2spIV6ibVuhrKvSw=
|
|
||||||
github.com/makeworld-the-better-one/go-gemini v0.9.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4=
|
|
||||||
github.com/makeworld-the-better-one/go-isemoji v1.1.0 h1:wZBHOKB5zAIgaU2vaWnXFDDhatebB8TySrNVxjVV84g=
|
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/go-isemoji v1.1.0/go.mod h1:FBjkPl9rr0G4vlZCc+Mr+QcnOfGCTbGWYW8/1sp06I0=
|
||||||
github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f h1:YEUlTs5gb35UlBLTgqrub9axWTYB3d7/8TxrkJDZpRI=
|
github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe h1:i3b9Qy5z23DcXRnrsMYcM5s9Ng5VIidM1xZd+szuTsY=
|
||||||
github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f/go.mod h1:X6sxWNi9PBgQybpR4fpXPVD5fm7svLqZTQ5DJuERIoM=
|
github.com/makeworld-the-better-one/gofeed v1.1.1-0.20201123002655-c0c6354134fe/go.mod h1:QQO3maftbOu+hiVOGOZDRLymqGQCos4zxbA4j89gMrE=
|
||||||
|
github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568 h1:fod4pD+rsU73WIUxl8Kpo35LDuOx0uxzlprBKbm84vw=
|
||||||
|
github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20201220005701-b036c4d38568/go.mod h1:CG/f0JmacksUc6TkZToO7tVq4t03zIQSQUtTd7F9GR4=
|
||||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
github.com/mattn/go-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.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
@ -151,19 +161,24 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4
|
|||||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=
|
github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5Y5bqfLA=
|
||||||
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI=
|
||||||
|
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
|
||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||||
github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
|
github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw=
|
||||||
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
|
github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs=
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
@ -182,8 +197,10 @@ github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b h1:8NiY6v9/IlFU8osj1
|
|||||||
github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b/go.mod h1:T1HolqzmdHnJIH6p7A9LDuvYGQgEHx9ijX3vKgDKU60=
|
github.com/rkoesters/xdg v0.0.0-20181125232953-edd15b846f9b/go.mod h1:T1HolqzmdHnJIH6p7A9LDuvYGQgEHx9ijX3vKgDKU60=
|
||||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
@ -192,8 +209,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
|
|||||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||||
github.com/spf13/afero v1.4.1 h1:asw9sl74539yqavKaglDM5hFpdJVK0Y5Dr/JOgQ89nQ=
|
github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
|
||||||
github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
|
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
|
||||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
@ -210,12 +227,12 @@ github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
|
|||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||||
|
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||||
gitlab.com/tslocum/cbind v0.1.4 h1:cbZXPPcieXspk8cShoT6efz7HAT8yMNQcofYWNizis4=
|
gitlab.com/tslocum/cbind v0.1.4 h1:cbZXPPcieXspk8cShoT6efz7HAT8yMNQcofYWNizis4=
|
||||||
gitlab.com/tslocum/cbind v0.1.4/go.mod h1:RvwYE3auSjBNlCmWeGspzn+jdLUVQ8C2QGC+0nP9ChI=
|
gitlab.com/tslocum/cbind v0.1.4/go.mod h1:RvwYE3auSjBNlCmWeGspzn+jdLUVQ8C2QGC+0nP9ChI=
|
||||||
@ -232,7 +249,8 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-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-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-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
|
||||||
|
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@ -251,6 +269,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
|
|||||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@ -264,7 +283,13 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201216054612-986b41b23924 h1:QsnDpLLOKwHBBDa8nDws4DYNc/ryVW2vCpxCs09d4PY=
|
||||||
|
golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@ -288,16 +313,22 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-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-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-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201113135734-0a15ea8d9b02/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
|
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
|
||||||
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.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.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.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 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.5-0.20201208001344-75a595aef632 h1:clKlpQ6BheG1zIRhU2SPRAXpLgol/tqWVEeRkjpsaDI=
|
||||||
|
golang.org/x/text v0.3.5-0.20201208001344-75a595aef632/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-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/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=
|
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -63,19 +64,17 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b
|
|||||||
}
|
}
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
go func() {
|
|
||||||
time.Sleep(time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second)
|
|
||||||
res.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err := io.CopyN(buf, res.Body, viper.GetInt64("a-general.page_max_size")+1)
|
_, err := io.CopyN(buf, res.Body, viper.GetInt64("a-general.page_max_size")+1)
|
||||||
res.Body.Close()
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Content was larger than max size
|
// Content was larger than max size
|
||||||
return nil, ErrTooLarge
|
return nil, ErrTooLarge
|
||||||
} else if err != io.EOF {
|
} else if err != io.EOF {
|
||||||
if strings.HasSuffix(err.Error(), "use of closed network connection") {
|
if os.IsTimeout(err) {
|
||||||
// Timed out
|
// I would use
|
||||||
|
// errors.Is(err, os.ErrDeadlineExceeded)
|
||||||
|
// but that isn't supported before Go 1.15.
|
||||||
|
|
||||||
return nil, ErrTimedOut
|
return nil, ErrTimedOut
|
||||||
}
|
}
|
||||||
// Some other error
|
// Some other error
|
||||||
@ -104,31 +103,37 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int, proxied b
|
|||||||
if mediatype == "text/gemini" {
|
if mediatype == "text/gemini" {
|
||||||
rendered, links := RenderGemini(utfText, width, leftMargin, proxied)
|
rendered, links := RenderGemini(utfText, width, leftMargin, proxied)
|
||||||
return &structs.Page{
|
return &structs.Page{
|
||||||
Mediatype: structs.TextGemini,
|
Mediatype: structs.TextGemini,
|
||||||
URL: url,
|
RawMediatype: mediatype,
|
||||||
Raw: utfText,
|
URL: url,
|
||||||
Content: rendered,
|
Raw: utfText,
|
||||||
Links: links,
|
Content: rendered,
|
||||||
|
Links: links,
|
||||||
|
MadeAt: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
} else if strings.HasPrefix(mediatype, "text/") {
|
} else if strings.HasPrefix(mediatype, "text/") {
|
||||||
if mediatype == "text/x-ansi" || strings.HasSuffix(url, ".ans") || strings.HasSuffix(url, ".ansi") {
|
if mediatype == "text/x-ansi" || strings.HasSuffix(url, ".ans") || strings.HasSuffix(url, ".ansi") {
|
||||||
// ANSI
|
// ANSI
|
||||||
return &structs.Page{
|
return &structs.Page{
|
||||||
Mediatype: structs.TextAnsi,
|
Mediatype: structs.TextAnsi,
|
||||||
URL: url,
|
RawMediatype: mediatype,
|
||||||
Raw: utfText,
|
URL: url,
|
||||||
Content: RenderANSI(utfText, leftMargin),
|
Raw: utfText,
|
||||||
Links: []string{},
|
Content: RenderANSI(utfText, leftMargin),
|
||||||
|
Links: []string{},
|
||||||
|
MadeAt: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treated as plaintext
|
// Treated as plaintext
|
||||||
return &structs.Page{
|
return &structs.Page{
|
||||||
Mediatype: structs.TextPlain,
|
Mediatype: structs.TextPlain,
|
||||||
URL: url,
|
RawMediatype: mediatype,
|
||||||
Raw: utfText,
|
URL: url,
|
||||||
Content: RenderPlainText(utfText, leftMargin),
|
Raw: utfText,
|
||||||
Links: []string{},
|
Content: RenderPlainText(utfText, leftMargin),
|
||||||
|
Links: []string{},
|
||||||
|
MadeAt: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,6 +134,9 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string,
|
|||||||
// There is link text
|
// There is link text
|
||||||
url = lines[i][:delim]
|
url = lines[i][:delim]
|
||||||
linkText = strings.Trim(lines[i][delim:], " \t")
|
linkText = strings.Trim(lines[i][delim:], " \t")
|
||||||
|
if viper.GetBool("a-general.show_link") {
|
||||||
|
linkText += " (" + url + ")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" {
|
if strings.TrimSpace(lines[i]) == "" || strings.TrimSpace(url) == "" {
|
||||||
@ -240,13 +243,18 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string,
|
|||||||
} else if strings.HasPrefix(lines[i], ">") {
|
} else if strings.HasPrefix(lines[i], ">") {
|
||||||
// It's a quote line, add extra quote symbols and italics to the start of each wrapped line
|
// It's a quote line, add extra quote symbols and italics to the start of each wrapped line
|
||||||
|
|
||||||
// Remove beginning quote and maybe space
|
if len(lines[i]) == 1 {
|
||||||
lines[i] = strings.TrimPrefix(lines[i], ">")
|
// Just an empty quote line
|
||||||
lines[i] = strings.TrimPrefix(lines[i], " ")
|
wrappedLines = append(wrappedLines, fmt.Sprintf("[%s::i]>[-::-]", config.GetColorString("quote_text")))
|
||||||
wrappedLines = append(wrappedLines,
|
} else {
|
||||||
wrapLine(lines[i], width, fmt.Sprintf("[%s::i]> ", config.GetColorString("quote_text")),
|
// Remove beginning quote and maybe space
|
||||||
"[-::-]", true)...,
|
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]) == "" {
|
} else if strings.TrimSpace(lines[i]) == "" {
|
||||||
// Just add empty line without processing
|
// Just add empty line without processing
|
||||||
|
37
rr/README.md
Normal file
37
rr/README.md
Normal 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
81
rr/rr.go
Normal 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
45
rr/rr_test.go
Normal 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")
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
package structs
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type Mediatype string
|
type Mediatype string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -18,18 +20,20 @@ const (
|
|||||||
|
|
||||||
// Page is for storing UTF-8 text/gemini pages, as well as text/plain pages.
|
// Page is for storing UTF-8 text/gemini pages, as well as text/plain pages.
|
||||||
type Page struct {
|
type Page struct {
|
||||||
URL string
|
URL string
|
||||||
Mediatype Mediatype
|
Mediatype Mediatype // Used for rendering purposes, generalized
|
||||||
Raw string // The raw response, as received over the network
|
RawMediatype string // The actual mediatype sent by the server
|
||||||
Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin.
|
Raw string // The raw response, as received over the network
|
||||||
Links []string // URLs, for each region in the content.
|
Content string // The processed content, NOT raw. Uses cview color tags. It will also have a left margin.
|
||||||
Row int // Scroll position
|
Links []string // URLs, for each region in the content.
|
||||||
Column int // ditto
|
Row int // Scroll position
|
||||||
Width int // The terminal width when the Content was set, to know when reformatting should happen.
|
Column int // ditto
|
||||||
Selected string // The current text or link selected
|
Width int // The terminal width when the Content was set, to know when reformatting should happen.
|
||||||
SelectedID string // The cview region ID for the selected text/link
|
Selected string // The current text or link selected
|
||||||
Mode PageMode
|
SelectedID string // The cview region ID for the selected text/link
|
||||||
Favicon string
|
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.
|
// Size returns an approx. size of a Page in bytes.
|
||||||
|
144
subscriptions/entries.go
Normal file
144
subscriptions/entries.go
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package subscriptions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file contains funcs for creating PageEntries, which
|
||||||
|
// are consumed by display/subscriptions.go
|
||||||
|
|
||||||
|
// getURL returns a URL to be used in a PageEntry, from a
|
||||||
|
// list of URLs for that item. It prefers gemini URLs, then
|
||||||
|
// HTTP(S), then by order.
|
||||||
|
func getURL(urls []string) string {
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstHTTP string
|
||||||
|
for _, u := range urls {
|
||||||
|
if strings.HasPrefix(u, "gemini://") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if (strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) && firstHTTP == "" {
|
||||||
|
// First HTTP(S) URL in the list
|
||||||
|
firstHTTP = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstHTTP != "" {
|
||||||
|
return firstHTTP
|
||||||
|
}
|
||||||
|
return urls[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageEntries returns the current list of PageEntries
|
||||||
|
// for use in rendering a page.
|
||||||
|
// The contents of the returned entries will never change,
|
||||||
|
// so this function needs to be called again to get updates.
|
||||||
|
// It always returns sorted entries - by post time, from newest to oldest.
|
||||||
|
func GetPageEntries() *PageEntries {
|
||||||
|
var pe PageEntries
|
||||||
|
|
||||||
|
data.RLock()
|
||||||
|
|
||||||
|
for _, feed := range data.Feeds {
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
if item.Links == nil || len(item.Links) == 0 {
|
||||||
|
// Ignore items without links
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pub
|
||||||
|
|
||||||
|
var pub time.Time
|
||||||
|
|
||||||
|
// Try to use updated time first, then published
|
||||||
|
|
||||||
|
if item.UpdatedParsed != nil && !item.UpdatedParsed.IsZero() {
|
||||||
|
pub = *item.UpdatedParsed
|
||||||
|
} else if item.PublishedParsed != nil && !item.PublishedParsed.IsZero() {
|
||||||
|
pub = *item.PublishedParsed
|
||||||
|
} else {
|
||||||
|
// No time on the post, use now
|
||||||
|
pub = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set prefix
|
||||||
|
|
||||||
|
// Prefer using the feed title over anything else.
|
||||||
|
// Many feeds in Gemini only have this due to gemfeed's default settings.
|
||||||
|
prefix := feed.Title
|
||||||
|
|
||||||
|
if prefix == "" {
|
||||||
|
// feed.Title was empty
|
||||||
|
|
||||||
|
if item.Author != nil {
|
||||||
|
// Prefer using the item author over the feed author
|
||||||
|
prefix = item.Author.Name
|
||||||
|
} else {
|
||||||
|
if feed.Author != nil {
|
||||||
|
prefix = feed.Author.Name
|
||||||
|
} else {
|
||||||
|
prefix = "[author unknown]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// There's already a title, so add the author (if exists) to
|
||||||
|
// the end of the title in parentheses.
|
||||||
|
// Don't add the author if it's the same as the title.
|
||||||
|
|
||||||
|
if item.Author != nil && item.Author.Name != prefix {
|
||||||
|
// Prefer using the item author over the feed author
|
||||||
|
prefix += " (" + item.Author.Name + ")"
|
||||||
|
} else if feed.Author != nil && feed.Author.Name != prefix {
|
||||||
|
prefix += " (" + feed.Author.Name + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pe.Entries = append(pe.Entries, &PageEntry{
|
||||||
|
Prefix: prefix,
|
||||||
|
Title: item.Title,
|
||||||
|
URL: getURL(item.Links),
|
||||||
|
Published: pub,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for u, page := range data.Pages {
|
||||||
|
parsed, _ := url.Parse(u)
|
||||||
|
|
||||||
|
// Path is title
|
||||||
|
title := parsed.Path
|
||||||
|
if strings.HasPrefix(title, "/~") && title != "/~" {
|
||||||
|
// A user dir
|
||||||
|
title = title[2:] // Remove beginning slash and tilde
|
||||||
|
// Remove trailing slash if the root of a user dir is being tracked
|
||||||
|
if strings.Count(title, "/") <= 1 && title[len(title)-1] == '/' {
|
||||||
|
title = title[:len(title)-1]
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(title, "/users/") && title != "/users/" {
|
||||||
|
// "/users/" is removed for aesthetics when tracking hosted users
|
||||||
|
title = strings.TrimPrefix(title, "/users/")
|
||||||
|
title = strings.TrimPrefix(title, "~") // Remove leading tilde
|
||||||
|
// Remove trailing slash if the root of a user dir is being tracked
|
||||||
|
if strings.Count(title, "/") <= 1 && title[len(title)-1] == '/' {
|
||||||
|
title = title[:len(title)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pe.Entries = append(pe.Entries, &PageEntry{
|
||||||
|
Prefix: parsed.Host,
|
||||||
|
Title: title,
|
||||||
|
URL: u,
|
||||||
|
Published: page.Changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data.RUnlock()
|
||||||
|
|
||||||
|
sort.Sort(&pe)
|
||||||
|
return &pe
|
||||||
|
}
|
108
subscriptions/structs.go
Normal file
108
subscriptions/structs.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package subscriptions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Example stored JSON.
|
||||||
|
|
||||||
|
{
|
||||||
|
"feeds": {
|
||||||
|
"url1": <gofeed.Feed>,
|
||||||
|
"url2": <gofeed.Feed>,
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"url1": {
|
||||||
|
"hash": <hash>,
|
||||||
|
"changed": <time>
|
||||||
|
},
|
||||||
|
"url2": {
|
||||||
|
"hash": <hash>,
|
||||||
|
"changed": <time>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"pages" are the pages tracked for changes that aren't feeds.
|
||||||
|
The hash used is SHA-256.
|
||||||
|
The time is in RFC 3339 format, preferably in the UTC timezone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Decoded JSON
|
||||||
|
type jsonData struct {
|
||||||
|
feedMu *sync.RWMutex
|
||||||
|
pageMu *sync.RWMutex
|
||||||
|
Feeds map[string]*gofeed.Feed `json:"feeds,omitempty"`
|
||||||
|
Pages map[string]*pageJSON `json:"pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock locks both feed and page mutexes.
|
||||||
|
func (j *jsonData) Lock() {
|
||||||
|
j.feedMu.Lock()
|
||||||
|
j.pageMu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock unlocks both feed and page mutexes.
|
||||||
|
func (j *jsonData) Unlock() {
|
||||||
|
j.feedMu.Unlock()
|
||||||
|
j.pageMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RLock read-locks both feed and page mutexes.
|
||||||
|
func (j *jsonData) RLock() {
|
||||||
|
j.feedMu.RLock()
|
||||||
|
j.pageMu.RLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RUnlock read-unlocks both feed and page mutexes.
|
||||||
|
func (j *jsonData) RUnlock() {
|
||||||
|
j.feedMu.RUnlock()
|
||||||
|
j.pageMu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageJSON struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Changed time.Time `json:"changed"` // When the latest change happened
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance of jsonData - loaded from JSON and used
|
||||||
|
var data = jsonData{
|
||||||
|
feedMu: &sync.RWMutex{},
|
||||||
|
pageMu: &sync.RWMutex{},
|
||||||
|
// Maps are created in Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageEntry is a single item on a subscriptions page.
|
||||||
|
// It is used for both feeds and pages.
|
||||||
|
type PageEntry struct {
|
||||||
|
Prefix string // Feed/log title, author, etc - something before the post title
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Published time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageEntries is new-to-old list of Entry structs, used to create a
|
||||||
|
// subscriptions page.
|
||||||
|
// It should always be assumed to be sorted when used in other packages,
|
||||||
|
// by post time, from newest to oldest.
|
||||||
|
type PageEntries struct {
|
||||||
|
Entries []*PageEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement sort.Interface
|
||||||
|
|
||||||
|
func (e *PageEntries) Len() int {
|
||||||
|
return len(e.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PageEntries) Less(i, j int) bool {
|
||||||
|
return e.Entries[i].Published.After(e.Entries[j].Published)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PageEntries) Swap(i, j int) {
|
||||||
|
e.Entries[i], e.Entries[j] = e.Entries[j], e.Entries[i]
|
||||||
|
}
|
469
subscriptions/subscriptions.go
Normal file
469
subscriptions/subscriptions.go
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
package subscriptions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime"
|
||||||
|
urlPkg "net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/makeworld-the-better-one/amfora/client"
|
||||||
|
"github.com/makeworld-the-better-one/amfora/config"
|
||||||
|
"github.com/makeworld-the-better-one/go-gemini"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSaving = errors.New("couldn't save JSON to disk")
|
||||||
|
ErrNotSuccess = errors.New("status 20 not returned")
|
||||||
|
ErrNotFeed = errors.New("not a valid feed")
|
||||||
|
ErrTooManyRedirects = errors.New("redirected more than 5 times")
|
||||||
|
)
|
||||||
|
|
||||||
|
var writeMu = sync.Mutex{} // Prevent concurrent writes to subscriptions.json file
|
||||||
|
|
||||||
|
// LastUpdated is the time when the in-memory data was last updated.
|
||||||
|
// It can be used to know if the subscriptions page should be regenerated.
|
||||||
|
var LastUpdated time.Time
|
||||||
|
|
||||||
|
// Init should be called after config.Init.
|
||||||
|
func Init() error {
|
||||||
|
f, err := os.Open(config.SubscriptionPath)
|
||||||
|
if err == nil {
|
||||||
|
// File exists and could be opened
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err == nil && fi.Size() > 0 {
|
||||||
|
// File is not empty
|
||||||
|
|
||||||
|
jsonBytes, err := ioutil.ReadAll(f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read subscriptions.json error: %w", err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(jsonBytes, &data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("subscriptions.json is corrupted: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// There's an error opening the file, but it's not bc is doesn't exist
|
||||||
|
return fmt.Errorf("open subscriptions.json error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Feeds == nil {
|
||||||
|
data.Feeds = make(map[string]*gofeed.Feed)
|
||||||
|
}
|
||||||
|
if data.Pages == nil {
|
||||||
|
data.Pages = make(map[string]*pageJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
LastUpdated = time.Now()
|
||||||
|
|
||||||
|
if viper.GetInt("subscriptions.update_interval") > 0 {
|
||||||
|
// Update subscriptions every so often
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
updateAll()
|
||||||
|
time.Sleep(time.Duration(viper.GetInt("subscriptions.update_interval")) * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
// User disabled automatic updates
|
||||||
|
// So just update once at the beginning
|
||||||
|
go updateAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSubscribed returns true if the URL is already subscribed to,
|
||||||
|
// whether a feed or page.
|
||||||
|
func IsSubscribed(url string) bool {
|
||||||
|
data.feedMu.RLock()
|
||||||
|
for u := range data.Feeds {
|
||||||
|
if url == u {
|
||||||
|
data.feedMu.RUnlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.feedMu.RUnlock()
|
||||||
|
data.pageMu.RLock()
|
||||||
|
for u := range data.Pages {
|
||||||
|
if url == u {
|
||||||
|
data.pageMu.RUnlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.pageMu.RUnlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeed returns a Feed object and a bool indicating whether the passed
|
||||||
|
// content was actually recognized as a feed.
|
||||||
|
func GetFeed(mediatype, filename string, r io.Reader) (*gofeed.Feed, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mediatype and filename
|
||||||
|
if mediatype != "application/atom+xml" && mediatype != "application/rss+xml" && mediatype != "application/json+feed" &&
|
||||||
|
filename != "atom.xml" && filename != "feed.xml" && filename != "feed.json" &&
|
||||||
|
!strings.HasSuffix(filename, ".atom") && !strings.HasSuffix(filename, ".rss") &&
|
||||||
|
!strings.HasSuffix(filename, ".xml") {
|
||||||
|
// No part of the above is true
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
feed, err := gofeed.NewParser().Parse(r)
|
||||||
|
if feed == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return feed, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON() error {
|
||||||
|
writeMu.Lock()
|
||||||
|
defer writeMu.Unlock()
|
||||||
|
|
||||||
|
data.Lock()
|
||||||
|
jsonBytes, err := json.MarshalIndent(&data, "", " ")
|
||||||
|
data.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = ioutil.WriteFile(config.SubscriptionPath, jsonBytes, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFeed stores a feed.
|
||||||
|
// It can be used to update a feed for a URL, although the package
|
||||||
|
// will handle that on its own.
|
||||||
|
func AddFeed(url string, feed *gofeed.Feed) error {
|
||||||
|
if feed == nil {
|
||||||
|
panic("feed is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any unused fields to save memory and disk space
|
||||||
|
feed.Image = nil
|
||||||
|
feed.Generator = ""
|
||||||
|
feed.Categories = nil
|
||||||
|
feed.DublinCoreExt = nil
|
||||||
|
feed.ITunesExt = nil
|
||||||
|
feed.Custom = nil
|
||||||
|
feed.Link = ""
|
||||||
|
feed.Links = nil
|
||||||
|
for _, item := range feed.Items {
|
||||||
|
item.Description = ""
|
||||||
|
item.Content = ""
|
||||||
|
item.Image = nil
|
||||||
|
item.Categories = nil
|
||||||
|
item.Enclosures = nil
|
||||||
|
item.DublinCoreExt = nil
|
||||||
|
item.ITunesExt = nil
|
||||||
|
item.Extensions = nil
|
||||||
|
item.Custom = nil
|
||||||
|
item.Link = "" // Links is used instead
|
||||||
|
}
|
||||||
|
|
||||||
|
data.feedMu.Lock()
|
||||||
|
oldFeed, ok := data.Feeds[url]
|
||||||
|
if !ok || !reflect.DeepEqual(feed, oldFeed) {
|
||||||
|
// Feeds are different, or there was never an old one
|
||||||
|
|
||||||
|
LastUpdated = time.Now()
|
||||||
|
data.Feeds[url] = feed
|
||||||
|
data.feedMu.Unlock()
|
||||||
|
err := writeJSON()
|
||||||
|
if err != nil {
|
||||||
|
return ErrSaving
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.feedMu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPage stores a page to track for changes.
|
||||||
|
// It can be used to update the page as well, although the package
|
||||||
|
// will handle that on its own.
|
||||||
|
func AddPage(url string, r io.Reader) error {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newHash := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
|
||||||
|
data.pageMu.Lock()
|
||||||
|
_, ok := data.Pages[url]
|
||||||
|
if !ok || data.Pages[url].Hash != newHash {
|
||||||
|
// Page content is different, or it didn't exist
|
||||||
|
|
||||||
|
LastUpdated = time.Now()
|
||||||
|
data.Pages[url] = &pageJSON{
|
||||||
|
Hash: newHash,
|
||||||
|
Changed: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pageMu.Unlock()
|
||||||
|
err := writeJSON()
|
||||||
|
if err != nil {
|
||||||
|
return ErrSaving
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.pageMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getResource returns a URL and Response for the given URL.
|
||||||
|
// It will follow up to 5 redirects, and if there is a permanent
|
||||||
|
// redirect it will return the new URL. Otherwise the URL will
|
||||||
|
// stay the same. THe returned URL will never be empty.
|
||||||
|
//
|
||||||
|
// If there is over 5 redirects the error will be ErrTooManyRedirects.
|
||||||
|
// ErrNotSuccess, as well as other fetch errors will also be returned.
|
||||||
|
func getResource(url string) (string, *gemini.Response, error) {
|
||||||
|
res, err := client.Fetch(url)
|
||||||
|
if err != nil {
|
||||||
|
if res != nil {
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
return url, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Status == gemini.StatusSuccess {
|
||||||
|
// No redirects
|
||||||
|
return url, res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := urlPkg.Parse(url)
|
||||||
|
if err != nil {
|
||||||
|
return url, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
redirs := make([]int, 0)
|
||||||
|
urls := make([]*urlPkg.URL, 0)
|
||||||
|
|
||||||
|
// Loop through redirects
|
||||||
|
for (res.Status == gemini.StatusRedirectPermanent || res.Status == gemini.StatusRedirectTemporary) && i < 5 {
|
||||||
|
redirs = append(redirs, res.Status)
|
||||||
|
urls = append(urls, parsed)
|
||||||
|
|
||||||
|
tmp, err := parsed.Parse(res.Meta)
|
||||||
|
if err != nil {
|
||||||
|
// Redirect URL returned by the server is invalid
|
||||||
|
return url, nil, err
|
||||||
|
}
|
||||||
|
parsed = tmp
|
||||||
|
|
||||||
|
// Make the new request
|
||||||
|
res, err := client.Fetch(parsed.String())
|
||||||
|
if err != nil {
|
||||||
|
if res != nil {
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
return url, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two possible options here:
|
||||||
|
// - Never redirected, got error on start
|
||||||
|
// - No more redirects, other status code
|
||||||
|
// - Too many redirects
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
// Never redirected or succeeded
|
||||||
|
return url, res, ErrNotSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 5 {
|
||||||
|
// The server stopped redirecting after <5 redirects
|
||||||
|
|
||||||
|
if res.Status == gemini.StatusSuccess {
|
||||||
|
// It ended by succeeding
|
||||||
|
|
||||||
|
for j := range redirs {
|
||||||
|
if redirs[j] == gemini.StatusRedirectTemporary {
|
||||||
|
if j == 0 {
|
||||||
|
// First redirect is temporary
|
||||||
|
return url, res, nil
|
||||||
|
}
|
||||||
|
// There were permanent redirects before this one
|
||||||
|
// Return the URL of the latest permanent redirect
|
||||||
|
return urls[j-1].String(), res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// They were all permanent redirects
|
||||||
|
return urls[len(urls)-1].String(), res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It stopped because there was a non-redirect, non-success response
|
||||||
|
return url, res, ErrNotSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too many redirects, return original
|
||||||
|
return url, nil, ErrTooManyRedirects
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFeed(url string) {
|
||||||
|
newURL, res, err := getResource(url)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediatype, _, err := mime.ParseMediaType(res.Meta)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := path.Base(newURL)
|
||||||
|
feed, ok := GetFeed(mediatype, filename, res.Body)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = AddFeed(newURL, feed)
|
||||||
|
if url != newURL && err == nil {
|
||||||
|
// URL has changed, remove old one
|
||||||
|
Remove(url) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePage(url string) {
|
||||||
|
newURL, res, err := getResource(url)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = AddPage(newURL, res.Body)
|
||||||
|
if url != newURL && err == nil {
|
||||||
|
// URL has changed, remove old one
|
||||||
|
Remove(url) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateAll updates all subscriptions using workers.
|
||||||
|
// It only returns once all the workers are done.
|
||||||
|
func updateAll() {
|
||||||
|
worker := func(jobs <-chan [2]string, wg *sync.WaitGroup) {
|
||||||
|
// Each job is: [2]string{<type>, "url"}
|
||||||
|
// where <type> is "feed" or "page"
|
||||||
|
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range jobs {
|
||||||
|
if j[0] == "feed" {
|
||||||
|
updateFeed(j[1]) //nolint:errcheck
|
||||||
|
} else if j[0] == "page" {
|
||||||
|
updatePage(j[1]) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
data.RLock()
|
||||||
|
numJobs := len(data.Feeds) + len(data.Pages)
|
||||||
|
jobs := make(chan [2]string, numJobs)
|
||||||
|
|
||||||
|
if numJobs == 0 {
|
||||||
|
data.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
numWorkers := viper.GetInt("subscriptions.workers")
|
||||||
|
if numWorkers < 1 {
|
||||||
|
numWorkers = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start workers, waiting for jobs
|
||||||
|
for w := 0; w < numWorkers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
worker(jobs, &wg)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get map keys in a slice
|
||||||
|
|
||||||
|
feedKeys := make([]string, len(data.Feeds))
|
||||||
|
i := 0
|
||||||
|
for k := range data.Feeds {
|
||||||
|
feedKeys[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
pageKeys := make([]string, len(data.Pages))
|
||||||
|
i = 0
|
||||||
|
for k := range data.Pages {
|
||||||
|
pageKeys[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
data.RUnlock()
|
||||||
|
|
||||||
|
for j := 0; j < numJobs; j++ {
|
||||||
|
if j < len(feedKeys) {
|
||||||
|
jobs <- [2]string{"feed", feedKeys[j]}
|
||||||
|
} else {
|
||||||
|
// In the Pages
|
||||||
|
jobs <- [2]string{"page", pageKeys[j-len(feedKeys)]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllURLs returns all the subscribed-to URLS.
|
||||||
|
func AllURLS() []string {
|
||||||
|
data.RLock()
|
||||||
|
defer data.RUnlock()
|
||||||
|
|
||||||
|
urls := make([]string, len(data.Feeds)+len(data.Pages))
|
||||||
|
i := 0
|
||||||
|
for k := range data.Feeds {
|
||||||
|
urls[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for k := range data.Pages {
|
||||||
|
urls[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a subscription from memory and from the disk.
|
||||||
|
// The URL must be provided. It will do nothing if the URL is
|
||||||
|
// not an actual subscription.
|
||||||
|
//
|
||||||
|
// It returns any errors that occurred when saving to disk.
|
||||||
|
func Remove(u string) error {
|
||||||
|
data.Lock()
|
||||||
|
// Just delete from both instead of using a loop to find it
|
||||||
|
delete(data.Feeds, u)
|
||||||
|
delete(data.Pages, u)
|
||||||
|
data.Unlock()
|
||||||
|
return writeJSON()
|
||||||
|
}
|
14
sysopen/open_browser_darwin.go
Normal file
14
sysopen/open_browser_darwin.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package sysopen
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// Open opens `path` in default system viewer.
|
||||||
|
func Open(path string) (string, error) {
|
||||||
|
err := exec.Command("open", path).Start()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "Opened in default system viewer", nil
|
||||||
|
}
|
11
sysopen/open_browser_other.go
Normal file
11
sysopen/open_browser_other.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// +build !linux,!darwin,!windows,!freebsd,!netbsd,!openbsd
|
||||||
|
|
||||||
|
package sysopen
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Open opens `path` in default system viewer, but not on this OS.
|
||||||
|
func Open(path string) (string, error) {
|
||||||
|
return "", fmt.Errorf("unsupported OS for default system viewer. " +
|
||||||
|
"Set a catch-all [[mediatype-handlers]] command in the config")
|
||||||
|
}
|
35
sysopen/open_browser_unix.go
Normal file
35
sysopen/open_browser_unix.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// +build linux freebsd netbsd openbsd
|
||||||
|
|
||||||
|
//nolint:goerr113
|
||||||
|
package sysopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open opens `path` in default system viewer. It tries to do so using
|
||||||
|
// xdg-open. It only works if there is a display server working.
|
||||||
|
func Open(path string) (string, error) {
|
||||||
|
var (
|
||||||
|
xorgDisplay = os.Getenv("DISPLAY")
|
||||||
|
waylandDisplay = os.Getenv("WAYLAND_DISPLAY")
|
||||||
|
xdgOpenPath, xdgOpenNotFoundErr = exec.LookPath("xdg-open")
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case xorgDisplay == "" && waylandDisplay == "":
|
||||||
|
return "", fmt.Errorf("no display server was found. " +
|
||||||
|
"You may set a default [[mediatype-handlers]] command in the config")
|
||||||
|
case xdgOpenNotFoundErr == nil:
|
||||||
|
// Use start rather than run or output in order
|
||||||
|
// to make application run in background.
|
||||||
|
if err := exec.Command(xdgOpenPath, path).Start(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "Opened in default system viewer", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("could not determine default system viewer. " +
|
||||||
|
"Set a catch-all [[mediatype-handlers]] command in the config")
|
||||||
|
}
|
||||||
|
}
|
15
sysopen/open_browser_windows.go
Normal file
15
sysopen/open_browser_windows.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// +build windows
|
||||||
|
// +build !linux !darwin !freebsd !netbsd !openbsd
|
||||||
|
|
||||||
|
package sysopen
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// Open opens `path` in default system vierwer.
|
||||||
|
func Open(path string) (string, error) {
|
||||||
|
err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "Opened in default system viewer", nil
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user