config: add config management
A config file is now created and used, and a few hardcoded things in the source have been moved to the config file \o/
This commit is contained in:
parent
4fbfa37937
commit
3a818812a9
|
@ -11,18 +11,22 @@ TODO DONE
|
||||||
bookmarks
|
bookmarks
|
||||||
view/edit sources
|
view/edit sources
|
||||||
downloads
|
downloads
|
||||||
|
configuration
|
||||||
open last download
|
open last download
|
||||||
non shit command-line
|
actual TOFU
|
||||||
home page
|
home page
|
||||||
media files
|
media files
|
||||||
view history
|
view history
|
||||||
identity management
|
identity management
|
||||||
configuration
|
help page for keybinds
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
BACKLOG
|
BACKLOG
|
||||||
|
click on links to open them
|
||||||
download to disk, not in memory
|
download to disk, not in memory
|
||||||
|
does encoding really work? cf. egsam
|
||||||
margins / centering
|
margins / centering
|
||||||
pre blocks folding
|
pre blocks folding
|
||||||
buffers (tabs)
|
buffers (tabs)
|
||||||
handle soft-hyphens on wrapping
|
handle soft-hyphens on wrapping
|
||||||
bug: combining chars reduce lengths
|
bug: combining chars reduce lengths
|
||||||
|
non shit command-line
|
||||||
|
|
80
README.md
80
README.md
|
@ -25,21 +25,85 @@ It passes the Conman's client test but not Egsam's for now.
|
||||||
Features
|
Features
|
||||||
--------
|
--------
|
||||||
|
|
||||||
### What works
|
Why use Bebop instead of something else?
|
||||||
|
|
||||||
Common basic browsing features work: go to URL, scrolling, follow links,
|
### Lightweight
|
||||||
redirections, page encoding.
|
|
||||||
|
|
||||||
Bebop also provide these neat features:
|
It only uses a single dependency, [asn1crypto][asn1crypto], to delegate
|
||||||
|
parsing certificates. Everything else including NCurses or TLS is done using
|
||||||
|
Python's standard library.
|
||||||
|
|
||||||
|
[asn1crypto]: https://github.com/wbond/asn1crypto
|
||||||
|
|
||||||
|
### Fun
|
||||||
|
|
||||||
|
Link navigation is done by entering the link ID with automatic validation: if
|
||||||
|
there are less than 10 links on a page, pressing the link ID will take you to
|
||||||
|
the page directly. If there are 30 links, pressing "1" will wait for another
|
||||||
|
digit. If there are 1000 links but you wish to visit link #5, pressing 5 and
|
||||||
|
enter will do!
|
||||||
|
|
||||||
|
Of course this is based on my own perception of what exactly makes a client
|
||||||
|
"fun" to use, but give it a shot!
|
||||||
|
|
||||||
|
### And more!
|
||||||
|
|
||||||
|
It does not try to do many things. Common basic browsing features work: go to
|
||||||
|
URL, scrolling, follow links, redirections, page encodings, etc.
|
||||||
|
|
||||||
|
It also provide these features:
|
||||||
|
|
||||||
- History
|
- History
|
||||||
- Caching
|
- Caching
|
||||||
- Bookmarks: it's just a text file with bindings.
|
- Bookmarks: it's just a text file with bindings.
|
||||||
|
- Downloads
|
||||||
|
|
||||||
Check out [this board](BOARD.txt) for what's done and coming next.
|
Check out [this board](BOARD.txt) for what's done and coming next.
|
||||||
|
|
||||||
### What is not planned for now
|
|
||||||
|
|
||||||
- 256-colors mode and themes.
|
|
||||||
- Subscriptions. I have no need for them as I prefer to use a browser-agnostic
|
Configuration
|
||||||
aggregator at the moment.
|
-------------
|
||||||
|
|
||||||
|
Bebop uses a JSON file (usually in `~/.config`). It is created with default
|
||||||
|
values on first start. It is never written to afterwards: you can edit it when
|
||||||
|
you want, just restart Bebop to take changes into account.
|
||||||
|
|
||||||
|
Here are the available options:
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-------------------|-------------|----------|---------------------------------------|
|
||||||
|
| `connect_timeout` | int | 10 | Seconds before connection times out. |
|
||||||
|
| `text_width` | int | 80 | Rendered line length. |
|
||||||
|
| `source_editor` | string list | `["vi"]` | Command to use for editing sources. |
|
||||||
|
| `command_editor` | string list | `["vi"]` | Command to use for editing CLI input. |
|
||||||
|
|
||||||
|
Note: 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
FAQ
|
||||||
|
---
|
||||||
|
|
||||||
|
### Can I change the colors?
|
||||||
|
|
||||||
|
I do not plan to allow modifying the colors or elements style for now. Configure
|
||||||
|
a nice palette for your terminal so that Bebop fits nicely in there!
|
||||||
|
|
||||||
|
### Will Bebop implement subscriptions?
|
||||||
|
|
||||||
|
I have no need for them as I prefer to use a browser-agnostic aggregator at the
|
||||||
|
moment, so no.
|
||||||
|
|
||||||
|
### WTF is wrong with the command line?
|
||||||
|
|
||||||
|
I don't understand how you're supposed to create a fine input field in pure
|
||||||
|
curses without the form extension library, which is not available from Python.
|
||||||
|
Or, I think I understand but it's way too hard for the limited time I have to
|
||||||
|
work on Bebop. So the command line is based on the very limited Textbox class;
|
||||||
|
it's fine for entering a simple URL or a bookmark title but if you need to type
|
||||||
|
more than the window's width, press `M-e` (ALT + e) to open an editor.
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from bebop.browser.browser import Browser
|
from bebop.browser.browser import Browser
|
||||||
from bebop.fs import get_user_data_path
|
from bebop.config import load_config
|
||||||
|
from bebop.fs import get_config_path, get_user_data_path
|
||||||
from bebop.tofu import load_cert_stash, save_cert_stash
|
from bebop.tofu import load_cert_stash, save_cert_stash
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,6 +16,9 @@ def main():
|
||||||
else:
|
else:
|
||||||
start_url = None
|
start_url = None
|
||||||
|
|
||||||
|
config_path = get_config_path()
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
user_data_path = get_user_data_path()
|
user_data_path = get_user_data_path()
|
||||||
if not user_data_path.exists():
|
if not user_data_path.exists():
|
||||||
user_data_path.mkdir()
|
user_data_path.mkdir()
|
||||||
|
@ -22,7 +26,7 @@ def main():
|
||||||
cert_stash_path = user_data_path / "known_hosts.txt"
|
cert_stash_path = user_data_path / "known_hosts.txt"
|
||||||
cert_stash = load_cert_stash(cert_stash_path) or {}
|
cert_stash = load_cert_stash(cert_stash_path) or {}
|
||||||
try:
|
try:
|
||||||
Browser(cert_stash).run(start_url=start_url)
|
Browser(config, cert_stash).run(start_url=start_url)
|
||||||
finally:
|
finally:
|
||||||
save_cert_stash(cert_stash, cert_stash_path)
|
save_cert_stash(cert_stash, cert_stash_path)
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,8 @@ from bebop.page_pad import PagePad
|
||||||
class Browser:
|
class Browser:
|
||||||
"""Manage the events, inputs and rendering."""
|
"""Manage the events, inputs and rendering."""
|
||||||
|
|
||||||
def __init__(self, cert_stash):
|
def __init__(self, config, cert_stash):
|
||||||
|
self.config = config
|
||||||
self.stash = cert_stash or {}
|
self.stash = cert_stash or {}
|
||||||
self.screen = None
|
self.screen = None
|
||||||
self.dim = (0, 0)
|
self.dim = (0, 0)
|
||||||
|
@ -82,7 +83,10 @@ class Browser:
|
||||||
*self.line_dim,
|
*self.line_dim,
|
||||||
*self.command_line_pos,
|
*self.command_line_pos,
|
||||||
)
|
)
|
||||||
self.command_line = CommandLine(command_line_window)
|
self.command_line = CommandLine(
|
||||||
|
command_line_window,
|
||||||
|
self.config["command_editor"]
|
||||||
|
)
|
||||||
|
|
||||||
if start_url:
|
if start_url:
|
||||||
self.open_url(start_url, assume_absolute=True)
|
self.open_url(start_url, assume_absolute=True)
|
||||||
|
@ -429,7 +433,7 @@ class Browser:
|
||||||
if content is None:
|
if content is None:
|
||||||
self.set_status_error("Failed to open bookmarks.")
|
self.set_status_error("Failed to open bookmarks.")
|
||||||
return
|
return
|
||||||
self.load_page(Page.from_gemtext(content))
|
self.load_page(Page.from_gemtext(content, self.config["text_width"]))
|
||||||
self.current_url = "bebop://bookmarks"
|
self.current_url = "bebop://bookmarks"
|
||||||
|
|
||||||
def add_bookmark(self):
|
def add_bookmark(self):
|
||||||
|
@ -453,9 +457,7 @@ class Browser:
|
||||||
needs it, if needed. Internal pages, e.g. the bookmarks page, are loaded
|
needs it, if needed. Internal pages, e.g. the bookmarks page, are loaded
|
||||||
directly from their location on disk.
|
directly from their location on disk.
|
||||||
"""
|
"""
|
||||||
command = ["vi"]
|
|
||||||
delete_source_after = False
|
delete_source_after = False
|
||||||
|
|
||||||
special_pages = {
|
special_pages = {
|
||||||
"bebop://bookmarks": str(get_bookmarks_path())
|
"bebop://bookmarks": str(get_bookmarks_path())
|
||||||
}
|
}
|
||||||
|
@ -470,7 +472,7 @@ class Browser:
|
||||||
source_filename = source_file.name
|
source_filename = source_file.name
|
||||||
delete_source_after = True
|
delete_source_after = True
|
||||||
|
|
||||||
command.append(source_filename)
|
command = self.config["source_editor"] + [source_filename]
|
||||||
open_external_program(command)
|
open_external_program(command)
|
||||||
if delete_source_after:
|
if delete_source_after:
|
||||||
os.unlink(source_filename)
|
os.unlink(source_filename)
|
||||||
|
|
|
@ -33,7 +33,8 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True,
|
||||||
return
|
return
|
||||||
|
|
||||||
req = Request(url, browser.stash)
|
req = Request(url, browser.stash)
|
||||||
connected = req.connect()
|
connect_timeout = browser.config["connect_timeout"]
|
||||||
|
connected = req.connect(connect_timeout)
|
||||||
if not connected:
|
if not connected:
|
||||||
if req.state == Request.STATE_ERROR_CERT:
|
if req.state == Request.STATE_ERROR_CERT:
|
||||||
error = f"Certificate was missing or corrupt ({url})."
|
error = f"Certificate was missing or corrupt ({url})."
|
||||||
|
@ -110,7 +111,7 @@ def handle_response_content(browser: Browser, url: str, response: Response,
|
||||||
except LookupError:
|
except LookupError:
|
||||||
error = f"Unknown encoding {encoding}."
|
error = f"Unknown encoding {encoding}."
|
||||||
else:
|
else:
|
||||||
page = Page.from_gemtext(text)
|
page = Page.from_gemtext(text, browser.config["text_width"])
|
||||||
else:
|
else:
|
||||||
text = response.content.decode("utf-8", errors="replace")
|
text = response.content.decode("utf-8", errors="replace")
|
||||||
page = Page.from_text(text)
|
page = Page.from_text(text)
|
||||||
|
|
|
@ -19,8 +19,9 @@ class CommandLine:
|
||||||
its content as result.
|
its content as result.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, window):
|
def __init__(self, window, editor_command):
|
||||||
self.window = window
|
self.window = window
|
||||||
|
self.editor_command = editor_command
|
||||||
self.textbox = None
|
self.textbox = None
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
|
|
41
bebop/config.py
Normal file
41
bebop/config.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
"""Config management."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"connect_timeout": 10,
|
||||||
|
"text_width": 80,
|
||||||
|
"source_editor": ["vi"],
|
||||||
|
"command_editor": ["vi"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path):
|
||||||
|
if not os.path.isfile(config_path):
|
||||||
|
create_default_config(config_path)
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "rt") as config_file:
|
||||||
|
config = json.load(config_file)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Could not read config file {config_path}: {exc}")
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Could not parse config file {config_path}: {exc}")
|
||||||
|
else:
|
||||||
|
# Fill missing values with defaults.
|
||||||
|
for key, value in DEFAULT_CONFIG.items():
|
||||||
|
if key not in config:
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_config(config_path):
|
||||||
|
try:
|
||||||
|
with open(config_path, "wt") as config_file:
|
||||||
|
json.dump(DEFAULT_CONFIG, config_file, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Could not create config file {config_path}: {exc}")
|
|
@ -13,6 +13,13 @@ from pathlib import Path
|
||||||
APP_NAME = "bebop"
|
APP_NAME = "bebop"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(None)
|
||||||
|
def get_config_path() -> Path:
|
||||||
|
"""Return the user config file path."""
|
||||||
|
config_dir = Path(getenv("XDG_CONFIG_HOME", expanduser("~/.config")))
|
||||||
|
return config_dir / (APP_NAME + ".json")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(None)
|
@lru_cache(None)
|
||||||
def get_user_data_path() -> Path:
|
def get_user_data_path() -> Path:
|
||||||
"""Return the user data directory path."""
|
"""Return the user data directory path."""
|
||||||
|
|
|
@ -24,10 +24,10 @@ class Page:
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_gemtext(gemtext: str):
|
def from_gemtext(gemtext: str, wrap_at: int):
|
||||||
"""Produce a Page from a Gemtext file or string."""
|
"""Produce a Page from a Gemtext file or string."""
|
||||||
elements, links, title = parse_gemtext(gemtext)
|
elements, links, title = parse_gemtext(gemtext)
|
||||||
metalines = generate_metalines(elements, 80)
|
metalines = generate_metalines(elements, wrap_at)
|
||||||
return Page(gemtext, metalines, links, title)
|
return Page(gemtext, metalines, links, title)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -61,7 +61,7 @@ class Request:
|
||||||
self.cert_status = None
|
self.cert_status = None
|
||||||
self.error = ""
|
self.error = ""
|
||||||
|
|
||||||
def connect(self):
|
def connect(self, timeout):
|
||||||
"""Connect to a Gemini server and return a RequestEventType.
|
"""Connect to a Gemini server and return a RequestEventType.
|
||||||
|
|
||||||
Return True if the connection is established. The caller has to verify
|
Return True if the connection is established. The caller has to verify
|
||||||
|
@ -95,7 +95,7 @@ class Request:
|
||||||
self.payload += LINE_TERM
|
self.payload += LINE_TERM
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sock = socket.create_connection((hostname, port), timeout=10)
|
sock = socket.create_connection((hostname, port), timeout=timeout)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.state = Request.STATE_CONNECTION_FAILED
|
self.state = Request.STATE_CONNECTION_FAILED
|
||||||
self.error = exc.strerror
|
self.error = exc.strerror
|
||||||
|
|
Reference in a new issue