From 397a1436957c189ba8dcd66281076460b46eaf57 Mon Sep 17 00:00:00 2001 From: dece Date: Sat, 8 May 2021 22:41:42 +0200 Subject: [PATCH] navigation: rework entirely URL management oh and fix the whole history mess a bit --- bebop/browser/browser.py | 76 ++++++++---- bebop/browser/file.py | 15 ++- bebop/browser/gemini.py | 47 +++++--- bebop/help.py | 1 + bebop/history.py | 36 ++++-- bebop/navigation.py | 210 ++++++++++++++++++++++++++------- bebop/rendering.py | 2 +- bebop/tests/test_navigation.py | 127 +++++++++++++++++--- 8 files changed, 399 insertions(+), 115 deletions(-) diff --git a/bebop/browser/browser.py b/bebop/browser/browser.py index 4bb185e..9fabaab 100644 --- a/bebop/browser/browser.py +++ b/bebop/browser/browser.py @@ -18,7 +18,8 @@ from bebop.history import History from bebop.links import Links from bebop.mouse import ButtonState from bebop.navigation import ( - get_parent_url, get_root_url, join_url, parse_url, sanitize_url) + get_parent_url, get_root_url, join_url, parse_url, unparse_url +) from bebop.page import Page from bebop.page_pad import PagePad @@ -89,6 +90,9 @@ class Browser: "help": { "open": self.open_help, }, + "history": { + "open": self.open_history, + }, } def run(self, *args, **kwargs): @@ -176,6 +180,8 @@ class Browser: self.add_bookmark() elif char == ord("e"): self.edit_page() + elif char == ord("y"): + self.open_history() elif curses.ascii.isdigit(char): self.handle_digit_input(char) elif char == curses.KEY_MOUSE: @@ -300,38 +306,52 @@ class Browser: return if assume_absolute or not self.current_url: - parts = parse_url(url, absolute=True) - join = False + parts = parse_url(url, absolute=True, default_scheme="gemini") else: parts = parse_url(url) - join = True - if parts.scheme == "gemini": + if parts["netloc"] is None: + base_url = base_url or self.current_url + if base_url: + parts = parse_url(join_url(base_url, url)) + else: + self.set_status_error(f"Can't open '{url}'.") + return + + # Replace URL passed as parameter by a proper absolute one. + url = unparse_url(parts) + + scheme = parts["scheme"] or "" + if scheme == "gemini": from bebop.browser.gemini import open_gemini_url - # If there is no netloc, this is a relative URL. - if join or base_url: - url = join_url(base_url or self.current_url, url) - open_gemini_url( + success = open_gemini_url( self, - sanitize_url(url), + url, redirects=redirects, - history=history, use_cache=use_cache ) - elif parts.scheme.startswith("http"): + if history and success: + self.history.push(url) + + elif scheme.startswith("http"): from bebop.browser.web import open_web_url open_web_url(self, url) - elif parts.scheme == "file": + + elif scheme == "file": from bebop.browser.file import open_file - open_file(self, parts.path, history=history) - elif parts.scheme == "bebop": - special_page = self.special_pages.get(parts.netloc) + file_url = open_file(self, parts["path"]) + if history and file_url: + self.history.push(file_url) + + elif scheme == "bebop": + special_page = self.special_pages.get(parts["path"]) if special_page: special_page["open"]() else: self.set_status_error("Unknown page.") + else: - self.set_status_error(f"Protocol {parts.scheme} not supported.") + self.set_status_error(f"Protocol '{scheme}' not supported.") def load_page(self, page: Page): """Load Gemtext data as the current page.""" @@ -455,8 +475,9 @@ class Browser: def go_back(self): """Go back in history if possible.""" - if self.history.has_links(): - self.open_url(self.history.pop(), history=False) + previous_url = self.history.get_previous() + if previous_url: + self.open_url(previous_url, history=False) def go_to_parent_page(self): """Go to the parent URL if possible.""" @@ -475,7 +496,7 @@ class Browser: self.set_status_error("Failed to open bookmarks.") return 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): """Add the current URL as bookmark.""" @@ -502,8 +523,9 @@ class Browser: directly from their location on disk. """ delete_source_after = False - if self.current_url.startswith("bebop://"): - page_name = self.current_url[len("bebop://"):] + parts = parse_url(self.current_url) + if parts["scheme"] == "bebop": + page_name = parts["path"] special_pages_functions = self.special_pages.get(page_name) if not special_pages_functions: return @@ -530,9 +552,17 @@ class Browser: def open_help(self): """Show the help page.""" self.load_page(Page.from_gemtext(HELP_PAGE, self.config["text_width"])) - self.current_url = "bebop://help" + self.current_url = "bebop:help" def prompt(self, text, keys): """Display the text and allow it to type one of the given keys.""" self.set_status(text) return self.command_line.prompt_key(keys) + + def open_history(self): + """Show a generated history of visited pages.""" + self.load_page(Page.from_gemtext( + self.history.to_gemtext(), + self.config["text_width"] + )) + self.current_url = "bebop:history" diff --git a/bebop/browser/file.py b/bebop/browser/file.py index da03279..9958af9 100644 --- a/bebop/browser/file.py +++ b/bebop/browser/file.py @@ -4,22 +4,29 @@ from bebop.browser.browser import Browser from bebop.page import Page -def open_file(browser: Browser, filepath: str, encoding="utf-8", history=True): +def open_file(browser: Browser, filepath: str, encoding="utf-8"): """Open a file and render it. This should be used only on Gemtext files or at least text files. Anything else will produce garbage and may crash the program. In the future this should be able to use a different parser according to a MIME type or something. + + Arguments: + - browser: Browser object making the request. + - filepath: a text file path on disk. + - encoding: file's encoding. + + Returns: + The loaded file URI on success, None otherwise (e.g. file not found). """ try: with open(filepath, "rt", encoding=encoding) as f: text = f.read() except (OSError, ValueError) as exc: browser.set_status_error(f"Failed to open file: {exc}") - return + return None browser.load_page(Page.from_text(text)) file_url = "file://" + filepath - if history: - browser.history.push(file_url) browser.current_url = file_url + return file_url diff --git a/bebop/browser/gemini.py b/bebop/browser/gemini.py index dcdd88d..60dfa38 100644 --- a/bebop/browser/gemini.py +++ b/bebop/browser/gemini.py @@ -14,8 +14,7 @@ from bebop.tofu import trust_fingerprint, untrust_fingerprint, WRONG_FP_ALERT MAX_URL_LEN = 1024 -def open_gemini_url(browser: Browser, url, redirects=0, history=True, - use_cache=True): +def open_gemini_url(browser: Browser, url, redirects=0, use_cache=True): """Open a Gemini URL and set the formatted response as content. While the specification is not set in stone, every client takes a slightly @@ -33,12 +32,14 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, as we're doing TOFU here, we could automatically trust it or let the user choose. For simplicity, we always trust it permanently. - Attributes: + Arguments: - browser: Browser object making the request. - url: a valid URL with Gemini scheme to open. - redirects: current amount of redirections done to open the initial URL. - - history: if true, save the final URL to history. - use_cache: if true, look up if the page is cached before requesting it. + + Returns: + True on success, False otherwise. """ if len(url) >= MAX_URL_LEN: browser.set_status_error("Request URL too long.") @@ -48,11 +49,9 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, if use_cache and url in browser.cache: browser.load_page(browser.cache[url]) - if browser.current_url and history: - browser.history.push(browser.current_url) browser.current_url = url browser.set_status(url) - return + return True req = Request(url, browser.stash) connect_timeout = browser.config["connect_timeout"] @@ -69,7 +68,7 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, else: error = f"Connection failed ({url})." browser.set_status_error(error) - return + return False if req.state == Request.STATE_INVALID_CERT: pass @@ -88,13 +87,13 @@ def open_gemini_url(browser: Browser, url, redirects=0, history=True, data = req.proceed() if not data: browser.set_status_error(f"Server did not respond in time ({url}).") - return + return False response = Response.parse(data) if not response: browser.set_status_error(f"Server response parsing failed ({url}).") - return + return False - _handle_response(browser, response, url, redirects, history) + return _handle_response(browser, response, url, redirects) def _handle_untrusted_cert(browser: Browser, request: Request): @@ -118,10 +117,14 @@ def _handle_untrusted_cert(browser: Browser, request: Request): def _handle_response(browser: Browser, response: Response, url: str, - redirects: int, history: bool): - """Handle a response from a Gemini server.""" + redirects: int): + """Handle a response from a Gemini server. + + Returns: + True on success, False otherwise. + """ if response.code == 20: - _handle_successful_response(browser, response, url, history) + return _handle_successful_response(browser, response, url) 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): @@ -132,10 +135,10 @@ def _handle_response(browser: Browser, response: Response, url: str, else: error = f"Unhandled response code {response.code}" browser.set_status_error(error) + return False -def _handle_successful_response(browser: Browser, response: Response, url: str, - history: bool): +def _handle_successful_response(browser: Browser, response: Response, url: str): """Handle a successful response content from a Gemini server. According to the MIME type received or inferred, the response is either @@ -150,8 +153,11 @@ def _handle_successful_response(browser: Browser, response: Response, url: str, - browser: Browser instance that made the initial request. - url: original URL. - response: a successful Response. - - history: whether to modify history on a page load. + + Returns: + True on success, False otherwise. """ + # Use appropriate response parser according to the MIME type. mime_type = response.get_mime_type() page = None error = None @@ -171,13 +177,14 @@ def _handle_successful_response(browser: Browser, response: Response, url: str, else: filepath = _get_download_path(url) + # If a page has been produced, load it. Else if a file has been retrieved, + # download it. 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) + return True elif filepath: try: with open(filepath, "wb") as download_file: @@ -186,8 +193,10 @@ def _handle_successful_response(browser: Browser, response: Response, url: str, browser.set_status_error(f"Failed to save {url} ({exc})") else: browser.set_status(f"Downloaded {url} ({mime_type.short}).") + return True elif error: browser.set_status_error(error) + return False def _get_download_path(url: str) -> Path: diff --git a/bebop/help.py b/bebop/help.py index d172e54..53f00e6 100644 --- a/bebop/help.py +++ b/bebop/help.py @@ -32,6 +32,7 @@ name, not the symbol itself. - b: open bookmarks - B: add current page to bookmarks - e: open the current page source in an editor +- y: open history - digits: go to the corresponding link ID - escape: reset status line text ``` diff --git a/bebop/history.py b/bebop/history.py index 4097546..19eb625 100644 --- a/bebop/history.py +++ b/bebop/history.py @@ -2,20 +2,34 @@ class History: - """Basic browsing history manager.""" + """Basic browsing history manager. + + The history follows the "by last visited" behaviour of Firefox for the lack + of a better idea. Links are pushed as they are visited. If a link is visited + again, it bubbles up to the top of the history. + """ def __init__(self): self.urls = [] - def has_links(self): - """Return True if there is at least one URL in the history.""" - return bool(self.urls) - def push(self, url): - """Add an URL to the history.""" - if not self.urls or self.urls[-1] != url: - self.urls.append(url) + """Add an URL to the history. - def pop(self): - """Return latest URL added to history and remove it.""" - return self.urls.pop() + If the URL is already in the list, it is moved to the top. + """ + try: + self.urls.remove(url) + except ValueError: + pass + self.urls.append(url) + + def get_previous(self): + """Return previous URL, or None if there is only one or zero URL.""" + try: + return self.urls[-2] + except IndexError: + return None + + def to_gemtext(self): + """Generate a simple Gemtext page of the current history.""" + return "\n".join("=> " + url for url in self.urls) diff --git a/bebop/navigation.py b/bebop/navigation.py index f9fd1d0..415bfe5 100644 --- a/bebop/navigation.py +++ b/bebop/navigation.py @@ -1,66 +1,192 @@ -"""URI (RFC 3986) helpers for Gemini navigation.""" +"""URI (RFC 3986) helpers for Gemini navigation. -import urllib.parse +It was supposed to be just thin fixes around urllib.parse functions but as +gemini is not recognized as a valid scheme it breaks a lot of things, so it +turned into a basic re-implementation of the RFC. +""" + +import re +from ssl import RAND_pseudo_bytes +from typing import Any, Dict, Optional +from urllib.parse import quote -def parse_url(url: str, absolute: bool =False): +URI_RE = re.compile( + "^" + r"(?:(?P[^:/?#\n]+):)?" + r"(?://(?P[^/?#\n]*))?" + r"(?P[^?#\n]*)" + r"(?:\?(?P[^#\n]*))?" + r"(?:#(?P.*))?" + "$" +) + + +class InvalidUrlException(Exception): + """Generic exception for invalid URLs used in this module.""" + + def __init__(self, url): + super().__init__() + self.url = url + + +def parse_url( + url: str, + absolute: bool =False, + default_scheme: Optional[str] =None +) -> Dict[str, Any]: """Return URL parts from this URL. - This uses urllib.parse.urlparse to not reinvent the wheel, with a few - adjustments. + Use the RFC regex to get parts from URL. This function can be used on + regular URLs but also on not-so-compliant URLs, e.g. "capsule.org/page", + which might be typed by an user (see `absolute` argument). - First, urllib does not know the Gemini scheme (yet!) so if it - is specified we strip it to get an absolute netloc. + Arguments: + - url: URL to parse. + - absolute: assume the URL is absolute, e.g. in the case we are trying to + parse an URL an user has written, which is most of the time an absolute + URL even if not perfectly so. This only has an effect if, after the + initial parsing, there is no scheme or netloc available. + - default_scheme: specify the scheme to use if the URL either does not + specify it and we need it (e.g. there is a location), or `absolute` is + true; if absolute is true but `default_scheme` is not specified, use the + gemini scheme. - Second, as this function can be used to process arbitrary user input, we - clean it a bit: - - strip whitespaces from the URL - - if "absolute" is True, consider that the URL is meant to be absolute, even - though it technically is not, e.g. "dece.space" is not absolute as it - misses either the // delimiter. + Returns: + URL parts, as a dictionary with the following keys: "scheme", "netloc", + "path", "query" and "fragment". All keys are present, but all values can be + None, except path which is always a string (but can be empty). + + Raises: + InvalidUrlException if you put really really stupid strings in there. """ - url = url.strip() - if url.startswith("file://"): - return urllib.parse.urlparse(url) - if url.startswith("gemini://"): - url = url[7:] - parts = urllib.parse.urlparse(url, scheme="gemini") - if not parts.netloc or absolute: - parts = urllib.parse.urlparse(f"//{url}", scheme="gemini") + match = URI_RE.match(url) + if not match: + raise InvalidUrlException(url) + + match_dict = match.groupdict() + parts = { + k: match_dict.get(k) + for k in ("scheme", "netloc", "path", "query", "fragment") + } + + # Smol hack: if we assume it's an absolute URL, just prefix scheme and "//". + if absolute and not parts["scheme"] and not parts["netloc"]: + scheme = default_scheme or "gemini" + return parse_url(scheme + "://" + url) + + # Another smol hack: if there is no scheme, use `default_scheme` as default. + if default_scheme and parts["scheme"] is None: + parts["scheme"] = default_scheme + return parts -def sanitize_url(url: str): - """Parse and unparse an URL to ensure it has been properly formatted.""" - return urllib.parse.urlunparse(parse_url(url)) +def unparse_url(parts) -> str: + """Unparse parts of an URL produced by `parse_url`.""" + url = "" + if parts["scheme"] is not None: + url += parts["scheme"] + ":" + if parts["netloc"] is not None: + url += "//" + parts["netloc"] + if parts["path"] is not None: + url += parts["path"] + if parts["query"] is not None: + url += "?" + parts["query"] + if parts["fragment"] is not None: + url += "#" + parts["fragment"] + return url -def join_url(base_url: str, url: str): - """Join a base URL with a relative url.""" - if base_url.startswith("gemini://"): - base_url = base_url[7:] - parts = parse_url(urllib.parse.urljoin(base_url, url)) - return urllib.parse.urlunparse(parts) +def clear_post_path(parts) -> None: + """Clear optional post-path parts (query and fragment).""" + parts["query"] = None + parts["fragment"] = None -def set_parameter(url: str, user_input: str): +def join_url(base_url: str, rel_url: str) -> str: + """Join a base URL with a relative path.""" + parts = parse_url(base_url) + rel_parts = parse_url(rel_url) + if rel_url.startswith("/"): + new_path = rel_parts["path"] + else: + base_path = parts["path"] or "" + new_path = remove_last_segment(base_path) + "/" + rel_parts["path"] + parts["path"] = remove_dot_segments(new_path) + parts["query"] = rel_parts["query"] + parts["fragment"] = rel_parts["fragment"] + return unparse_url(parts) + + +def remove_dot_segments(path: str): + """Remove dot segments in an URL path.""" + output = "" + while path: + if path.startswith("../"): + path = path[3:] + elif path.startswith("./") or path.startswith("/./"): + path = path[2:] # Either strip "./" or leave a single "/". + elif path == "/.": + path = "/" + elif path.startswith("/../"): + path = "/" + path[4:] + output = remove_last_segment(output) + elif path == "/..": + path = "/" + output = remove_last_segment(output) + elif path in (".", ".."): + path = "" + else: + first_segment, path = pop_first_segment(path) + output += first_segment + return output + + +def remove_last_segment(path: str): + """Remove last path segment, including preceding "/" if any.""" + return path[:path.rfind("/")] + + +def pop_first_segment(path: str): + """Return first segment and the rest. + + Return the first segment including the initial "/" if any, and the rest of + the path up to, but not including, the next "/" or the end of the string. + """ + next_slash = path[1:].find("/") + if next_slash == -1: + return path, "" + next_slash += 1 + return path[:next_slash], path[next_slash:] + + +def set_parameter(url: str, user_input: str) -> str: """Return a new URL with the escaped user input appended.""" - quoted_input = urllib.parse.quote(user_input) - if "?" in url: - url = url.split("?", maxsplit=1)[0] - return url + "?" + quoted_input + parts = parse_url(url) + parts["query"] = quote(user_input) + return unparse_url(parts) + + +def get_parent_path(path: str) -> str: + """Return the parent path.""" + last_slash = path.rstrip("/").rfind("/") + if last_slash > -1: + path = path[:last_slash + 1] + return path def get_parent_url(url: str) -> str: """Return the parent URL (one level up).""" - scheme, netloc, path, _, _, _ = parse_url(url) - last_slash = path.rstrip("/").rfind("/") - if last_slash > -1: - path = path[:last_slash + 1] - return urllib.parse.urlunparse((scheme, netloc, path, "", "", "")) + parts = parse_url(url) + parts["path"] = get_parent_path(parts["path"]) # type: ignore + clear_post_path(parts) + return unparse_url(parts) def get_root_url(url: str) -> str: """Return the root URL (basically discards path).""" - scheme, netloc, _, _, _, _ = parse_url(url) - return urllib.parse.urlunparse((scheme, netloc, "/", "", "", "")) + parts = parse_url(url) + parts["path"] = "/" + clear_post_path(parts) + return unparse_url(parts) diff --git a/bebop/rendering.py b/bebop/rendering.py index f84469a..873b696 100644 --- a/bebop/rendering.py +++ b/bebop/rendering.py @@ -15,7 +15,7 @@ def render_lines(metalines, window, max_width): Arguments: - metalines: list of metalines to render, must have at least one element. - - window: window that will be resized as filled with rendered lines. + - window: window that will be resized and filled with rendered lines. - max_width: line length limit for the pad. Returns: diff --git a/bebop/tests/test_navigation.py b/bebop/tests/test_navigation.py index 88556a1..2ee0c26 100644 --- a/bebop/tests/test_navigation.py +++ b/bebop/tests/test_navigation.py @@ -1,32 +1,54 @@ import unittest -from ..navigation import join_url, parse_url, set_parameter +from ..navigation import ( + get_parent_url, get_root_url, join_url, parse_url, pop_first_segment, remove_dot_segments, + remove_last_segment, set_parameter, +) class TestNavigation(unittest.TestCase): def test_parse_url(self): - res = parse_url("gemini://dece.space/parse-me.gmi") - self.assertEqual(res.scheme, "gemini") - self.assertEqual(res.netloc, "dece.space") - self.assertEqual(res.path, "/parse-me.gmi") + # Basic complete URL. + res = parse_url("gemini://netloc/parse-me.gmi") + self.assertEqual(res["scheme"], "gemini") + self.assertEqual(res["netloc"], "netloc") + self.assertEqual(res["path"], "/parse-me.gmi") - res_netloc = parse_url("//dece.space/parse-me.gmi") - self.assertEqual(res, res_netloc) + # No scheme. + res_netloc = parse_url("//netloc/parse-me.gmi") + self.assertIsNone(res_netloc["scheme"], None) + for key in res_netloc: + if key == "scheme": + continue + self.assertEqual(res_netloc[key], res[key]) + # No scheme but a default is provided. + res_netloc = parse_url("//netloc/parse-me.gmi", default_scheme="gemini") + self.assertDictEqual(res_netloc, res) + + # No scheme nor netloc: only a path should be produced. + res = parse_url("dece.space/parse-me.gmi") + self.assertIsNone(res["scheme"]) + self.assertIsNone(res["netloc"]) + self.assertEqual(res["path"], "dece.space/parse-me.gmi") + + # No scheme nor netloc but we should pretend having an absolute URL. res = parse_url("dece.space/parse-me.gmi", absolute=True) - self.assertEqual(res.scheme, "gemini") - self.assertEqual(res.netloc, "dece.space") - self.assertEqual(res.path, "/parse-me.gmi") + self.assertEqual(res["scheme"], "gemini") + self.assertEqual(res["netloc"], "dece.space") + self.assertEqual(res["path"], "/parse-me.gmi") + # HTTPS scheme. res = parse_url("https://dece.space/index.html") - self.assertEqual(res.scheme, "https") - self.assertEqual(res.netloc, "dece.space") - self.assertEqual(res.path, "/index.html") + self.assertEqual(res["scheme"], "https") + self.assertEqual(res["netloc"], "dece.space") + self.assertEqual(res["path"], "/index.html") + # File scheme. res = parse_url("file:///home/dece/gemini/index.gmi") - self.assertEqual(res.scheme, "file") - self.assertEqual(res.path, "/home/dece/gemini/index.gmi") + self.assertEqual(res["scheme"], "file") + self.assertEqual(res["path"], "/home/dece/gemini/index.gmi") def test_join_url(self): url = join_url("gemini://dece.space/", "some-file.gmi") @@ -39,9 +61,84 @@ class TestNavigation(unittest.TestCase): self.assertEqual(url, "gemini://dece.space/dir1/other-file.gmi") url = join_url("gemini://dece.space/dir1/file.gmi", "../top-level.gmi") self.assertEqual(url, "gemini://dece.space/top-level.gmi") + url = join_url("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi") + self.assertEqual(url, "s://hard/test/b/d/a.gmi") + + def test_remove_dot_segments(self): + paths = [ + ("index.gmi", "index.gmi"), + ("/index.gmi", "/index.gmi"), + ("./index.gmi", "index.gmi"), + ("/./index.gmi", "/index.gmi"), + ("/../index.gmi", "/index.gmi"), + ("/a/b/c/./../../g", "/a/g"), + ("mid/content=5/../6", "mid/6"), + ("../../../../g", "g"), + ] + for path, expected in paths: + self.assertEqual( + remove_dot_segments(path), + expected, + msg=f"path was " + path + ) + + def test_remove_last_segment(self): + self.assertEqual(remove_last_segment(""), "") + self.assertEqual(remove_last_segment("/"), "") + self.assertEqual(remove_last_segment("/a"), "") + self.assertEqual(remove_last_segment("/a/"), "/a") + self.assertEqual(remove_last_segment("/a/b"), "/a") + self.assertEqual(remove_last_segment("/a/b/c/d"), "/a/b/c") + self.assertEqual(remove_last_segment("///"), "//") + + def test_pop_first_segment(self): + self.assertEqual(pop_first_segment(""), ("", "")) + self.assertEqual(pop_first_segment("a"), ("a", "")) + self.assertEqual(pop_first_segment("/a"), ("/a", "")) + self.assertEqual(pop_first_segment("/a/"), ("/a", "/")) + self.assertEqual(pop_first_segment("/a/b"), ("/a", "/b")) + self.assertEqual(pop_first_segment("a/b"), ("a", "/b")) def test_set_parameter(self): url = set_parameter("gemini://gus.guru/search", "my search") self.assertEqual(url, "gemini://gus.guru/search?my%20search") url = set_parameter("gemini://gus.guru/search?old%20search", "new") self.assertEqual(url, "gemini://gus.guru/search?new") + + def test_get_parent_url(self): + urls_and_parents = [ + ("gemini://host", "gemini://host"), + ("gemini://host/", "gemini://host/"), + ("gemini://host/a", "gemini://host/"), + ("gemini://host/a/", "gemini://host/"), + ("gemini://host/a/index.gmi", "gemini://host/a/"), + ("gemini://host/a/b/", "gemini://host/a/"), + ("gemini://host/a/b/file.flac", "gemini://host/a/b/"), + ("//host/a/b", "//host/a/"), + ("hey", "hey"), # does not really make sense but whatever + ("hey/ho", "hey/"), + ("hey/ho/letsgo", "hey/ho/"), + ] + for url, parent in urls_and_parents: + self.assertEqual( + get_parent_url(url), + parent, + msg=f"URL was " + url) + + def test_get_root_url(self): + urls_and_roots = [ + ("gemini://host", "gemini://host/"), + ("gemini://host/", "gemini://host/"), + ("gemini://host/a", "gemini://host/"), + ("gemini://host/a/b/c", "gemini://host/"), + ("//host/path", "//host/"), + ("//host/path?query", "//host/"), + ("dumb", "/"), + ("dumb/dumber", "/"), + ] + for url, root in urls_and_roots: + self.assertEqual( + get_root_url(url), + root, + msg=f"URL was " + url + )