From cf48818c240c71616eb5c8eb6298bb5dc396b7ee Mon Sep 17 00:00:00 2001 From: dece Date: Sat, 17 Apr 2021 22:59:54 +0200 Subject: [PATCH] protocol: download unknown mime types --- BOARD.txt | 4 +- bebop/browser/gemini.py | 86 ++++++++++++++++++++++++++--------------- bebop/fs.py | 22 +++++++++++ bebop/mime.py | 4 ++ bebop/protocol.py | 11 +++++- bebop/rendering.py | 4 +- 6 files changed, 96 insertions(+), 35 deletions(-) diff --git a/BOARD.txt b/BOARD.txt index 8f32be1..7e4d9fe 100644 --- a/BOARD.txt +++ b/BOARD.txt @@ -10,15 +10,17 @@ TODO DONE encodings bookmarks view/edit sources + downloads +open last download non shit command-line home page -downloads media files view history identity management configuration -------------------------------------------------------------------------------- BACKLOG +download to disk, not in memory margins / centering pre blocks folding buffers (tabs) diff --git a/bebop/browser/gemini.py b/bebop/browser/gemini.py index a8f7745..c285710 100644 --- a/bebop/browser/gemini.py +++ b/bebop/browser/gemini.py @@ -1,6 +1,9 @@ """Gemini-related features of the browser.""" +from pathlib import Path + from bebop.browser.browser import Browser +from bebop.fs import get_downloads_path from bebop.navigation import set_parameter from bebop.page import Page from bebop.protocol import Request, Response @@ -55,17 +58,10 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, if not response: browser.set_status_error(f"Server response parsing failed ({url}).") return + response.url = url if response.code == 20: - handle_code = handle_response_content(browser, response) - if handle_code == 0: - if browser.current_url and history: - browser.history.push(browser.current_url) - browser.current_url = url - browser.cache[url] = browser.page_pad.current_page - browser.set_status(url) - elif handle_code == 1: - browser.set_status(f"Downloaded {url}.") + handle_response_content(browser, url, response, history) elif response.generic_code == 30 and response.meta: browser.open_url(response.meta, base_url=url, redirects=redirects + 1) elif response.generic_code in (40, 50): @@ -78,44 +74,72 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, browser.set_status_error(error) -def handle_response_content(browser: Browser, response: Response) -> int: - """Handle a response's content from a Gemini server. +def handle_response_content(browser: Browser, url: str, response: Response, + history: bool): + """Handle a successful response content from a Gemini server. - According to the MIME type received or inferred, render or download the - response's content. + According to the MIME type received or inferred, the response is either + rendered by the browser, or saved to disk. If an error occurs, the browser + displays it. - Currently only text content is rendered. For Gemini, the encoding specified - in the response is used, if available on the Python distribution. For other - text formats, only UTF-8 is attempted. + Only text content is rendered. For Gemini, the encoding specified in the + response is used, if available on the Python distribution. For other text + formats, only UTF-8 is attempted. Arguments: + - browser: Browser instance that made the initial request. + - url: original URL. - response: a successful Response. - - Returns: - An error code: 0 means a page has been loaded, so any book-keeping such - as history management can be applied; 1 means a content has been - successfully retrieved but has not been displayed (e.g. non-text - content) nor saved as a page; 2 means that the content could not be - handled, either due to bogus MIME type or MIME parameters. + - history: whether to modify history on a page load. """ mime_type = response.get_mime_type() + page = None + error = None + filepath = None if mime_type.main_type == "text": if mime_type.sub_type == "gemini": encoding = mime_type.charset try: text = response.content.decode(encoding, errors="replace") except LookupError: - browser.set_status_error("Unknown encoding {encoding}.") - return 2 - browser.load_page(Page.from_gemtext(text)) - return 0 + error = f"Unknown encoding {encoding}." + else: + page = Page.from_gemtext(text) else: text = response.content.decode("utf-8", errors="replace") - browser.load_page(Page.from_text(text)) - return 0 + page = Page.from_text(text) else: - pass # TODO - return 1 + filepath = get_download_path(url) + + if page: + browser.load_page(page) + if browser.current_url and history: + browser.history.push(browser.current_url) + browser.current_url = url + browser.cache[url] = page + browser.set_status(url) + elif filepath: + try: + with open(filepath, "wb") as download_file: + download_file.write(response.content) + except OSError as exc: + browser.set_status_error(f"Failed to save {url} ({exc})") + else: + browser.set_status(f"Downloaded {url} ({mime_type.short}).") + elif error: + browser.set_status_error(error) + + +def get_download_path(url: str) -> Path: + """Try to find the best download file path possible from this URL.""" + download_dir = get_downloads_path() + 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_dir / filename def handle_input_request(browser: Browser, from_url: str, message: str =None): diff --git a/bebop/fs.py b/bebop/fs.py index 87c9e84..87ac441 100644 --- a/bebop/fs.py +++ b/bebop/fs.py @@ -4,6 +4,7 @@ A lot of logic comes from `appdirs`: https://github.com/ActiveState/appdirs/blob/master/appdirs.py """ +from functools import lru_cache from os import getenv from os.path import expanduser from pathlib import Path @@ -12,7 +13,28 @@ from pathlib import Path APP_NAME = "bebop" +@lru_cache(None) def get_user_data_path() -> Path: """Return the user data directory path.""" path = Path(getenv("XDG_DATA_HOME", expanduser("~/.local/share"))) return path / APP_NAME + + +@lru_cache(None) +def get_downloads_path() -> Path: + """Return the user downloads directory path.""" + xdg_config_path = Path(getenv("XDG_CONFIG_HOME", expanduser("~/.config"))) + download_path = "" + try: + with open(xdg_config_path / "user-dirs.dirs", "rt") as user_dirs_file: + for line in user_dirs_file: + if line.startswith("XDG_DOWNLOAD_DIR="): + download_path = line.rstrip().split("=", maxsplit=1)[1] + download_path = download_path.strip('"') + download_path = download_path.replace("$HOME", expanduser("~")) + break + except OSError: + pass + if download_path: + return Path(download_path) + return Path.home() diff --git a/bebop/mime.py b/bebop/mime.py index 8fab55f..b8db08e 100644 --- a/bebop/mime.py +++ b/bebop/mime.py @@ -13,6 +13,10 @@ class MimeType: sub_type: str parameters: dict + @property + def short(self): + return f"{self.main_type or '*'}/{self.sub_type or '*'}" + @property def charset(self): return self.parameters.get("charset", DEFAULT_CHARSET) diff --git a/bebop/protocol.py b/bebop/protocol.py index 1a1337d..47f2503 100644 --- a/bebop/protocol.py +++ b/bebop/protocol.py @@ -182,7 +182,16 @@ class StatusCode(IntEnum): @dataclass class Response: - """A Gemini response.""" + """A Gemini response. + + Response objects can be created only by parsing a Gemini response using the + static `parse` method, so you're guaranteed to have a valid object. + + Attributes: + - code: the status code returned by the server. + - meta: optional meta content. + - content: bytes as returned by the server, only in successful requests. + """ code: StatusCode meta: str = "" diff --git a/bebop/rendering.py b/bebop/rendering.py index eedd397..3ee6581 100644 --- a/bebop/rendering.py +++ b/bebop/rendering.py @@ -18,7 +18,7 @@ def render_lines(metalines, window, max_width): - window: window that will be resized as filled with rendered lines. - max_width: line length limit for the pad. - Return: + Returns: The tuple of integers (error, height, width), error being a non-zero value if an error occured during rendering, and height and width being the new dimensions of the resized window. @@ -53,7 +53,7 @@ def render_line(metaline, window, max_width): window.addstr(url_text, attributes) -def get_base_line_attributes(line_type): +def get_base_line_attributes(line_type) -> int: """Return the base attributes for this line type. Other attributes may be freely used later for this line type but this is