Compare commits
No commits in common. "00d5e51e2f58c3a1fa3107a1451cddeb7c7fae53" and "403f5da5b4df9db4bbcf3d57c9579ef9ccba2dfc" have entirely different histories.
00d5e51e2f
...
403f5da5b4
|
@ -1,5 +1,6 @@
|
|||
TODO
|
||||
--------------------------------------------------------------------------------
|
||||
gopher plugin
|
||||
|
||||
|
||||
|
||||
|
@ -27,8 +28,6 @@ identity management
|
|||
configurable keybinds
|
||||
handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
|
||||
allow encoding overrides (useful for gopher i guess)
|
||||
config for web browser, default to webbrowser module
|
||||
use pubkeys instead of the whole DER hash for TOFU
|
||||
|
||||
|
||||
|
||||
|
@ -61,4 +60,3 @@ basic local browsing
|
|||
search in page
|
||||
plugin interface for schemes
|
||||
finger plugin
|
||||
gopher plugin
|
||||
|
|
|
@ -5,8 +5,7 @@ Bebop is a [Gemini][gemini] browser for the terminal, focusing on practicality
|
|||
and laziness. It is a personal project to learn how to use ncurses and try new
|
||||
ways to explore the Geminispace. It borrows some ideas from [Amfora][amfora],
|
||||
another great terminal browser, Vim for interactivity and tries to support mouse
|
||||
usage decently. It also supports some “smol net” protocols such as Finger and
|
||||
Gopher through plugins.
|
||||
usage decently.
|
||||
|
||||
[gemini]: https://gemini.circumlunar.space/
|
||||
[amfora]: https://github.com/makeworld-the-better-one/amfora
|
||||
|
@ -109,15 +108,13 @@ works OK in cmd.exe, but it feels completely broken on Windows Terminal.
|
|||
Since v0.2.0 you can use and develop plugins for Bebop. Installing a plugin
|
||||
requires you to install the corresponding Python package, usually from PyPI,
|
||||
then enable the plugin in your config file. Check the internal help page or the
|
||||
`bebop/plugins.py` module for more information.
|
||||
`bebop/plugin.py` module for more information.
|
||||
|
||||
Here is a list of plugins I did, available on PyPI:
|
||||
|
||||
- `bebop-browser-finger`: support for Finger protocol ([readme][plugin-finger])
|
||||
- `bebop-browser-gopher`: support for Gopher protocol ([readme][plugin-gopher])
|
||||
|
||||
[plugin-finger]: plugins/finger/README.md
|
||||
[plugin-gopher]: plugins/gopher/README.md
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ class Browser:
|
|||
values are dicts as well: the "open" key maps to a callable to use when
|
||||
the page is accessed, and the optional "source" key maps to callable
|
||||
returning the page source path.
|
||||
- last_download: tuple of MimeType (may be None) and path, or None.
|
||||
- last_download: tuple of MimeType and path, or None.
|
||||
- identities: identities map.
|
||||
- search_res_lines: list of lines containing results of the last search.
|
||||
"""
|
||||
|
@ -84,7 +84,7 @@ class Browser:
|
|||
self.history = History(self.config["history_limit"])
|
||||
self.cache = {}
|
||||
self.special_pages = self.setup_special_pages()
|
||||
self.last_download: Optional[Tuple[Optional[MimeType], Path]] = None
|
||||
self.last_download: Optional[Tuple[MimeType, Path]] = None
|
||||
self.identities = {}
|
||||
self.search_res_lines = []
|
||||
self.plugins = []
|
||||
|
@ -358,14 +358,6 @@ class Browser:
|
|||
return
|
||||
|
||||
command = words[0]
|
||||
|
||||
# Check for plugin registered commands first.
|
||||
for plugin in self.plugins:
|
||||
if command in map(lambda c: c.name, plugin.commands):
|
||||
plugin.use_command(self, command, command_text)
|
||||
return
|
||||
|
||||
# Then built-in commands without args.
|
||||
if num_words == 1:
|
||||
if command == "help":
|
||||
self.open_help()
|
||||
|
@ -376,8 +368,6 @@ class Browser:
|
|||
elif command in ("i", "info"):
|
||||
self.show_page_info()
|
||||
return
|
||||
|
||||
# And commands with one or more args.
|
||||
if command in ("o", "open"):
|
||||
self.open_url(words[1])
|
||||
elif command == "forget-certificate":
|
||||
|
@ -725,7 +715,7 @@ class Browser:
|
|||
|
||||
def open_help(self):
|
||||
"""Show the help page."""
|
||||
self.open_internal_page("help", get_help(self.config, self.plugins))
|
||||
self.open_internal_page("help", get_help(self.config))
|
||||
|
||||
def prompt(self, text: str, keys: str ="yn"):
|
||||
"""Display the text and allow it to type one of the given keys."""
|
||||
|
@ -742,8 +732,7 @@ class Browser:
|
|||
if not self.last_download:
|
||||
return
|
||||
mime_type, path = self.last_download
|
||||
main_type = mime_type.main_type if mime_type else ""
|
||||
command = self.config["external_commands"].get(main_type)
|
||||
command = self.config["external_commands"].get(mime_type.main_type)
|
||||
if not command:
|
||||
command = self.config["external_command_default"]
|
||||
command = command + [str(path)]
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
"""Gemini-related features of the browser."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bebop.browser.browser import Browser
|
||||
from bebop.command_line import CommandLine
|
||||
from bebop.downloads import get_download_path
|
||||
from bebop.fs import get_identities_list_path
|
||||
from bebop.fs import get_downloads_path, get_identities_list_path
|
||||
from bebop.identity import (
|
||||
ClientCertificateException, create_certificate, get_cert_and_key,
|
||||
get_identities_for_url, load_identities, save_identities
|
||||
|
@ -230,7 +230,7 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
|
|||
page.encoding = encoding
|
||||
else:
|
||||
download_dir = browser.config["download_path"]
|
||||
filepath = get_download_path(url, download_dir=download_dir)
|
||||
filepath = _get_download_path(url, download_dir=download_dir)
|
||||
|
||||
# If a page has been produced, load it. Else if a file has been retrieved,
|
||||
# download it.
|
||||
|
@ -254,6 +254,20 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
|
|||
return None
|
||||
|
||||
|
||||
def _get_download_path(url: str, download_dir: Optional[str] =None) -> Path:
|
||||
"""Try to find the best download file path possible from this URL."""
|
||||
download_path = Path(download_dir) if download_dir else get_downloads_path()
|
||||
if not download_path.exists():
|
||||
download_path.mkdir(parents=True)
|
||||
url_parts = url.rsplit("/", maxsplit=1)
|
||||
if url_parts:
|
||||
filename = url_parts[-1]
|
||||
else:
|
||||
filename = url.split("://")[1] if "://" in url else url
|
||||
filename = filename.replace("/", "_")
|
||||
return download_path / filename
|
||||
|
||||
|
||||
def _handle_input_request(
|
||||
browser: Browser,
|
||||
from_url: str,
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
"""Downloads management."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bebop.fs import get_downloads_path
|
||||
|
||||
|
||||
def get_download_path(url: str, download_dir: Optional[str] =None) -> Path:
|
||||
"""Try to find the best download file path possible from this URL."""
|
||||
download_path = Path(download_dir) if download_dir else get_downloads_path()
|
||||
if not download_path.exists():
|
||||
download_path.mkdir(parents=True)
|
||||
url_parts = url.rsplit("/", maxsplit=1)
|
||||
if url_parts:
|
||||
filename = url_parts[-1]
|
||||
else:
|
||||
filename = url.split("://")[1] if "://" in url else url
|
||||
filename = filename.replace("/", "_")
|
||||
return download_path / filename
|
|
@ -54,7 +54,6 @@ Commands are mostly for actions requiring user input. You can type a command wit
|
|||
* i(nfo): show page informations
|
||||
* forget-certificate <hostname>: remove saved fingerprint for this hostname
|
||||
* set-render-mode: set render mode preference for the current URL
|
||||
{plugin_commands}
|
||||
|
||||
## Configuration
|
||||
|
||||
|
@ -81,33 +80,24 @@ Notes:
|
|||
|
||||
1: For the "command" parameters such as source_editor and command_editor, a string list is used to separate the different program arguments, e.g. if you wish to use `vim -c 'startinsert'`, you should write the list `["vim", "-c", "startinsert"]`. In both case, a temporary or regular file name will be appended to this command when run.
|
||||
|
||||
2: The external_commands dict maps MIME types to commands just as above. For example, if you want to open video files with VLC and audio files in Clementine, you can use the following dict: `{{"audio": ["clementine"], "video": ["vlc"]}}`. For now only "main" MIME types are supported, i.e. you cannot specify precise types like "audio/flac", just "audio".
|
||||
2: The external_commands dict maps MIME types to commands just as above. For example, if you want to open video files with VLC and audio files in Clementine, you can use the following dict: `{"audio": ["clementine"], "video": ["vlc"]}`. For now only "main" MIME types are supported, i.e. you cannot specify precise types like "audio/flac", just "audio".
|
||||
|
||||
3: The generate_client_cert_command uses the same format as other commands (specified in note 1 above), with the exception that if the strings "{{cert_path}}", "{{key_path}}" or "{{common_name}}" are present in any string for the list, they will be replaced respectively by the certificate output path, the key output path and the CN to use.
|
||||
3: The generate_client_cert_command uses the same format as other commands (specified in note 1 above), with the exception that if the strings "{cert_path}", "{key_path}" or "{common_name}" are present in any string for the list, they will be replaced respectively by the certificate output path, the key output path and the CN to use.
|
||||
|
||||
4: The enabled_plugins list contain plugin names to load. Plugins are available if they are installed Python packages that can be imported using the `bebop_<plugin-name>` package name.
|
||||
|
||||
Your current configuration is:
|
||||
|
||||
{current_config}
|
||||
"""
|
||||
|
||||
|
||||
def get_help(config, plugins):
|
||||
plugin_commands = "\n".join(
|
||||
f"* {command.name}: {command.description}"
|
||||
for plugin in plugins
|
||||
for command in plugin.commands
|
||||
)
|
||||
def get_help(config):
|
||||
config_list = "\n".join(
|
||||
(
|
||||
f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})"
|
||||
f'* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})'
|
||||
if value != DEFAULT_CONFIG[key]
|
||||
else f"* {key} = {value}"
|
||||
else f'* {key} = {value}'
|
||||
)
|
||||
for key, value in config.items()
|
||||
)
|
||||
return HELP_PAGE.format(
|
||||
plugin_commands=plugin_commands,
|
||||
current_config=config_list
|
||||
)
|
||||
return HELP_PAGE + config_list
|
||||
|
|
|
@ -18,58 +18,15 @@ There is at least one plugin in this repository in the `plugins` directory.
|
|||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from bebop.browser.browser import Browser
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginCommand:
|
||||
"""A descriptor for a plugin command.
|
||||
|
||||
Attributes:
|
||||
- name: the command name.
|
||||
- description: a very short description of the command; should start lower
|
||||
case and does not need a period at the end.
|
||||
"""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class Plugin(ABC):
|
||||
"""Base class for plugins.
|
||||
|
||||
Attributes:
|
||||
- commands: list of PluginCommand provided by the plugin.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.commands = []
|
||||
|
||||
def use_command(self, browser: Browser, name: str, text: str):
|
||||
"""Use a command presented by this plugin.
|
||||
|
||||
Plugins that do not use custom commands can leave this method
|
||||
unimplemented.
|
||||
|
||||
Attributes:
|
||||
- name: the command used as it is in the commands list.
|
||||
- text: the whole command text, including the command name.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SchemePlugin(Plugin):
|
||||
"""Plugin for URL scheme management.
|
||||
|
||||
If you want to create a plugin that can handle new schemes, create a plugin
|
||||
inheriting this class.
|
||||
"""
|
||||
class SchemePlugin(ABC):
|
||||
"""Plugin for URL scheme management."""
|
||||
|
||||
def __init__(self, scheme: str) -> None:
|
||||
super().__init__()
|
||||
self.scheme = scheme
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
@ -8,11 +8,3 @@ This is a Gopher plugin for [Bebop][bebop], refer to its docs for details.
|
|||
Requires:
|
||||
|
||||
* Bebop >= 0.2.0
|
||||
|
||||
It currently displays only the maps and the file item types.
|
||||
|
||||
Avoid using the path navigation features of Bebop because they do not make much
|
||||
sense in Gopher; Gopher URLs do not really represent a path as they can be
|
||||
prefixed with an item type, so going up one level from a file item will usually
|
||||
put you on a map item with the file item indicator still in the URL. Going to
|
||||
the root URL and history navigation should still be fine though.
|
||||
|
|
|
@ -6,13 +6,11 @@ from typing import Optional
|
|||
|
||||
from bebop.browser.browser import Browser
|
||||
from bebop.command_line import CommandLine
|
||||
from bebop.downloads import get_download_path
|
||||
from bebop.links import Links
|
||||
from bebop.metalines import LineType
|
||||
from bebop.mime import MimeType
|
||||
from bebop.navigation import parse_url, parse_host_and_port, unparse_url
|
||||
from bebop.navigation import parse_url, parse_host_and_port
|
||||
from bebop.page import Page
|
||||
from bebop.plugins import PluginCommand, SchemePlugin
|
||||
from bebop.plugins import SchemePlugin
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
|
@ -38,23 +36,15 @@ class ItemType(Enum):
|
|||
_missing_ = lambda s: ItemType.FILE
|
||||
|
||||
|
||||
# Types that can be parsed as a page (see `parse_source`).
|
||||
PARSABLE_TYPES = (ItemType.FILE, ItemType.DIR)
|
||||
# Types that are not rendered by this plugin; should be handled by a separate
|
||||
# program, but for now we simply do nothing with them.
|
||||
UNHANDLED_TYPES = (
|
||||
ItemType.CCSO, ItemType.ERROR, ItemType.TELNET, ItemType.REDUNDANT,
|
||||
ItemType.TN3270
|
||||
)
|
||||
# Map item types lowercase names to the actual type, to easily set a type from
|
||||
# the command-line.
|
||||
USER_FRIENDLY_TYPES = {t.name.lower(): t for t in ItemType}
|
||||
# Icons to display for some item types in a Gopher map.
|
||||
ICONS = {
|
||||
ItemType.FILE: "📄",
|
||||
ItemType.DIR: "📂",
|
||||
ItemType.ERROR: "❌",
|
||||
ItemType.SEARCH: "✍ ",
|
||||
ItemType.SEARCH: "🤔",
|
||||
ItemType.HTML: "🌐",
|
||||
}
|
||||
|
||||
|
@ -74,20 +64,8 @@ class GopherPlugin(SchemePlugin):
|
|||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("gopher")
|
||||
self.commands = [
|
||||
PluginCommand(
|
||||
"set-item-type",
|
||||
"display current page as another item type (Gopher only)"
|
||||
)
|
||||
]
|
||||
|
||||
def open_url(self, browser: Browser, url: str) -> Optional[str]:
|
||||
"""Request an selector from a Gopher host.
|
||||
|
||||
As Bebop works only with URLs and not really the Gopher host/selector
|
||||
format, we use RFC 4266 (“The gopher URI Scheme”) for consistency with
|
||||
other schemes and to get that sweet item type hint in the URL path.
|
||||
"""
|
||||
parts = parse_url(url)
|
||||
host = parts["netloc"]
|
||||
host_and_port = parse_host_and_port(host, 70)
|
||||
|
@ -95,8 +73,7 @@ class GopherPlugin(SchemePlugin):
|
|||
browser.set_status_error("Could not parse gopher URL.")
|
||||
return None
|
||||
host, port = host_and_port
|
||||
# Decode path; spaces in Gopher URLs are encoded for display in Bebop.
|
||||
path = parts["path"].replace("%20", " ")
|
||||
path = parts["path"]
|
||||
|
||||
# If the URL has an item type, use it to properly parse the response.
|
||||
type_path_match = TYPE_PATH_RE.match(path)
|
||||
|
@ -126,74 +103,24 @@ class GopherPlugin(SchemePlugin):
|
|||
else:
|
||||
item_type = ItemType.DIR
|
||||
|
||||
# If we have spaces in our path, encode it for UI & logging.
|
||||
encoded_path = path.replace(" ", "%20").replace("\t", "%09")
|
||||
# If we have text search in our path, encode it for UI & logging.
|
||||
encoded_path = path.replace("\t", "%09")
|
||||
browser.set_status(f"Loading {host} {port} '{encoded_path}'…")
|
||||
|
||||
timeout = browser.config["connect_timeout"]
|
||||
try:
|
||||
response = request(host, port, path, timeout)
|
||||
response = self.request(host, port, path, timeout)
|
||||
page = parse_response(response, item_type)
|
||||
except GopherPluginException as exc:
|
||||
browser.set_status_error("Error: " + exc.message)
|
||||
return None
|
||||
|
||||
url = f"gopher://{host}:{port}/{item_type.value}{encoded_path}"
|
||||
if item_type in PARSABLE_TYPES:
|
||||
page = parse_response(response, item_type)
|
||||
browser.load_page(page)
|
||||
url = f"gopher://{host}:{port}/{item_type.value}{encoded_path}"
|
||||
browser.current_url = url
|
||||
else:
|
||||
download_dir = browser.config["download_path"]
|
||||
filepath = get_download_path(url, download_dir=download_dir)
|
||||
try:
|
||||
with open(filepath, "wb") as download_file:
|
||||
download_file.write(response)
|
||||
except OSError as exc:
|
||||
browser.set_status_error(f"Failed to save {url} ({exc})")
|
||||
return None
|
||||
else:
|
||||
browser.set_status(f"Downloaded {url}.")
|
||||
browser.last_download = None, filepath
|
||||
|
||||
return url
|
||||
|
||||
def use_command(self, browser: Browser, name: str, text: str):
|
||||
if name == "set-item-type":
|
||||
given_type = text[len(name):].strip()
|
||||
valid_types = [
|
||||
t for t in USER_FRIENDLY_TYPES
|
||||
if USER_FRIENDLY_TYPES[t] not in UNHANDLED_TYPES
|
||||
]
|
||||
if given_type not in valid_types:
|
||||
error = "Valid types: " + ", ".join(valid_types)
|
||||
browser.set_status_error(error)
|
||||
return
|
||||
item_type = USER_FRIENDLY_TYPES[given_type]
|
||||
self.set_item_type(browser, item_type)
|
||||
|
||||
def set_item_type(self, browser: Browser, item_type: ItemType):
|
||||
"""Re-parse the current page using this item type."""
|
||||
if browser.current_scheme != self.scheme or not browser.current_page:
|
||||
browser.set_status_error("Can only set item types on Gopher URLs.")
|
||||
return
|
||||
|
||||
logging.debug(f"Force parsing current page as {item_type}…")
|
||||
current_source = browser.current_page.source
|
||||
new_page = get_page_from_source(current_source, item_type)
|
||||
browser.load_page(new_page)
|
||||
|
||||
# If possible, set the correct item type in the URL path as well.
|
||||
url = browser.current_url
|
||||
parts = parse_url(browser.current_url)
|
||||
type_path_match = TYPE_PATH_RE.match(parts["path"])
|
||||
if type_path_match:
|
||||
path = type_path_match.group(2)
|
||||
parts["path"] = f"/{item_type.value}{path}"
|
||||
browser.current_url = unparse_url(parts)
|
||||
|
||||
|
||||
def request(host: str, port: int, path: str, timeout: int) -> bytes:
|
||||
"""Send a Gopher request and return the received bytes."""
|
||||
def request(self, host: str, port: int, path: str, timeout: int):
|
||||
try:
|
||||
sock = socket.create_connection((host, port), timeout=timeout)
|
||||
except OSError as exc:
|
||||
|
@ -218,23 +145,12 @@ def request(host: str, port: int, path: str, timeout: int) -> bytes:
|
|||
|
||||
|
||||
def parse_response(response: bytes, item_type: ItemType, encoding: str ="utf8"):
|
||||
"""Parse a Gopher response."""
|
||||
decoded = response.decode(encoding=encoding, errors="replace")
|
||||
return get_page_from_source(decoded, item_type)
|
||||
|
||||
|
||||
def get_page_from_source(source: str, item_type: ItemType):
|
||||
"""Get a Page object from a decoded source text."""
|
||||
metalines, links = parse_source(source, item_type)
|
||||
return Page(source, metalines, links)
|
||||
metalines, links = parse_source(decoded, item_type)
|
||||
return Page(decoded, metalines, links)
|
||||
|
||||
|
||||
def parse_source(source: str, item_type: ItemType):
|
||||
"""Generate metalines and a Links instance for this source text.
|
||||
|
||||
The item_type must be a type that can be parsed: FILE or DIR. Any other
|
||||
item type will silently result in no metalines.
|
||||
"""
|
||||
metalines = []
|
||||
links = Links()
|
||||
|
||||
|
@ -247,10 +163,7 @@ def parse_source(source: str, item_type: ItemType):
|
|||
# parse any kind of text data.
|
||||
elif item_type == ItemType.DIR:
|
||||
current_link_id = 1
|
||||
# Split lines on \n and discard \r separately because some maps do not
|
||||
# end lines with \r\n all the time.
|
||||
for line in source.split("\n"):
|
||||
line = line.rstrip("\r")
|
||||
for line in source.split("\r\n"):
|
||||
ltype, tline = line[:1], line[1:]
|
||||
if ltype == "." and not tline:
|
||||
break
|
||||
|
|
Reference in a new issue