From dd4a4196a8ccc9fadbdc6c58dddb7557eac629f8 Mon Sep 17 00:00:00 2001 From: dece Date: Sun, 14 Mar 2021 00:04:55 +0100 Subject: [PATCH] mime: basic MIME type management --- bebop/browser.py | 58 +++++++++++++++++++++++++++++++++++-------- bebop/command_line.py | 3 ++- bebop/mime.py | 40 +++++++++++++++++++++++++++++ bebop/protocol.py | 13 +++++++--- 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 bebop/mime.py diff --git a/bebop/browser.py b/bebop/browser.py index e9ba8e1..7b7dc1b 100644 --- a/bebop/browser.py +++ b/bebop/browser.py @@ -131,10 +131,10 @@ class Browser: elif char == ord("l"): self.scroll_page_horizontally(1) self.screen.nodelay(False) - - ctrl_char = curses.unctrl(char) - if ctrl_char == "a": - self.set_status("yup!") + # else: + # unctrled = curses.unctrl(char) + # if unctrled == b"^T": + # self.set_status("test!") @property def page_pad_size(self): @@ -281,13 +281,14 @@ class Browser: return if response.code == 20: - # TODO handle MIME type; assume it's gemtext for now. - text = response.content.decode("utf-8", errors="replace") - self.load_page(Page.from_gemtext(text)) - if self.current_url and history: - self.history.push(self.current_url) - self.current_url = url - self.set_status(url) + handle_code = self.handle_response_content(response) + if handle_code == 0: + if self.current_url and history: + self.history.push(self.current_url) + self.current_url = url + self.set_status(url) + elif handle_code == 1: + self.set_status(f"Downloaded {url}.") elif response.generic_code == 30 and response.meta: self.open_url(response.meta, base_url=url, redirects=redirects + 1) elif response.generic_code in (40, 50): @@ -299,6 +300,41 @@ class Browser: error = f"Unhandled response code {response.code}" self.set_status_error(error) + def handle_response_content(self, response: Response) -> int: + """Handle a response's content from a Gemini server. + + According to the MIME type received or inferred, render or download the + response's content. + + Currently only text/gemini content is rendered. + + Arguments: + - 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. + """ + mime_type = response.get_mime_type() + 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: + self.set_status_error("Unknown encoding {encoding}.") + return 2 + self.load_page(Page.from_gemtext(text)) + return 0 + else: + pass # TODO + else: + pass # TODO + return 1 + def load_page(self, page: Page): """Load Gemtext data as the current page.""" old_pad_height = self.page_pad.dim[0] diff --git a/bebop/command_line.py b/bebop/command_line.py index cdba03a..3992cef 100644 --- a/bebop/command_line.py +++ b/bebop/command_line.py @@ -47,7 +47,8 @@ class CommandLine: command = "" except TerminateCommandInterrupt as exc: command = exc.command - command = command[1:].rstrip() + else: + command = command[1:].rstrip() curses.curs_set(0) self.clear() return command diff --git a/bebop/mime.py b/bebop/mime.py new file mode 100644 index 0000000..8fab55f --- /dev/null +++ b/bebop/mime.py @@ -0,0 +1,40 @@ +"""Basic MIME utilities (RFC 2046).""" + +from dataclasses import dataclass +from typing import Optional + + +DEFAULT_CHARSET = "utf-8" + + +@dataclass +class MimeType: + main_type: str + sub_type: str + parameters: dict + + @property + def charset(self): + return self.parameters.get("charset", DEFAULT_CHARSET) + + @staticmethod + def from_str(mime_string) -> Optional["MimeType"]: + """Parse a MIME string into a MimeType instance, or None on error.""" + if ";" in mime_string: + type_str, *parameters = mime_string.split(";") + parameters = {} + for param in map(lambda s: s.strip().lower(), parameters): + if param.count("=") != 1: + return None + param_name, param_value = param.split("=") + parameters[param_name] = param_value + else: + type_str = mime_string.strip() + parameters = {} + if type_str.count("/") != 1: + return None + main_type, sub_type = type_str.split("/") + return MimeType(main_type, sub_type, parameters) + + +DEFAULT_MIME_TYPE = MimeType("text", "gemini", {"charset": DEFAULT_CHARSET}) diff --git a/bebop/protocol.py b/bebop/protocol.py index 9e3ea6f..1a1337d 100644 --- a/bebop/protocol.py +++ b/bebop/protocol.py @@ -5,7 +5,9 @@ import socket import ssl from dataclasses import dataclass from enum import IntEnum +from typing import Optional +from bebop.mime import DEFAULT_MIME_TYPE, MimeType from bebop.tofu import CertStatus, CERT_STATUS_INVALID, validate_cert @@ -190,11 +192,16 @@ class Response: MAX_META_LEN = 1024 @property - def generic_code(self): + def generic_code(self) -> int: + """See `Response.get_generic_code`.""" return Response.get_generic_code(self.code) + def get_mime_type(self) -> MimeType: + """Return the MIME type if possible, else the default MIME type.""" + return MimeType.from_str(self.meta) or DEFAULT_MIME_TYPE + @staticmethod - def parse(data): + def parse(data: bytes) -> Optional["Response"]: """Parse a received response.""" try: response_header_len = data.index(LINE_TERM) @@ -216,6 +223,6 @@ class Response: return response @staticmethod - def get_generic_code(code): + def get_generic_code(code) -> int: """Return the generic version (x0) of this code.""" return code - (code % 10)