From 2780f4001fe847898db2f387d9664c08723e29bd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 10:18:45 +0100 Subject: [PATCH 01/14] :sparkles: Add the core framework for download support This doesn't add the code that does the downloading. This adds the code that funnels other UI elements towards allowing for downloading. --- src/rogallo/messages/__init__.py | 2 ++ src/rogallo/messages/opening.py | 9 +++++++++ src/rogallo/screens/confirm_unsupported.py | 17 +++++++++++++++-- src/rogallo/screens/main/screen.py | 12 ++++++++++++ src/rogallo/screens/main/unsupported.py | 5 +++++ src/rogallo/widgets/viewer/gemtext/link.py | 13 ++++++++++++- 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/rogallo/messages/__init__.py b/src/rogallo/messages/__init__.py index 49cd0fdf..87e0b2a6 100644 --- a/src/rogallo/messages/__init__.py +++ b/src/rogallo/messages/__init__.py @@ -9,6 +9,7 @@ HistoryModified, ) from .opening import ( + DownloadURI, OpenFromFileSystem, OpenLocation, OpenURI, @@ -20,6 +21,7 @@ "BookmarksModified", "CopyToClipboard", "ClientCertificatesModified", + "DownloadURI", "HistoryModified", "OpenFromFileSystem", "OpenLocation", diff --git a/src/rogallo/messages/opening.py b/src/rogallo/messages/opening.py index 6fa7a569..30d4e7ed 100644 --- a/src/rogallo/messages/opening.py +++ b/src/rogallo/messages/opening.py @@ -26,6 +26,15 @@ class OpenURI(Message): """Whether to allow opening the URI from cache.""" +############################################################################## +@dataclass +class DownloadURI(Message): + """Download a given URI to the local filesystem.""" + + uri: str + """The URI to download.""" + + ############################################################################## @dataclass class OpenLocation(Message): diff --git a/src/rogallo/screens/confirm_unsupported.py b/src/rogallo/screens/confirm_unsupported.py index 136f4a21..2f660de5 100644 --- a/src/rogallo/screens/confirm_unsupported.py +++ b/src/rogallo/screens/confirm_unsupported.py @@ -17,7 +17,7 @@ from textual_enhanced.tools import add_key ############################################################################## -type Confirmation = Literal["once", "always"] | None +type Confirmation = Literal["once", "always", "download"] | None """Type of the data returned from the confirmation dialog.""" @@ -56,23 +56,29 @@ class ConfirmUnsupportedURI(ModalScreen[Confirmation]): BINDINGS = [ ("o", "open_once"), ("a", "open_always"), + ("d", "download"), ("escape", "cancel"), ("left, up", "app.focus_previous"), ("right, down", "app.focus_next"), ] - def __init__(self, uri: str, description: str) -> None: + def __init__( + self, uri: str, description: str, allow_download: bool = False + ) -> None: """Initialise the screen. Args: uri: The URI to confirm. description: A description for the confirmation. + allow_download: Whether to allow downloading the URI. Defaults to False. """ super().__init__() self._uri = uri """The URI to confirm.""" self._description = description """The description for the configuration dialog.""" + self._allow_download = allow_download + """Whether to allow downloading the URI.""" def compose(self) -> ComposeResult: """Compose the screen. @@ -86,6 +92,8 @@ def compose(self) -> ComposeResult: with HorizontalGroup(id="buttons"): yield Button(add_key("Once", "o"), id="once", variant="success") yield Button(add_key("Always", "a"), id="always", variant="success") + if self._allow_download: + yield Button(add_key("Download", "d"), id="download") yield Button(add_key("Cancel", "Esc"), id="cancel", variant="error") @on(Button.Pressed, "#once") @@ -98,6 +106,11 @@ def action_open_always(self) -> None: """Allow always opening.""" self.dismiss("always") + @on(Button.Pressed, "#download") + def action_download(self) -> None: + """Allow downloading the URI.""" + self.dismiss("download") + @on(Button.Pressed, "#cancel") def action_cancel(self) -> None: """Cancel opening the URI.""" diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index 74ac4c5d..d9ca554d 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -104,6 +104,7 @@ BookmarksModified, ClientCertificatesModified, CopyToClipboard, + DownloadURI, HistoryModified, OpenFromFileSystem, OpenLocation, @@ -579,6 +580,17 @@ def open_uri(self, message: OpenURI) -> None: self._navigation_changed() self.post_message(uri_resolver(message)) + @on(DownloadURI) + def download_uri(self, message: DownloadURI) -> None: + """Download a URI to the filesystem. + + Args: + message: The message containing the URI to download. + """ + self.notify( + f"TODO: Downloading {message.uri} to the filesystem...", title="Download" + ) + @on(OpenUnsupportedURI) @work async def _open_unsupported_uri(self, message: OpenUnsupportedURI) -> None: diff --git a/src/rogallo/screens/main/unsupported.py b/src/rogallo/screens/main/unsupported.py index 6cd4c913..34dddb5e 100644 --- a/src/rogallo/screens/main/unsupported.py +++ b/src/rogallo/screens/main/unsupported.py @@ -22,6 +22,7 @@ save_trusted_mime_types, save_trusted_schemes, ) +from ...messages import DownloadURI from ..confirm_unsupported import ConfirmUnsupportedURI from .local_messages import OpenUnsupportedMIMEType, OpenUnsupportedURI @@ -102,6 +103,7 @@ async def maybe_open_unsupported_mime_type( str(message.location), f"The MIME type '{message.mime_type}' is not supported by Rogallo. " "Do you want to open the location in your external browser?", + allow_download=not isinstance(message.location, Path), ) ): case "once": @@ -110,6 +112,9 @@ async def maybe_open_unsupported_mime_type( open_uri = True trusted_mime_types.add(message.mime_type) save_trusted_mime_types(trusted_mime_types) + case "download": + open_uri = False + owner.post_message(DownloadURI(str(message.location))) # At this point, if the user has consented to opening the location # based on the MIME type, let's do it. diff --git a/src/rogallo/widgets/viewer/gemtext/link.py b/src/rogallo/widgets/viewer/gemtext/link.py index cf7682c4..48a53c2f 100644 --- a/src/rogallo/widgets/viewer/gemtext/link.py +++ b/src/rogallo/widgets/viewer/gemtext/link.py @@ -35,7 +35,7 @@ ############################################################################## # Local imports. from ....data import load_general, load_icons -from ....messages import CopyToClipboard, OpenLocation, OpenURI +from ....messages import CopyToClipboard, DownloadURI, OpenLocation, OpenURI from ....preflight import ( has_navigable_path, is_finger_uri, @@ -109,6 +109,13 @@ class GemtextLink(Widget, can_focus=True): show=False, id="gemtext_link.copy_link", ), + HelpfulBinding( + "d", + "download_link", + "Download the content of the link", + show=False, + id="gemtext_link.download_link", + ), HelpfulBinding( "o", "open_link_externally", @@ -291,6 +298,10 @@ def _action_copy_link(self) -> None: CopyToClipboard(self._normalised_uri, description="selected link") ) + def _action_download_link(self) -> None: + """Download the content of the link.""" + self.post_message(DownloadURI(self._normalised_uri)) + def _action_open_link_externally(self) -> None: """Open the link in the external browser.""" open_in_browser(self._normalised_uri) From e29ccec82da5fda813a120bc81ab435b01ad913a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 10:46:48 +0100 Subject: [PATCH 02/14] :hammer: Turn URI to class inference into its own function --- src/rogallo/screens/main/uri_resolver.py | 38 +++++++++++++++++------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/rogallo/screens/main/uri_resolver.py b/src/rogallo/screens/main/uri_resolver.py index 596de3cc..00b394d9 100644 --- a/src/rogallo/screens/main/uri_resolver.py +++ b/src/rogallo/screens/main/uri_resolver.py @@ -39,19 +39,17 @@ ############################################################################## -def uri_resolver( - request: OpenURI, -) -> OpenFromFileSystem | OpenLocation | OpenUnsupportedURI: - """Turn a URI request into a location. +def class_from_uri( + uri: str, +) -> type[GeminiURI | TitanURI | FingerURI | GopherURI | SpartanURI | NexURI] | None: + """Return the URI class for a given URI string. Args: - request: The request to resolve. + uri: The URI string to check. Returns: - A message for opening the location. + The URI class for the given URI string, or None if the URI is not supported. """ - - # Work through the supported URI types. for uri_type, uri_error in ( (GeminiURI, GeminiURIError), (TitanURI, GeminiURIError), @@ -61,11 +59,29 @@ def uri_resolver( (NexURI, NexURIError), ): try: - return OpenLocation( - uri_type(request.uri), allow_cached=request.allow_cached - ) + _ = uri_type(uri) + return uri_type except uri_error: pass + return None + + +############################################################################## +def uri_resolver( + request: OpenURI, +) -> OpenFromFileSystem | OpenLocation | OpenUnsupportedURI: + """Turn a URI request into a location. + + Args: + request: The request to resolve. + + Returns: + A message for opening the location. + """ + + # Obviously a supported URI type? + if uri_type := class_from_uri(request.uri): + return OpenLocation(uri_type(request.uri), allow_cached=request.allow_cached) # Perhaps it's a local text file? if is_local_text_file(request.uri): From c919b602a1cd910952552e9b197a578b05d4fd96 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 10:47:22 +0100 Subject: [PATCH 03/14] :sparkles: Add core framework of the downloader function --- src/rogallo/screens/main/downloader.py | 50 ++++++++++++++++++++++++++ src/rogallo/screens/main/screen.py | 8 ++--- 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/rogallo/screens/main/downloader.py diff --git a/src/rogallo/screens/main/downloader.py b/src/rogallo/screens/main/downloader.py new file mode 100644 index 00000000..b1ae40f2 --- /dev/null +++ b/src/rogallo/screens/main/downloader.py @@ -0,0 +1,50 @@ +"""Provides a downloader for the rogallo application.""" + +############################################################################## +# Python imports. +from pathlib import Path + +############################################################################## +# Textual imports. +from textual.widget import Widget + +############################################################################## +# Textual FSPicker imports. +from textual_fspicker import FileSave + +############################################################################## +# Local imports. +from ...clients import Clients +from .uri_resolver import class_from_uri + + +############################################################################## +async def download(uri: str, clients: Clients, owner: Widget) -> None: + """Download a URI using the appropriate client. + + Args: + uri: The URI to download. + clients: The clients to use for downloading. + """ + + # Turn the URI into a URI class so we know what we're working with. + if (uri_class := class_from_uri(uri)) is None: + owner.notify(f"Unable to download {uri}: unsupported scheme", severity="error") + return + location = uri_class(uri) + + # Prompt the user for the download location. + if not ( + target_file := await owner.app.push_screen_wait( + FileSave( + title=f"Download {location}", default_file=Path(location.path).name + ) + ) + ): + owner.notify("Download cancelled.") + return + + owner.notify(f"TODO: Downloading {location} to {target_file}") + + +### downloader.py ends here diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index d9ca554d..eb94e8a5 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -122,6 +122,7 @@ from ..about_page import AboutPage from ..client_certificate import ClientCertificateViewer from ..trusted_hosts import TrustedHostsBrowser +from .downloader import download from .handlers import handle_filesystem_request from .local_messages import ( OpenDocument, @@ -581,15 +582,14 @@ def open_uri(self, message: OpenURI) -> None: self.post_message(uri_resolver(message)) @on(DownloadURI) - def download_uri(self, message: DownloadURI) -> None: + @work + async def download_uri(self, message: DownloadURI) -> None: """Download a URI to the filesystem. Args: message: The message containing the URI to download. """ - self.notify( - f"TODO: Downloading {message.uri} to the filesystem...", title="Download" - ) + await download(message.uri, self._clients, self) @on(OpenUnsupportedURI) @work From 8ed0942cd554a7cea929c6c4902669f01f9700c4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 10:53:13 +0100 Subject: [PATCH 04/14] :hammer: Add a type for remote locations --- src/rogallo/screens/main/uri_resolver.py | 3 ++- src/rogallo/types.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/rogallo/screens/main/uri_resolver.py b/src/rogallo/screens/main/uri_resolver.py index 00b394d9..6af9728c 100644 --- a/src/rogallo/screens/main/uri_resolver.py +++ b/src/rogallo/screens/main/uri_resolver.py @@ -35,13 +35,14 @@ local_index_from_uri, path_from_uri, ) +from ...types import RemoteRogalloLocation from .local_messages import OpenUnsupportedURI ############################################################################## def class_from_uri( uri: str, -) -> type[GeminiURI | TitanURI | FingerURI | GopherURI | SpartanURI | NexURI] | None: +) -> type[RemoteRogalloLocation] | None: """Return the URI class for a given URI string. Args: diff --git a/src/rogallo/types.py b/src/rogallo/types.py index 23212edd..f960e8ac 100644 --- a/src/rogallo/types.py +++ b/src/rogallo/types.py @@ -26,9 +26,13 @@ from wasat import GeminiURI, TitanURI ############################################################################## -type RogalloLocation = ( - Path | GeminiURI | TitanURI | FingerURI | GopherURI | SpartanURI | NexURI +type RemoteRogalloLocation = ( + GeminiURI | TitanURI | FingerURI | GopherURI | SpartanURI | NexURI ) +"""The type of a remote location handled by Rogallo.""" + +############################################################################## +type RogalloLocation = Path | RemoteRogalloLocation """The type of a location handled by Rogallo.""" ############################################################################## From cc3fe3e2d11ac5ac1d3e2027557f766b8dc413ef Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 10:54:04 +0100 Subject: [PATCH 05/14] :art: Code tidy --- src/rogallo/screens/main/uri_resolver.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/rogallo/screens/main/uri_resolver.py b/src/rogallo/screens/main/uri_resolver.py index 6af9728c..684fceb4 100644 --- a/src/rogallo/screens/main/uri_resolver.py +++ b/src/rogallo/screens/main/uri_resolver.py @@ -40,9 +40,7 @@ ############################################################################## -def class_from_uri( - uri: str, -) -> type[RemoteRogalloLocation] | None: +def class_from_uri(uri: str) -> type[RemoteRogalloLocation] | None: """Return the URI class for a given URI string. Args: From 26d77fae55872c88de3c9512b1e2bf510ca18148 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 17:04:41 +0100 Subject: [PATCH 06/14] :sparkles: Initial download support for Gemini URIs This is a pretty simple approach for now. The problem I see here is that I probably want to handle downloading via input too, and the like. For now this simply assumes that the data on the end of the URI is available without the need to mess with new certificates or get user input. --- src/rogallo/screens/main/downloader.py | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/rogallo/screens/main/downloader.py b/src/rogallo/screens/main/downloader.py index b1ae40f2..a90e73c0 100644 --- a/src/rogallo/screens/main/downloader.py +++ b/src/rogallo/screens/main/downloader.py @@ -12,12 +12,51 @@ # Textual FSPicker imports. from textual_fspicker import FileSave +############################################################################## +# Wasat imports. +from wasat import GeminiURI, SecurityError, URIError + ############################################################################## # Local imports. from ...clients import Clients from .uri_resolver import class_from_uri +############################################################################## +async def _download_gemini( + location: GeminiURI, target: Path, clients: Clients, owner: Widget +) -> None: + """Download a Gemini URI to a target file. + + Args: + location: The Gemini URI to download. + target: The target file to download to. + clients: The clients to use for downloading. + owner: The widget that owns the request. + """ + try: + response = await clients.gemini.request(location) + except (ConnectionError, SecurityError, URIError) as error: + owner.notify(f"Unable to download {location}: {error}", severity="error") + return + + if not response.status.is_success: + owner.notify( + f"Unable to download {location}: {response.status} {response.meta}", + severity="error", + ) + return + + # Write the content to the target file. + try: + target.write_bytes(await response.read()) + except OSError as error: + owner.notify(f"Unable to write to {target}: {error}", severity="error") + return + + owner.notify(f"Downloaded {location} to {target}") + + ############################################################################## async def download(uri: str, clients: Clients, owner: Widget) -> None: """Download a URI using the appropriate client. @@ -44,7 +83,13 @@ async def download(uri: str, clients: Clients, owner: Widget) -> None: owner.notify("Download cancelled.") return - owner.notify(f"TODO: Downloading {location} to {target_file}") + if isinstance(location, GeminiURI): + await _download_gemini(location, target_file, clients, owner) + else: + owner.notify( + f"Downloading for {location.scheme} URIs is not yet implemented", + severity="warning", + ) ### downloader.py ends here From 3c15415a63c4b5dacea7d56172d79de339807098 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 3 Oct 2026 17:22:02 +0100 Subject: [PATCH 07/14] :hammer: Remove the downloader (and Gemini download code) While what I had was working, I didn't like where it was going. I want to try, as much as possible, to work via the existing code that handles requests. So let's funnel the requests into a DownloadLocation class that can be fed to the handlers and then they can just save to the target file once all the other work is done. Is the idea anyway. This is just a placeholder for now as I have to go afk. --- src/rogallo/messages/__init__.py | 2 + src/rogallo/messages/opening.py | 13 +++- src/rogallo/screens/main/downloader.py | 95 -------------------------- src/rogallo/screens/main/screen.py | 38 ++++++++++- 4 files changed, 49 insertions(+), 99 deletions(-) delete mode 100644 src/rogallo/screens/main/downloader.py diff --git a/src/rogallo/messages/__init__.py b/src/rogallo/messages/__init__.py index 87e0b2a6..450e8a69 100644 --- a/src/rogallo/messages/__init__.py +++ b/src/rogallo/messages/__init__.py @@ -9,6 +9,7 @@ HistoryModified, ) from .opening import ( + DownloadLocation, DownloadURI, OpenFromFileSystem, OpenLocation, @@ -21,6 +22,7 @@ "BookmarksModified", "CopyToClipboard", "ClientCertificatesModified", + "DownloadLocation", "DownloadURI", "HistoryModified", "OpenFromFileSystem", diff --git a/src/rogallo/messages/opening.py b/src/rogallo/messages/opening.py index 30d4e7ed..40310f1d 100644 --- a/src/rogallo/messages/opening.py +++ b/src/rogallo/messages/opening.py @@ -12,7 +12,7 @@ ############################################################################## # Local imports. from ..input_content import InputContent -from ..types import RogalloLocation +from ..types import RemoteRogalloLocation, RogalloLocation ############################################################################## @@ -52,6 +52,17 @@ class OpenLocation(Message): """The input content associated with this location, if any.""" +############################################################################## +@dataclass +class DownloadLocation(Message): + """Download a given location to the local filesystem.""" + + location: RemoteRogalloLocation + """The location to download.""" + target: Path + """The target file to download to.""" + + ############################################################################## @dataclass class OpenFromFileSystem(Message): diff --git a/src/rogallo/screens/main/downloader.py b/src/rogallo/screens/main/downloader.py deleted file mode 100644 index a90e73c0..00000000 --- a/src/rogallo/screens/main/downloader.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Provides a downloader for the rogallo application.""" - -############################################################################## -# Python imports. -from pathlib import Path - -############################################################################## -# Textual imports. -from textual.widget import Widget - -############################################################################## -# Textual FSPicker imports. -from textual_fspicker import FileSave - -############################################################################## -# Wasat imports. -from wasat import GeminiURI, SecurityError, URIError - -############################################################################## -# Local imports. -from ...clients import Clients -from .uri_resolver import class_from_uri - - -############################################################################## -async def _download_gemini( - location: GeminiURI, target: Path, clients: Clients, owner: Widget -) -> None: - """Download a Gemini URI to a target file. - - Args: - location: The Gemini URI to download. - target: The target file to download to. - clients: The clients to use for downloading. - owner: The widget that owns the request. - """ - try: - response = await clients.gemini.request(location) - except (ConnectionError, SecurityError, URIError) as error: - owner.notify(f"Unable to download {location}: {error}", severity="error") - return - - if not response.status.is_success: - owner.notify( - f"Unable to download {location}: {response.status} {response.meta}", - severity="error", - ) - return - - # Write the content to the target file. - try: - target.write_bytes(await response.read()) - except OSError as error: - owner.notify(f"Unable to write to {target}: {error}", severity="error") - return - - owner.notify(f"Downloaded {location} to {target}") - - -############################################################################## -async def download(uri: str, clients: Clients, owner: Widget) -> None: - """Download a URI using the appropriate client. - - Args: - uri: The URI to download. - clients: The clients to use for downloading. - """ - - # Turn the URI into a URI class so we know what we're working with. - if (uri_class := class_from_uri(uri)) is None: - owner.notify(f"Unable to download {uri}: unsupported scheme", severity="error") - return - location = uri_class(uri) - - # Prompt the user for the download location. - if not ( - target_file := await owner.app.push_screen_wait( - FileSave( - title=f"Download {location}", default_file=Path(location.path).name - ) - ) - ): - owner.notify("Download cancelled.") - return - - if isinstance(location, GeminiURI): - await _download_gemini(location, target_file, clients, owner) - else: - owner.notify( - f"Downloading for {location.scheme} URIs is not yet implemented", - severity="warning", - ) - - -### downloader.py ends here diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index eb94e8a5..0d236b6e 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -5,6 +5,7 @@ from argparse import Namespace from collections.abc import Awaitable from functools import partial +from pathlib import Path from subprocess import CalledProcessError, run from typing import Final from webbrowser import open as open_in_browser @@ -104,6 +105,7 @@ BookmarksModified, ClientCertificatesModified, CopyToClipboard, + DownloadLocation, DownloadURI, HistoryModified, OpenFromFileSystem, @@ -122,7 +124,6 @@ from ..about_page import AboutPage from ..client_certificate import ClientCertificateViewer from ..trusted_hosts import TrustedHostsBrowser -from .downloader import download from .handlers import handle_filesystem_request from .local_messages import ( OpenDocument, @@ -131,7 +132,7 @@ ) from .request_builder import build_request from .unsupported import maybe_open_unsupported_mime_type, maybe_open_unsupported_uri -from .uri_resolver import uri_resolver +from .uri_resolver import class_from_uri, uri_resolver ############################################################################## @@ -589,7 +590,38 @@ async def download_uri(self, message: DownloadURI) -> None: Args: message: The message containing the URI to download. """ - await download(message.uri, self._clients, self) + + # Turn the URI into a URI class so we know what we're working with. + if (uri_class := class_from_uri(message.uri)) is None: + self.notify( + f"Unable to download {message.uri}: unsupported scheme", + severity="error", + ) + return + location = uri_class(message.uri) + + # Prompt the user for the download location. + if not ( + target_file := await self.app.push_screen_wait( + FileSave( + title=f"Download {location}", default_file=Path(location.path).name + ) + ) + ): + self.notify("Download cancelled.") + return + + self.post_message(DownloadLocation(location, target_file)) + + @on(DownloadLocation) + @work + async def download_location(self, message: DownloadLocation) -> None: + """Download a location to the filesystem. + + Args: + message: The message containing the location to download. + """ + self.notify(f"TODO: Downloading {message.location} to {message.target}") @on(OpenUnsupportedURI) @work From eed2134279df1e67a3a6581eddbc5072f2727a6d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 09:39:27 +0100 Subject: [PATCH 08/14] :sparkles: Add a type for download and ope requests --- src/rogallo/messages/__init__.py | 2 ++ src/rogallo/messages/opening.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/rogallo/messages/__init__.py b/src/rogallo/messages/__init__.py index 450e8a69..ff5bef75 100644 --- a/src/rogallo/messages/__init__.py +++ b/src/rogallo/messages/__init__.py @@ -9,6 +9,7 @@ HistoryModified, ) from .opening import ( + AcquireLocation, DownloadLocation, DownloadURI, OpenFromFileSystem, @@ -19,6 +20,7 @@ ############################################################################## # Exports. __all__ = [ + "AcquireLocation", "BookmarksModified", "CopyToClipboard", "ClientCertificatesModified", diff --git a/src/rogallo/messages/opening.py b/src/rogallo/messages/opening.py index 40310f1d..55619bc2 100644 --- a/src/rogallo/messages/opening.py +++ b/src/rogallo/messages/opening.py @@ -63,6 +63,11 @@ class DownloadLocation(Message): """The target file to download to.""" +############################################################################## +type AcquireLocation = OpenLocation | DownloadLocation +"""Type for messages that acquire a location, either for opening or downloading.""" + + ############################################################################## @dataclass class OpenFromFileSystem(Message): From 8296fa49bd56968a97afc8991a749608fca7d49c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 09:43:09 +0100 Subject: [PATCH 09/14] :sparkles: Implement the downloading code --- src/rogallo/screens/main/handlers/finger.py | 83 +++++++++++++++----- src/rogallo/screens/main/handlers/gemini.py | 28 +++++-- src/rogallo/screens/main/handlers/gopher.py | 83 ++++++++++++++------ src/rogallo/screens/main/handlers/nex.py | 20 ++++- src/rogallo/screens/main/handlers/spartan.py | 23 +++++- src/rogallo/screens/main/handlers/titan.py | 12 ++- src/rogallo/screens/main/request_builder.py | 4 +- src/rogallo/screens/main/screen.py | 24 +++--- 8 files changed, 200 insertions(+), 77 deletions(-) diff --git a/src/rogallo/screens/main/handlers/finger.py b/src/rogallo/screens/main/handlers/finger.py index 0c8d0738..9a157512 100644 --- a/src/rogallo/screens/main/handlers/finger.py +++ b/src/rogallo/screens/main/handlers/finger.py @@ -12,13 +12,63 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ..local_messages import OpenDocument +############################################################################## +async def _open_document( + uri: FingerURI, + request: OpenLocation, + client: Client, + cache: ContentCache, + owner: Widget, +) -> None: + """Open a document from a finger request. + + Args: + uri: The URI to open. + request: The open location request. + client: The client to use for the request. + cache: The content cache to use for caching documents. + owner: The widget that owns the request. + """ + owner.post_message( + OpenDocument( + cache.add_document( + Document( + location=uri, + original_location=uri, + content=(await client.request(uri)).text, + mime_type="text/plain", + avoid_cache=False, + avoid_history=request.avoid_history, + ) + ), + from_history=request.from_history, + ) + ) + + +############################################################################## +async def _download_document( + uri: FingerURI, request: DownloadLocation, client: Client, owner: Widget +) -> None: + """Download a document from a finger request. + + Args: + uri: The URI to download. + request: The download location request. + client: The client to use for the request. + owner: The widget that owns the request. + """ + request.target.write_bytes((await client.request(uri)).raw_bytes) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + + ############################################################################## async def handle_finger_request( - request: OpenLocation, client: Client, owner: Widget, cache: ContentCache + request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache ) -> None: """Handle a finger request. @@ -32,8 +82,14 @@ async def handle_finger_request( assert isinstance(uri, FingerURI) # Check the cache first. - if request.allow_cached and ( - cached_document := cache.get_document(uri, avoid_history=request.avoid_history) + if ( + isinstance(request, OpenLocation) + and request.allow_cached + and ( + cached_document := cache.get_document( + uri, avoid_history=request.avoid_history + ) + ) ): owner.post_message( OpenDocument(cached_document, from_history=request.from_history) @@ -41,21 +97,10 @@ async def handle_finger_request( return try: - owner.post_message( - OpenDocument( - cache.add_document( - Document( - location=uri, - original_location=uri, - content=(await client.request(uri)).text, - mime_type="text/plain", - avoid_cache=False, - avoid_history=request.avoid_history, - ) - ), - from_history=request.from_history, - ) - ) + if isinstance(request, OpenLocation): + await _open_document(uri, request, client, cache, owner) + else: + await _download_document(uri, request, client, owner) except Port79Error as error: owner.notify( f"Error loading {uri}:\n\n{error}", diff --git a/src/rogallo/screens/main/handlers/gemini.py b/src/rogallo/screens/main/handlers/gemini.py index c0a0c266..ba3d6d0c 100644 --- a/src/rogallo/screens/main/handlers/gemini.py +++ b/src/rogallo/screens/main/handlers/gemini.py @@ -1,5 +1,9 @@ """Provides code for handling a Gemini request.""" +############################################################################## +# Python imports. +from pathlib import Path + ############################################################################## # Textual imports. from textual.widget import Widget @@ -20,7 +24,7 @@ # Local imports. from ....cache import ContentCache from ....input_content import InputContent -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ...user_input import UserInput from ..local_messages import OpenDocument, OpenUnsupportedMIMEType @@ -82,7 +86,7 @@ async def _handle_input_request( ############################################################################## async def _handle_response( response: Response, - request: OpenLocation, + request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache, @@ -105,10 +109,13 @@ async def _handle_response( # If we ended up with a response URI that is a different protocol, # bounce to its handler. if not isinstance(uri, GeminiURI): + assert not isinstance(uri, Path) owner.post_message( OpenLocation( location=uri, allow_cached=False, avoid_history=request.avoid_history ) + if isinstance(request, OpenLocation) + else DownloadLocation(location=uri, target=request.target) ) return @@ -132,7 +139,8 @@ async def _handle_response( # Handle any other non-successful response. if not response.status.is_success: - set_last_input(request.associated_input) + if isinstance(request, OpenLocation): + set_last_input(request.associated_input) owner.notify( f"Error loading {uri}:\n\n{response.status.value} {response.status.name}\n{response.meta}", severity="error", @@ -143,6 +151,12 @@ async def _handle_response( # Clear out any saved input. set_last_input(None) + # If it's a download request, perform the download. + if isinstance(request, DownloadLocation): + request.target.write_bytes(await response.read()) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + return + # Handle a successful response. if is_displayable_mime_type(response.mime_type): owner.post_message( @@ -157,7 +171,7 @@ async def _handle_response( ############################################################################## async def handle_gemini_request( - request: OpenLocation, + request: AcquireLocation, owner: Widget, client: Client, cache: ContentCache, @@ -181,7 +195,8 @@ async def handle_gemini_request( # If a cached copy of the document exists and the request allows it, # use that instead of making a network request. if ( - request.allow_cached + isinstance(request, OpenLocation) + and request.allow_cached and ( cached_document := cache.get_document( uri, avoid_history=request.avoid_history @@ -201,7 +216,8 @@ async def handle_gemini_request( response, request, client, owner, cache, set_last_input, get_last_input ) except ConnectionError as error: - set_last_input(request.associated_input) + if isinstance(request, OpenLocation): + set_last_input(request.associated_input) owner.notify( f"Error loading {uri}:\n\n{error}", severity="error", diff --git a/src/rogallo/screens/main/handlers/gopher.py b/src/rogallo/screens/main/handlers/gopher.py index 53cfd88c..5b1b015f 100644 --- a/src/rogallo/screens/main/handlers/gopher.py +++ b/src/rogallo/screens/main/handlers/gopher.py @@ -20,14 +20,63 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ..local_messages import OpenDocument, OpenUnsupportedMIMEType ############################################################################## -async def handle_gopher_request( +async def __open_document( + uri: GopherURI, request: OpenLocation, + client: Client, + cache: ContentCache, + owner: Widget, +) -> None: + # While Gopher doesn't deal with MIME types, Rogallo does for the + # most part, so let's figure out the effective MIME type for what + # we're doing here. + mime_type = ItemType(uri.item_type).mime_type + if not is_displayable_mime_type(mime_type): + owner.post_message(OpenUnsupportedMIMEType(uri, mime_type)) + return + + owner.post_message( + OpenDocument( + cache.add_document( + Document( + location=uri, + original_location=uri, + content=(await client.request(uri)).text, + mime_type=mime_type, + avoid_cache=ItemType(uri.item_type) is ItemType.INDEX_SEARCH, + avoid_history=request.avoid_history, + ) + ), + from_history=request.from_history, + ) + ) + + +############################################################################## +async def _download_document( + uri: GopherURI, request: DownloadLocation, client: Client, owner: Widget +) -> None: + """Download a document from a gopher request. + + Args: + uri: The URI to download. + request: The download location request. + client: The client to use for the request. + owner: The widget that owns the request. + """ + request.target.write_bytes((await client.request(uri)).raw_bytes) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + + +############################################################################## +async def handle_gopher_request( + request: AcquireLocation, current_document: Document, client: Client, owner: Widget, @@ -65,7 +114,8 @@ async def handle_gopher_request( # If a cached copy of the document exists and the request allows it, # use that instead of making a network request. if ( - ItemType(uri.item_type) is not ItemType.INDEX_SEARCH + isinstance(request, OpenLocation) + and ItemType(uri.item_type) is not ItemType.INDEX_SEARCH and request.allow_cached and ( cached_document := cache.get_document( @@ -78,30 +128,11 @@ async def handle_gopher_request( ) return - # While Gopher doesn't deal with MIME types, Rogallo does for the - # most part, so let's figure out the effective MIME type for what - # we're doing here. - mime_type = ItemType(uri.item_type).mime_type - if not is_displayable_mime_type(mime_type): - owner.post_message(OpenUnsupportedMIMEType(uri, mime_type)) - return - try: - owner.post_message( - OpenDocument( - cache.add_document( - Document( - location=uri, - original_location=uri, - content=(await client.request(uri)).text, - mime_type=mime_type, - avoid_cache=ItemType(uri.item_type) is ItemType.INDEX_SEARCH, - avoid_history=request.avoid_history, - ) - ), - from_history=request.from_history, - ) - ) + if isinstance(request, OpenLocation): + await __open_document(uri, request, client, cache, owner) + else: + await _download_document(uri, request, client, owner) except Port70Error as error: owner.notify( f"Error loading {uri}:\n\n{error}", diff --git a/src/rogallo/screens/main/handlers/nex.py b/src/rogallo/screens/main/handlers/nex.py index b28a91bf..8de78223 100644 --- a/src/rogallo/screens/main/handlers/nex.py +++ b/src/rogallo/screens/main/handlers/nex.py @@ -12,14 +12,14 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ..local_messages import OpenDocument, OpenUnsupportedMIMEType ############################################################################## async def handle_nex_request( - request: OpenLocation, client: Client, owner: Widget, cache: ContentCache + request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache ) -> None: """Handle a Nex request. @@ -33,8 +33,14 @@ async def handle_nex_request( assert isinstance(uri, NexURI) # Check the cache first. - if request.allow_cached and ( - cached_document := cache.get_document(uri, avoid_history=request.avoid_history) + if ( + isinstance(request, OpenLocation) + and request.allow_cached + and ( + cached_document := cache.get_document( + uri, avoid_history=request.avoid_history + ) + ) ): owner.post_message( OpenDocument(cached_document, from_history=request.from_history) @@ -61,6 +67,12 @@ async def handle_nex_request( ) return + # If it's a download request, write the raw bytes to the target file. + if isinstance(request, DownloadLocation): + request.target.write_bytes(response.raw_bytes) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + return + # Try and show it. if is_displayable_mime_type(response.mime_type): owner.post_message( diff --git a/src/rogallo/screens/main/handlers/spartan.py b/src/rogallo/screens/main/handlers/spartan.py index 085dfdd2..86a38497 100644 --- a/src/rogallo/screens/main/handlers/spartan.py +++ b/src/rogallo/screens/main/handlers/spartan.py @@ -1,5 +1,9 @@ """Provides code for handling a Spartan request.""" +############################################################################## +# Python imports. +from pathlib import Path + ############################################################################## # Sybaritic imports. from sybaritic import Client, Response, SpartanURI, SybariticError @@ -12,7 +16,7 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ....text_decoder import decode_text from ....types import SpartanURINeedingData @@ -22,7 +26,7 @@ ############################################################################## async def _handle_response( - response: Response, request: OpenLocation, owner: Widget, cache: ContentCache + response: Response, request: AcquireLocation, owner: Widget, cache: ContentCache ) -> None: """Handle a response from a Spartan request. @@ -35,10 +39,13 @@ async def _handle_response( uri = response.uri or response.requested_uri or request.location if not isinstance(uri, SpartanURI): + assert not isinstance(uri, Path) owner.post_message( OpenLocation( location=uri, allow_cached=False, avoid_history=request.avoid_history ) + if isinstance(request, OpenLocation) + else DownloadLocation(location=uri, target=request.target) ) return @@ -51,6 +58,13 @@ async def _handle_response( ) return + # It's a download request, so let's write the raw bytes to the target + # file. + if isinstance(request, DownloadLocation): + request.target.write_bytes(await response.read()) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + return + # Handle a successful response. if is_displayable_mime_type(response.mime_type): owner.post_message( @@ -74,7 +88,7 @@ async def _handle_response( ############################################################################## async def handle_spartan_request( - request: OpenLocation, client: Client, owner: Widget, cache: ContentCache + request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache ) -> None: """Handle a Spartan request. @@ -91,7 +105,8 @@ async def handle_spartan_request( # If a cached copy of the document exists and the request allows it, # use that instead of making a network request. if ( - not isinstance(uri, SpartanURINeedingData) + isinstance(request, OpenLocation) + and not isinstance(uri, SpartanURINeedingData) and request.allow_cached and ( cached_document := cache.get_document( diff --git a/src/rogallo/screens/main/handlers/titan.py b/src/rogallo/screens/main/handlers/titan.py index ff3d6278..dccb64c8 100644 --- a/src/rogallo/screens/main/handlers/titan.py +++ b/src/rogallo/screens/main/handlers/titan.py @@ -21,7 +21,7 @@ ############################################################################## # Local imports. from ....input_content import InputContent -from ....messages import OpenLocation +from ....messages import AcquireLocation, DownloadLocation from ....mime_checks import is_displayable_mime_type from ....text_decoder import decode_text from ...user_upload import UserUpload @@ -38,7 +38,7 @@ ############################################################################## async def _handle_response( response: Response, - request: OpenLocation, + request: AcquireLocation, client: Client, owner: Widget, set_last_input: LastInputSetter, @@ -78,6 +78,12 @@ async def _handle_response( # Clear out any saved input. set_last_input(None) + # If it's a download request, perform the download. + if isinstance(request, DownloadLocation): + request.target.write_bytes(await response.read()) + owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + return + # Handle a successful response. if is_displayable_mime_type(response.mime_type): owner.post_message( @@ -135,7 +141,7 @@ async def _get_raw_content_to_edit( ############################################################################## async def handle_titan_request( - request: OpenLocation, + request: AcquireLocation, owner: Widget, client: Client, set_last_input: LastInputSetter, diff --git a/src/rogallo/screens/main/request_builder.py b/src/rogallo/screens/main/request_builder.py index e4568535..d4a8f8bc 100644 --- a/src/rogallo/screens/main/request_builder.py +++ b/src/rogallo/screens/main/request_builder.py @@ -33,7 +33,7 @@ from ...cache import ContentCache from ...clients import Clients from ...document import Document -from ...messages import OpenLocation +from ...messages import AcquireLocation from .handlers import ( LastInputGetter, LastInputSetter, @@ -49,7 +49,7 @@ ############################################################################## def build_request( clients: Clients, - message: OpenLocation, + message: AcquireLocation, cache: ContentCache, owner: Widget, current_document: Document, diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index 0d236b6e..ab2c97c5 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -102,6 +102,7 @@ ) from ...input_content import InputContent from ...messages import ( + AcquireLocation, BookmarksModified, ClientCertificatesModified, CopyToClipboard, @@ -540,11 +541,18 @@ def _load_from_filesystem(self, request: OpenLocation) -> None: handle_filesystem_request(request, self) @on(OpenLocation) - def open_location(self, message: OpenLocation) -> None: - """Open a location in the viewer. + @on(DownloadLocation) + def acquire_location(self, message: AcquireLocation) -> None: + """Acquire the content of a location. Args: - message: The message the location open request. + message: The message the location acquisition request. + + Note: + The request can either be a request to open a location for viewing, + or a request to download a location to the filesystem. The handling + of the request is delegated to the appropriate handler based on the + type of the request. """ if ( request := build_request( @@ -613,16 +621,6 @@ async def download_uri(self, message: DownloadURI) -> None: self.post_message(DownloadLocation(location, target_file)) - @on(DownloadLocation) - @work - async def download_location(self, message: DownloadLocation) -> None: - """Download a location to the filesystem. - - Args: - message: The message containing the location to download. - """ - self.notify(f"TODO: Downloading {message.location} to {message.target}") - @on(OpenUnsupportedURI) @work async def _open_unsupported_uri(self, message: OpenUnsupportedURI) -> None: From 45a16681f8ce33938a98c94ad7c37f9a105c9be3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 14:26:42 +0100 Subject: [PATCH 10/14] :hammer: DRY the downloading code --- .../screens/main/handlers/_download.py | 34 +++++++++++++++++++ src/rogallo/screens/main/handlers/finger.py | 21 ++---------- src/rogallo/screens/main/handlers/gemini.py | 4 +-- src/rogallo/screens/main/handlers/gopher.py | 25 +++----------- src/rogallo/screens/main/handlers/nex.py | 4 +-- src/rogallo/screens/main/handlers/spartan.py | 4 +-- src/rogallo/screens/main/handlers/titan.py | 4 +-- 7 files changed, 50 insertions(+), 46 deletions(-) create mode 100644 src/rogallo/screens/main/handlers/_download.py diff --git a/src/rogallo/screens/main/handlers/_download.py b/src/rogallo/screens/main/handlers/_download.py new file mode 100644 index 00000000..bd33e3f9 --- /dev/null +++ b/src/rogallo/screens/main/handlers/_download.py @@ -0,0 +1,34 @@ +"""Provide code for downloading and saving content.""" + +############################################################################## +# Textual imports. +from textual.widget import Widget + +############################################################################## +# Local imports. +from ....messages import DownloadLocation + + +############################################################################## +def save_download(request: DownloadLocation, content: bytes, owner: Widget) -> None: + """Save the downloaded content to the target file. + + Args: + request: The download request containing the target file. + content: The content to save. + """ + try: + request.target.write_bytes(content) + except OSError as error: + owner.notify( + f"Failed to save downloaded content to {request.target}:\n\n{error}", + severity="error", + title="Download Error", + ) + return + owner.notify( + f"Downloaded {request.location} to {request.target}", title="Download Complete" + ) + + +### _download.py ends here diff --git a/src/rogallo/screens/main/handlers/finger.py b/src/rogallo/screens/main/handlers/finger.py index 9a157512..040fae93 100644 --- a/src/rogallo/screens/main/handlers/finger.py +++ b/src/rogallo/screens/main/handlers/finger.py @@ -12,8 +12,9 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import AcquireLocation, DownloadLocation, OpenLocation +from ....messages import AcquireLocation, OpenLocation from ..local_messages import OpenDocument +from ._download import save_download ############################################################################## @@ -50,22 +51,6 @@ async def _open_document( ) -############################################################################## -async def _download_document( - uri: FingerURI, request: DownloadLocation, client: Client, owner: Widget -) -> None: - """Download a document from a finger request. - - Args: - uri: The URI to download. - request: The download location request. - client: The client to use for the request. - owner: The widget that owns the request. - """ - request.target.write_bytes((await client.request(uri)).raw_bytes) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") - - ############################################################################## async def handle_finger_request( request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache @@ -100,7 +85,7 @@ async def handle_finger_request( if isinstance(request, OpenLocation): await _open_document(uri, request, client, cache, owner) else: - await _download_document(uri, request, client, owner) + save_download(request, (await client.request(uri)).raw_bytes, owner) except Port79Error as error: owner.notify( f"Error loading {uri}:\n\n{error}", diff --git a/src/rogallo/screens/main/handlers/gemini.py b/src/rogallo/screens/main/handlers/gemini.py index ba3d6d0c..d96751b0 100644 --- a/src/rogallo/screens/main/handlers/gemini.py +++ b/src/rogallo/screens/main/handlers/gemini.py @@ -28,6 +28,7 @@ from ....mime_checks import is_displayable_mime_type from ...user_input import UserInput from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download from ._glv import ( LastInputGetter, LastInputSetter, @@ -153,8 +154,7 @@ async def _handle_response( # If it's a download request, perform the download. if isinstance(request, DownloadLocation): - request.target.write_bytes(await response.read()) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + save_download(request, await response.read(), owner) return # Handle a successful response. diff --git a/src/rogallo/screens/main/handlers/gopher.py b/src/rogallo/screens/main/handlers/gopher.py index 5b1b015f..4d24cd0b 100644 --- a/src/rogallo/screens/main/handlers/gopher.py +++ b/src/rogallo/screens/main/handlers/gopher.py @@ -20,13 +20,14 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import AcquireLocation, DownloadLocation, OpenLocation +from ....messages import AcquireLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download ############################################################################## -async def __open_document( +async def _open_document( uri: GopherURI, request: OpenLocation, client: Client, @@ -58,22 +59,6 @@ async def __open_document( ) -############################################################################## -async def _download_document( - uri: GopherURI, request: DownloadLocation, client: Client, owner: Widget -) -> None: - """Download a document from a gopher request. - - Args: - uri: The URI to download. - request: The download location request. - client: The client to use for the request. - owner: The widget that owns the request. - """ - request.target.write_bytes((await client.request(uri)).raw_bytes) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") - - ############################################################################## async def handle_gopher_request( request: AcquireLocation, @@ -130,9 +115,9 @@ async def handle_gopher_request( try: if isinstance(request, OpenLocation): - await __open_document(uri, request, client, cache, owner) + await _open_document(uri, request, client, cache, owner) else: - await _download_document(uri, request, client, owner) + save_download(request, (await client.request(uri)).raw_bytes, owner) except Port70Error as error: owner.notify( f"Error loading {uri}:\n\n{error}", diff --git a/src/rogallo/screens/main/handlers/nex.py b/src/rogallo/screens/main/handlers/nex.py index 8de78223..c9c4a525 100644 --- a/src/rogallo/screens/main/handlers/nex.py +++ b/src/rogallo/screens/main/handlers/nex.py @@ -15,6 +15,7 @@ from ....messages import AcquireLocation, DownloadLocation, OpenLocation from ....mime_checks import is_displayable_mime_type from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download ############################################################################## @@ -69,8 +70,7 @@ async def handle_nex_request( # If it's a download request, write the raw bytes to the target file. if isinstance(request, DownloadLocation): - request.target.write_bytes(response.raw_bytes) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + save_download(request, response.raw_bytes, owner) return # Try and show it. diff --git a/src/rogallo/screens/main/handlers/spartan.py b/src/rogallo/screens/main/handlers/spartan.py index 86a38497..a7d7a5e1 100644 --- a/src/rogallo/screens/main/handlers/spartan.py +++ b/src/rogallo/screens/main/handlers/spartan.py @@ -22,6 +22,7 @@ from ....types import SpartanURINeedingData from ...user_input import UserInput from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download ############################################################################## @@ -61,8 +62,7 @@ async def _handle_response( # It's a download request, so let's write the raw bytes to the target # file. if isinstance(request, DownloadLocation): - request.target.write_bytes(await response.read()) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + save_download(request, await response.read(), owner) return # Handle a successful response. diff --git a/src/rogallo/screens/main/handlers/titan.py b/src/rogallo/screens/main/handlers/titan.py index dccb64c8..983b7910 100644 --- a/src/rogallo/screens/main/handlers/titan.py +++ b/src/rogallo/screens/main/handlers/titan.py @@ -26,6 +26,7 @@ from ....text_decoder import decode_text from ...user_upload import UserUpload from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download from ._glv import ( LastInputGetter, LastInputSetter, @@ -80,8 +81,7 @@ async def _handle_response( # If it's a download request, perform the download. if isinstance(request, DownloadLocation): - request.target.write_bytes(await response.read()) - owner.notify(f"Downloaded {uri} to {request.target}", title="Download Complete") + save_download(request, await response.read(), owner) return # Handle a successful response. From 10dfc9a7347f9da9f28f961efb67581bc952513b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 14:33:53 +0100 Subject: [PATCH 11/14] :lipstick: Make the download cancellation message stand out --- src/rogallo/screens/main/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index ab2c97c5..ed23a25f 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -616,7 +616,7 @@ async def download_uri(self, message: DownloadURI) -> None: ) ) ): - self.notify("Download cancelled.") + self.notify("Download cancelled.", severity="warning") return self.post_message(DownloadLocation(location, target_file)) From 090c3105d2dfe72a348b5c531c0cd789de13d162 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 14:43:37 +0100 Subject: [PATCH 12/14] :lipstick: Rename the save button to download --- src/rogallo/screens/main/screen.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rogallo/screens/main/screen.py b/src/rogallo/screens/main/screen.py index ed23a25f..bd047007 100644 --- a/src/rogallo/screens/main/screen.py +++ b/src/rogallo/screens/main/screen.py @@ -612,7 +612,9 @@ async def download_uri(self, message: DownloadURI) -> None: if not ( target_file := await self.app.push_screen_wait( FileSave( - title=f"Download {location}", default_file=Path(location.path).name + title=f"Download {location}", + default_file=Path(location.path).name, + save_button="Download", ) ) ): From f75d7c32ec3db1520b4367eb36dccd0fb8605374 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 14:46:38 +0100 Subject: [PATCH 13/14] :books: Update the ChangeLog --- ChangeLog.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index 3db64135..e969e098 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,14 @@ # Rogallo ChangeLog +## Unreleased + +**Released: WiP** + +- Added a download command to in-document links; bound to d by + default. ([#488](https://github.com/davep/rogallo/pull/488)) +- Added the option to download an unsupported mime type document. + ([#488](https://github.com/davep/rogallo/pull/488)) + ## v3.0.0 **Released: 2026-10-02** From eedae742b41b85b308df7f3c0e5892c9182941aa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Oct 2026 14:48:01 +0100 Subject: [PATCH 14/14] :books: Document the download binding ID --- docs/source/ui/viewer.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/ui/viewer.md b/docs/source/ui/viewer.md index 3c6a5216..9db6be97 100644 --- a/docs/source/ui/viewer.md +++ b/docs/source/ui/viewer.md @@ -271,6 +271,7 @@ show_link_tooltips: true The following actions can [have their bindings overridden](../configuration/bindings.md): - `gemtext_link.copy_link` - Copy the current link's URI to clipboard +- `gemtext_link.download_link` - Download the content of the link - `gemtext_link.open_link_externally` - Hand the current link off to the operating system for opening - `viewer.cancel_search` - Cancel the current search in the document - `viewer.next_link` - Move forward through each of the links