diff --git a/BOARD.txt b/BOARD.txt index edbd6b8..d88c2c1 100644 --- a/BOARD.txt +++ b/BOARD.txt @@ -29,11 +29,13 @@ 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 +reload after forgetting old cert specify external commands interface (exec, run, pipes) table of contents better default editor than vim search engine auto-open some media types +use about scheme instead of bebop don't store explicit port 1965 in URL prefixes (e.g. for identities) fix shifted line detection when text goes belong the horizontal limit auto-rewrite missing "/" in empty URL paths diff --git a/bebop/browser/browser.py b/bebop/browser/browser.py index 153f2ba..de72caa 100644 --- a/bebop/browser/browser.py +++ b/bebop/browser/browser.py @@ -19,7 +19,7 @@ from bebop.bookmarks import ( ) from bebop.colors import A_ITALIC, ColorPair, init_colors from bebop.command_line import CommandLine -from bebop.external import open_external_program +from bebop.external import open_external_program, substitute_external_command from bebop.fs import get_capsule_prefs_path, get_identities_list_path from bebop.help import get_help from bebop.history import History @@ -269,6 +269,8 @@ class Browser: self.move_to_search_result(Browser.SEARCH_PREVIOUS) elif char == ord("i"): self.show_page_info() + elif char == ord("!"): + self.quick_command("exec") elif curses.ascii.isdigit(char): self.handle_digit_input(char) elif char == curses.KEY_MOUSE: @@ -385,6 +387,8 @@ class Browser: else: if command in ("o", "open"): self.open_url(words[1]) + elif command == "exec": + self.exec_external_command(" ".join(words[1:])) elif command == "forget-certificate": from bebop.browser.gemini import forget_certificate forget_certificate(self, words[1]) @@ -789,7 +793,7 @@ class Browser: encoding = page.encoding or "unk. encoding" size = f"{len(page.source)} chars" lines = f"{len(page.metalines)} lines" - info = f"{mime} {encoding} {size} {lines}" + info = f"{mime}; {encoding}; {size}; {lines}" self.set_status(info) def set_render_mode(self, mode): @@ -890,3 +894,16 @@ class Browser: continue logging.info(f"Loaded plugin {plugin_name}.") + + def exec_external_command(self, command): + if not self.current_page: + return + command = substitute_external_command( + command, self.current_url, self.current_page) + self.set_status(f"Launching `{command}`…") + success = open_external_program(command) + if success: + self.reset_status() + else: + self.set_status_error("Command failed.") + self.refresh_windows() diff --git a/bebop/browser/gemini.py b/bebop/browser/gemini.py index 29f4f8f..adfc640 100644 --- a/bebop/browser/gemini.py +++ b/bebop/browser/gemini.py @@ -172,7 +172,7 @@ def _handle_response( redirects=redirects + 1 ) elif response.generic_code in (40, 50): - error = f"Server error: {response.meta or Response.code.name}" + error = f"Server error: {response.meta or response.code.name}" browser.set_status_error(error) elif response.generic_code == 10: return _handle_input_request(browser, url, response.meta) diff --git a/bebop/external.py b/bebop/external.py index 3b69afc..9f04179 100644 --- a/bebop/external.py +++ b/bebop/external.py @@ -2,7 +2,24 @@ import curses import logging +import re import subprocess +import tempfile + +from bebop.page import Page + + +def _pre_exec(): + curses.nocbreak() + curses.echo() + curses.curs_set(1) + + +def _post_exec(): + curses.mousemask(curses.ALL_MOUSE_EVENTS) + curses.curs_set(0) + curses.noecho() + curses.cbreak() def open_external_program(command): @@ -14,17 +31,48 @@ def open_external_program(command): Returns: True if no exception occured. """ - curses.nocbreak() - curses.echo() - curses.curs_set(1) + _pre_exec() result = True try: subprocess.run(command) except OSError as exc: logging.error(f"Failed to run '{command}': {exc}") result = False - curses.mousemask(curses.ALL_MOUSE_EVENTS) - curses.curs_set(0) - curses.noecho() - curses.cbreak() + _post_exec() return result + + +SUB_URL_RE = re.compile(r"(? ParsedGemtext: match = Blockquote.RE.match(line) if match: text = match.groups()[0] - elements.append(Blockquote(text)) + if text or dumb: + elements.append(Blockquote(text)) continue match = ListItem.RE.match(line) diff --git a/bebop/tests/test_external.py b/bebop/tests/test_external.py new file mode 100644 index 0000000..b128ec7 --- /dev/null +++ b/bebop/tests/test_external.py @@ -0,0 +1,45 @@ +from bebop.metalines import RenderOptions +import unittest + +from ..external import substitute_external_command +from ..page import Page + + +URL = "gemini://example.com" +GEMTEXT = """\ +# Test page + +Blablabla + +=> gemini://example.com/index.gmi Link to index +=> gemini://example.com/sub/gemlog.gmi Link to gemlog +""" +PAGE = Page.from_gemtext(GEMTEXT, RenderOptions(80, "fancy", "- ")) + + +class TestExternal(unittest.TestCase): + + def test_substitute_external_command(self): + # Replace URLs occurences. + command = "gmni $u | grep $u" # Grep for a page's own URL. + result = substitute_external_command(command, URL, PAGE) + self.assertEqual(result, "gmni {u} | grep {u}".format(u=URL)) + + # Replace link ID's with the target URL. + command = "gmni $1 && gmni $2" # Get both links + result = substitute_external_command(command, URL, PAGE) + expected = ( + "gmni gemini://example.com/index.gmi" + " && gmni gemini://example.com/sub/gemlog.gmi" + ) + self.assertEqual(result, expected) + + # Invalid link ID raise a ValueError. + command = "gmni $8" + with self.assertRaises(Exception): + substitute_external_command(command, URL, PAGE) + + # Replace escaped $$ with literal $. + command = "grep ^iamaregex$$ | echo dollar $" # Do nothing with last. + result = substitute_external_command(command, URL, PAGE) + self.assertEqual(result, "grep ^iamaregex$ | echo dollar $")