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** 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 diff --git a/src/rogallo/messages/__init__.py b/src/rogallo/messages/__init__.py index 49cd0fdf..ff5bef75 100644 --- a/src/rogallo/messages/__init__.py +++ b/src/rogallo/messages/__init__.py @@ -9,6 +9,9 @@ HistoryModified, ) from .opening import ( + AcquireLocation, + DownloadLocation, + DownloadURI, OpenFromFileSystem, OpenLocation, OpenURI, @@ -17,9 +20,12 @@ ############################################################################## # Exports. __all__ = [ + "AcquireLocation", "BookmarksModified", "CopyToClipboard", "ClientCertificatesModified", + "DownloadLocation", + "DownloadURI", "HistoryModified", "OpenFromFileSystem", "OpenLocation", diff --git a/src/rogallo/messages/opening.py b/src/rogallo/messages/opening.py index 6fa7a569..55619bc2 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 ############################################################################## @@ -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): @@ -43,6 +52,22 @@ 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.""" + + +############################################################################## +type AcquireLocation = OpenLocation | DownloadLocation +"""Type for messages that acquire a location, either for opening or downloading.""" + + ############################################################################## @dataclass class OpenFromFileSystem(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/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 0c8d0738..040fae93 100644 --- a/src/rogallo/screens/main/handlers/finger.py +++ b/src/rogallo/screens/main/handlers/finger.py @@ -12,13 +12,48 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import OpenLocation +from ....messages import AcquireLocation, OpenLocation from ..local_messages import OpenDocument +from ._download import save_download + + +############################################################################## +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 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 +67,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 +82,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: + 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 c0a0c266..d96751b0 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,10 +24,11 @@ # 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 +from ._download import save_download from ._glv import ( LastInputGetter, LastInputSetter, @@ -82,7 +87,7 @@ async def _handle_input_request( ############################################################################## async def _handle_response( response: Response, - request: OpenLocation, + request: AcquireLocation, client: Client, owner: Widget, cache: ContentCache, @@ -105,10 +110,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 +140,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 +152,11 @@ 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): + save_download(request, await response.read(), owner) + 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..4d24cd0b 100644 --- a/src/rogallo/screens/main/handlers/gopher.py +++ b/src/rogallo/screens/main/handlers/gopher.py @@ -20,14 +20,48 @@ # Local imports. from ....cache import ContentCache from ....document import Document -from ....messages import 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 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 handle_gopher_request( + request: AcquireLocation, current_document: Document, client: Client, owner: Widget, @@ -65,7 +99,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 +113,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: + 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 b28a91bf..c9c4a525 100644 --- a/src/rogallo/screens/main/handlers/nex.py +++ b/src/rogallo/screens/main/handlers/nex.py @@ -12,14 +12,15 @@ # 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 +from ._download import save_download ############################################################################## 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 +34,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 +68,11 @@ async def handle_nex_request( ) return + # If it's a download request, write the raw bytes to the target file. + if isinstance(request, DownloadLocation): + save_download(request, response.raw_bytes, owner) + 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..a7d7a5e1 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,17 +16,18 @@ # 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 from ...user_input import UserInput from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download ############################################################################## 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 +40,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 +59,12 @@ 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): + save_download(request, await response.read(), owner) + 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..983b7910 100644 --- a/src/rogallo/screens/main/handlers/titan.py +++ b/src/rogallo/screens/main/handlers/titan.py @@ -21,11 +21,12 @@ ############################################################################## # 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 from ..local_messages import OpenDocument, OpenUnsupportedMIMEType +from ._download import save_download from ._glv import ( LastInputGetter, LastInputSetter, @@ -38,7 +39,7 @@ ############################################################################## async def _handle_response( response: Response, - request: OpenLocation, + request: AcquireLocation, client: Client, owner: Widget, set_last_input: LastInputSetter, @@ -78,6 +79,11 @@ 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): + save_download(request, await response.read(), owner) + 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 74ac4c5d..bd047007 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 @@ -101,9 +102,12 @@ ) from ...input_content import InputContent from ...messages import ( + AcquireLocation, BookmarksModified, ClientCertificatesModified, CopyToClipboard, + DownloadLocation, + DownloadURI, HistoryModified, OpenFromFileSystem, OpenLocation, @@ -129,7 +133,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 ############################################################################## @@ -537,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( @@ -579,6 +590,39 @@ def open_uri(self, message: OpenURI) -> None: self._navigation_changed() self.post_message(uri_resolver(message)) + @on(DownloadURI) + @work + async def download_uri(self, message: DownloadURI) -> None: + """Download a URI to the filesystem. + + Args: + message: The message containing the URI to download. + """ + + # 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, + save_button="Download", + ) + ) + ): + self.notify("Download cancelled.", severity="warning") + return + + self.post_message(DownloadLocation(location, target_file)) + @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/screens/main/uri_resolver.py b/src/rogallo/screens/main/uri_resolver.py index 596de3cc..684fceb4 100644 --- a/src/rogallo/screens/main/uri_resolver.py +++ b/src/rogallo/screens/main/uri_resolver.py @@ -35,23 +35,20 @@ local_index_from_uri, path_from_uri, ) +from ...types import RemoteRogalloLocation from .local_messages import OpenUnsupportedURI ############################################################################## -def uri_resolver( - request: OpenURI, -) -> OpenFromFileSystem | OpenLocation | OpenUnsupportedURI: - """Turn a URI request into a location. +def class_from_uri(uri: str) -> type[RemoteRogalloLocation] | 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 +58,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): 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.""" ############################################################################## 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)