Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Rogallo ChangeLog

## Unreleased

**Released: WiP**

- Added a download command to in-document links; bound to <kbd>d</kbd> 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**
Expand Down
1 change: 1 addition & 0 deletions docs/source/ui/viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/rogallo/messages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
HistoryModified,
)
from .opening import (
AcquireLocation,
DownloadLocation,
DownloadURI,
OpenFromFileSystem,
OpenLocation,
OpenURI,
Expand All @@ -17,9 +20,12 @@
##############################################################################
# Exports.
__all__ = [
"AcquireLocation",
"BookmarksModified",
"CopyToClipboard",
"ClientCertificatesModified",
"DownloadLocation",
"DownloadURI",
"HistoryModified",
"OpenFromFileSystem",
"OpenLocation",
Expand Down
27 changes: 26 additions & 1 deletion src/rogallo/messages/opening.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
##############################################################################
# Local imports.
from ..input_content import InputContent
from ..types import RogalloLocation
from ..types import RemoteRogalloLocation, RogalloLocation


##############################################################################
Expand All @@ -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):
Expand All @@ -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):
Expand Down
17 changes: 15 additions & 2 deletions src/rogallo/screens/confirm_unsupported.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""


Expand Down Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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."""
Expand Down
34 changes: 34 additions & 0 deletions src/rogallo/screens/main/handlers/_download.py
Original file line number Diff line number Diff line change
@@ -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
68 changes: 49 additions & 19 deletions src/rogallo/screens/main/handlers/finger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -32,30 +67,25 @@ 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)
)
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}",
Expand Down
28 changes: 22 additions & 6 deletions src/rogallo/screens/main/handlers/gemini.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Provides code for handling a Gemini request."""

##############################################################################
# Python imports.
from pathlib import Path

##############################################################################
# Textual imports.
from textual.widget import Widget
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -157,7 +171,7 @@ async def _handle_response(

##############################################################################
async def handle_gemini_request(
request: OpenLocation,
request: AcquireLocation,
owner: Widget,
client: Client,
cache: ContentCache,
Expand All @@ -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
Expand All @@ -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",
Expand Down
Loading
Loading