Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
([#45](https://github.com/davep/rogallo/pull/45))
- Added an `open` CLI command that allows a location to be opened from the
command line. ([#48](https://github.com/davep/rogallo/pull/48))
- Added support for working with gemtext files in the local filesystem.
([#49](https://github.com/davep/rogallo/pull/49))

## v0.2.0

Expand Down
4 changes: 3 additions & 1 deletion src/rogallo/messages/opening.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ class OpenText(Message):

text: str
"""The text to open."""
originally_from: GeminiLocation | None = None
original_request: OpenLocation
"""The original request that led to this text being opened."""
originally_from: GeminiLocation
"""The location the text was originally from, if any."""


Expand Down
39 changes: 39 additions & 0 deletions src/rogallo/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

##############################################################################
# Python imports.
from pathlib import Path
from urllib.parse import urlparse

##############################################################################
Expand Down Expand Up @@ -41,4 +42,42 @@ def is_likely_capsule(uri: str) -> bool:
return is_likely_page_relative(uri)


##############################################################################
def path_from_uri(uri: str) -> Path:
"""Get the path from a URI.

Args:
uri: The URI to get the path from.

Returns:
The path from the URI.

Raises:
ValueError: If the URI can't be turned into a [`Path`][pathlib.Path].
"""

if (parsed := urlparse(uri)).scheme.lower() == "file":
return Path(parsed.path).resolve()
elif not parsed.scheme and not parsed.netloc:
return Path(uri).expanduser().resolve()
raise ValueError(f"URI is not a local file: {uri}")


##############################################################################
def is_likely_local_file(uri: str) -> bool:
"""Determine if a URI is likely a local file.

Args:
uri: The URI to check.

Returns:
`True` if the URI is likely a local file, `False` otherwise.
"""
try:
candidate = path_from_uri(uri)
except ValueError:
return False
return candidate.exists() and candidate.is_file()


### location_tests.py ends here
94 changes: 57 additions & 37 deletions src/rogallo/screens/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
##############################################################################
# Python imports.
from argparse import Namespace
from pathlib import Path
from webbrowser import open as open_in_browser

##############################################################################
Expand Down Expand Up @@ -50,6 +51,7 @@
update_configuration,
)
from ..messages import OpenLocation, OpenText, OpenURI
from ..preflight import is_likely_local_file, path_from_uri
from ..providers import MainCommands
from ..widgets import CommandLine, HistoryViewer, Viewer

Expand Down Expand Up @@ -221,34 +223,6 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No
return len(self._location_history) > 0 or None
return True

def _maybe_remember_location(
self, request: OpenLocation, response: Response | None = None
) -> None:
"""Remember a location in the history.

Args:
location: The location to remember.
response: The response from the request, if any.
"""
if (
location := (
(response.uri or response.requested_uri)
if response and response.uri
else request.location
)
) is None:
return
self._location_history.add(LocationVisit(location))
self.mutate_reactive(Main._location_history)
save_location_history(self._location_history)
if (
not request.from_history
and self._navigation_history.current_item != location
):
self._navigation_history.add(location)
self.mutate_reactive(Main._navigation_history)
save_naviagation_history(self._navigation_history)

async def _handle_response(self, response: Response, request: OpenLocation) -> None:
"""Handle a response from a Gemini request.

Expand All @@ -272,8 +246,36 @@ async def _handle_response(self, response: Response, request: OpenLocation) -> N
title="Request Error",
)
return
self._maybe_remember_location(request, response)
self.post_message(OpenText(await response.text(), uri))
self.post_message(OpenText(await response.text(), request, uri))

def _maybe_remember_location(self, request: OpenText) -> None:
"""Remember a location in the history.

Args:
request: The request to open text for. This is used to determine
the location to remember.
"""
self._location_history.add(LocationVisit(request.originally_from))
self.mutate_reactive(Main._location_history)
save_location_history(self._location_history)
if (
not request.original_request.from_history
and self._navigation_history.current_item != request.originally_from
):
self._navigation_history.add(request.originally_from)
self.mutate_reactive(Main._navigation_history)
save_naviagation_history(self._navigation_history)

@on(OpenText)
def open_text(self, message: OpenText) -> None:
"""Open text in the viewer.

Args:
message: The message containing the text to open.
"""
self._maybe_remember_location(message)
self._viewer.document = Viewer.Document(message.originally_from, message.text)
self.refresh_bindings()

@work
async def _load_from_capsule(self, request: OpenLocation) -> None:
Expand Down Expand Up @@ -305,15 +307,28 @@ async def _load_from_capsule(self, request: OpenLocation) -> None:
finally:
self._command_line.working = False

@on(OpenText)
def open_text(self, message: OpenText) -> None:
"""Open text in the viewer.
@work(thread=True)
def _load_from_filesystem(self, request: OpenLocation) -> None:
"""Load a document from the filesystem.

Args:
message: The message containing the text to open.
request: The request to load the document from.
"""
self._viewer.document = Viewer.Document(message.originally_from, message.text)
self.refresh_bindings()
assert isinstance(request.location, Path)
try:
self.post_message(
OpenText(
request.location.read_text(encoding="utf-8"),
request,
request.location,
)
)
except OSError as error:
self.notify(
f"Error loading {request.location}:\n\n{error}",
severity="error",
title="Filesystem Error",
)

@on(OpenLocation)
def open_location(self, message: OpenLocation) -> None:
Expand All @@ -324,6 +339,8 @@ def open_location(self, message: OpenLocation) -> None:
"""
if isinstance(message.location, GeminiURI):
self._load_from_capsule(message)
else:
self._load_from_filesystem(message)

@on(OpenURI)
def open_uri(self, message: OpenURI) -> None:
Expand All @@ -340,7 +357,10 @@ def open_uri(self, message: OpenURI) -> None:
except URIError:
pass

# TODO: Handle gmi files in the filesystem.
# Perhaps it's a local file?
if is_likely_local_file(message.uri):
self.post_message(OpenLocation(path_from_uri(message.uri)))
return

# Otherwise, try to open it in the system browser.
open_in_browser(message.uri)
Expand Down
37 changes: 37 additions & 0 deletions src/rogallo/widgets/command_line/open_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Provides a command for opening a gemtext file in the local filesystem."""

##############################################################################
# Textual imports.
from textual.widget import Widget

##############################################################################
# Local imports.
from ...messages import OpenLocation
from ...preflight import is_likely_local_file, path_from_uri
from .base_command import InputCommand


##############################################################################
class OpenFileCommand(InputCommand):
"""Open `<file>` in your external browser"""
Comment thread
davep marked this conversation as resolved.
Outdated

COMMAND = "`<file>`"

@classmethod
def handle(cls, text: str, for_widget: Widget) -> bool:
"""Handle the command.

Args:
text: The text of the command.
for_widget: The widget to handle the command for.

Returns:
`True` if the command was handled; `False` if not.
"""
if is_likely_local_file(text):
for_widget.post_message(OpenLocation(path_from_uri(text)))
return True
return False


### open_file.py ends here
2 changes: 2 additions & 0 deletions src/rogallo/widgets/command_line/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@
from ...data import CommandLineHistory
from .base_command import InputCommand
from .general import HelpCommand, QuitCommand
from .open_file import OpenFileCommand
from .open_gemini_uri import OpenGeminiURICommand
from .open_other_uri import OpenOtherURICommand

##############################################################################
COMMANDS: Final[tuple[type[InputCommand], ...]] = (
OpenGeminiURICommand,
OpenOtherURICommand,
OpenFileCommand,
HelpCommand,
QuitCommand,
)
Expand Down
3 changes: 3 additions & 0 deletions src/rogallo/widgets/viewer/gemtext_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Python imports.
from collections.abc import Callable
from functools import cache
from pathlib import Path
from typing import Final
from urllib.parse import urlparse

Expand Down Expand Up @@ -226,6 +227,8 @@ def normalise_uri(self, base_uri: GeminiLocation | None) -> None:
return
if isinstance(base_uri, GeminiURI):
self._normalised_uri = str(base_uri.resolve(self._link.uri))
elif isinstance(base_uri, Path):
self._normalised_uri = (base_uri.parent / self._link.uri).resolve().as_uri()

def _watch__normalised_uri(self) -> None:
"""Watch for changes to the normalised URI."""
Expand Down
37 changes: 35 additions & 2 deletions tests/unit/test_preflight.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Unit tests for the Preflight module."""

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

##############################################################################
# Pytest imports.
from pytest import mark
from pytest import mark, raises

##############################################################################
# Local imports.
from rogallo.preflight import is_likely_capsule, is_likely_page_relative
from rogallo.preflight import is_likely_capsule, is_likely_page_relative, path_from_uri


##############################################################################
Expand Down Expand Up @@ -47,4 +51,33 @@ def test_is_likely_capsule(uri: str, result: bool) -> None:
assert is_likely_capsule(uri) is result


##############################################################################
@mark.parametrize(
"uri, result",
[
("file:///tmp/test.gmi", Path("/tmp/test.gmi").resolve()),
("/tmp/test.gmi", Path("/tmp/test.gmi").resolve()),
],
)
def test_path_from_uri_file(uri: str, result: Path) -> None:
"""Test the path_from_uri function with a file URI."""
assert path_from_uri(uri) == result


##############################################################################
@mark.parametrize(
"uri",
[
("http://example.com"),
("gemini://example.com"),
("ftp://example.com"),
("//example.com"),
],
)
def test_path_from_uri_invalid(uri: str) -> None:
"""Test the path_from_uri function with an invalid URI."""
with raises(ValueError):
_ = path_from_uri(uri)


### test_preflight.py ends here
Loading