Make navigation toggleable

This commit is contained in:
Timothy Farrell 2026-05-07 21:28:05 +00:00
parent e6a833f6f5
commit 7619f63191
6 changed files with 128 additions and 52 deletions

View File

@ -105,7 +105,10 @@
window.location.href = buildUrl(params.hash, newOrder, params.delay); window.location.href = buildUrl(params.hash, newOrder, params.delay);
} }
} else if (e.key.toLowerCase() === 'n') { } else if (e.key.toLowerCase() === 'n') {
document.getElementById('toggle-link').click(); var link = document.getElementById('toggle-link');
if (link && link.getAttribute('href') !== '#') {
link.click();
}
} }
}); });
</script> </script>

39
main.py
View File

@ -22,6 +22,7 @@ app = FastAPI()
file_mapping: dict[str, str] = {} file_mapping: dict[str, str] = {}
indexers = [] indexers = []
sorted_hashes: list[str] = [] sorted_hashes: list[str] = []
navigate_enabled = False
class FileIndexer: class FileIndexer:
@ -165,7 +166,8 @@ INDEXER_MAP = {".zip": ZipFileIndexer}
def initialize_server(args: argparse.Namespace): def initialize_server(args: argparse.Namespace):
"""Initialize the server with directory or glob indexing""" """Initialize the server with directory or glob indexing"""
global file_mapping, indexers, sorted_hashes global file_mapping, indexers, sorted_hashes, navigate_enabled
navigate_enabled = args.navigate
src_path = Path(args.source) src_path = Path(args.source)
@ -373,18 +375,21 @@ def _render_page(
# Build the toggle URL (alternate view mode) # Build the toggle URL (alternate view mode)
file_path = navigation_data.get("filename", "") file_path = navigation_data.get("filename", "")
if use_navigate_urls: if navigate_enabled:
toggle_url = _build_url( if use_navigate_urls:
navigation_data["file_hash"], toggle_url = _build_url(
order=current_order, navigation_data["file_hash"],
delay=current_delay, order=current_order,
) delay=current_delay,
)
else:
toggle_url = _build_navigate_url(
file_path,
order=current_order,
delay=current_delay,
)
else: else:
toggle_url = _build_navigate_url( toggle_url = "#"
file_path,
order=current_order,
delay=current_delay,
)
content = template.substitute( content = template.substitute(
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]), img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
@ -393,7 +398,7 @@ def _render_page(
prev_url=prev_url, prev_url=prev_url,
filename=navigation_data["filename"], filename=navigation_data["filename"],
file_hash=navigation_data["file_hash"], file_hash=navigation_data["file_hash"],
file_path=file_path, file_path=file_path if navigate_enabled else "",
toggle_url=toggle_url, toggle_url=toggle_url,
extra_meta=extra_meta, extra_meta=extra_meta,
play_button=play_button, play_button=play_button,
@ -646,6 +651,9 @@ async def navigate_page(
order: Navigation order - 'next' for sequential, 'random' for random. order: Navigation order - 'next' for sequential, 'random' for random.
delay: Delay in seconds before auto-navigating to next file. delay: Delay in seconds before auto-navigating to next file.
""" """
if not navigate_enabled:
raise HTTPException(status_code=404, detail="File not found")
if order is not None and order not in ("next", "random"): if order is not None and order not in ("next", "random"):
raise HTTPException( raise HTTPException(
status_code=400, detail="Invalid order. Must be 'next' or 'random'" status_code=400, detail="Invalid order. Must be 'next' or 'random'"
@ -723,6 +731,11 @@ if __name__ == "__main__":
parser.add_argument( parser.add_argument(
"--salt", type=str, default=None, help="Salt for hashing file paths" "--salt", type=str, default=None, help="Salt for hashing file paths"
) )
parser.add_argument(
"--navigate",
action="store_true",
help="Enable path-based navigation (/navigate/{path})",
)
args = parser.parse_args() args = parser.parse_args()
initialize_server(args) initialize_server(args)

View File

@ -17,6 +17,7 @@ def _reset_state() -> None:
main.file_mapping.clear() main.file_mapping.clear()
main.indexers.clear() main.indexers.clear()
main.sorted_hashes.clear() main.sorted_hashes.clear()
main.navigate_enabled = False
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -84,6 +85,7 @@ def args_directory(sample_files: dict[str, Path], tmp_path: Path) -> argparse.Na
host="127.0.0.1", host="127.0.0.1",
port=0, port=0,
salt="test-salt", salt="test-salt",
navigate=False,
) )
@ -95,6 +97,21 @@ def args_zip(sample_zip: dict[str, Path], tmp_path: Path) -> argparse.Namespace:
host="127.0.0.1", host="127.0.0.1",
port=0, port=0,
salt="test-salt", salt="test-salt",
navigate=False,
)
@pytest.fixture
def args_zip_navigate(
sample_zip: dict[str, Path], tmp_path: Path
) -> argparse.Namespace:
"""Argparse namespace pointing at the sample zip with navigate enabled."""
return argparse.Namespace(
source=str(tmp_path / "test_archive.zip"),
host="127.0.0.1",
port=0,
salt="test-salt",
navigate=True,
) )
@ -126,6 +143,22 @@ async def client_zip(initialized_zip: None) -> Generator[AsyncClient]:
yield ac yield ac
@pytest.fixture
def initialized_zip_navigate(args_zip_navigate: argparse.Namespace) -> None:
"""Initialize the server with sample zip files and navigate enabled."""
main.initialize_server(args_zip_navigate)
@pytest.fixture
async def client_zip_navigate(
initialized_zip_navigate: None,
) -> Generator[AsyncClient]:
"""Async HTTP client against the app initialized with zip + navigate."""
transport = ASGITransport(app=main.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture @pytest.fixture
def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]: def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]:
"""Copy the pre-existing testfile.zip to a temp location. """Copy the pre-existing testfile.zip to a temp location.
@ -156,6 +189,7 @@ def args_encrypted_zip(
host="127.0.0.1", host="127.0.0.1",
port=0, port=0,
salt="test-salt", salt="test-salt",
navigate=False,
) )

View File

@ -161,13 +161,13 @@ class TestHashPage:
assert response.status_code == 404 assert response.status_code == 404
async def test_toggle_url_points_to_navigate_mode( async def test_toggle_url_points_to_navigate_mode(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Hash page toggle URL points to navigate-based URL.""" """Hash page toggle URL points to navigate-based URL."""
import re import re
file_hash = list(main.file_mapping.keys())[0] file_hash = list(main.file_mapping.keys())[0]
response = await client_zip.get(f"/{file_hash}") response = await client_zip_navigate.get(f"/{file_hash}")
match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text) match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text)
assert match is not None assert match is not None
toggle_href = match.group(1) toggle_href = match.group(1)

View File

@ -8,56 +8,62 @@ import main
class TestNavigatePage: class TestNavigatePage:
"""Tests for GET /navigate/{path}.""" """Tests for GET /navigate/{path}."""
async def test_returns_html_page(self, client_zip: AsyncClient) -> None: async def test_returns_html_page(self, client_zip_navigate: AsyncClient) -> None:
"""Returns an HTML page for a valid internal ZIP path.""" """Returns an HTML page for a valid internal ZIP path."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
assert response.status_code == 200 assert response.status_code == 200
assert "text/html" in response.headers["content-type"] assert "text/html" in response.headers["content-type"]
async def test_page_contains_image_url(self, client_zip: AsyncClient) -> None: async def test_page_contains_image_url(
self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page contains the image URL using hash-based /api endpoint.""" """HTML page contains the image URL using hash-based /api endpoint."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
# The image URL should use the hash-based /api endpoint # The image URL should use the hash-based /api endpoint
assert "/api/" in response.text assert "/api/" in response.text
assert "/data" in response.text assert "/data" in response.text
async def test_page_contains_navigate_nav_links( async def test_page_contains_navigate_nav_links(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""HTML page contains /navigate/ URLs for prev and next navigation.""" """HTML page contains /navigate/ URLs for prev and next navigation."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
assert "/navigate/" in response.text assert "/navigate/" in response.text
async def test_page_contains_prev_next_buttons( async def test_page_contains_prev_next_buttons(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""HTML page contains prev and next navigation buttons.""" """HTML page contains prev and next navigation buttons."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
assert 'class="chevron left"' in response.text assert 'class="chevron left"' in response.text
assert 'class="chevron right"' in response.text assert 'class="chevron right"' in response.text
async def test_page_contains_play_button(self, client_zip: AsyncClient) -> None: async def test_page_contains_play_button(
self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page contains play button with /navigate query param URL.""" """HTML page contains play button with /navigate query param URL."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
assert "/navigate/" in response.text assert "/navigate/" in response.text
assert "?order=next&delay=5" in response.text assert "?order=next&delay=5" in response.text
async def test_subdirectory_path(self, client_zip: AsyncClient) -> None: async def test_subdirectory_path(self, client_zip_navigate: AsyncClient) -> None:
"""Paths with subdirectories work correctly.""" """Paths with subdirectories work correctly."""
response = await client_zip.get("/navigate/folder/deep.txt") response = await client_zip_navigate.get("/navigate/folder/deep.txt")
assert response.status_code == 200 assert response.status_code == 200
assert "text/html" in response.headers["content-type"] assert "text/html" in response.headers["content-type"]
async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None: async def test_returns_404_for_invalid_path(
self, client_zip_navigate: AsyncClient
) -> None:
"""Returns 404 for a path that doesn't exist in the zip.""" """Returns 404 for a path that doesn't exist in the zip."""
response = await client_zip.get("/navigate/nonexistent.txt") response = await client_zip_navigate.get("/navigate/nonexistent.txt")
assert response.status_code == 404 assert response.status_code == 404
async def test_returns_404_for_directory_path( async def test_returns_404_for_directory_path(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Returns 404 for a directory path (not a file).""" """Returns 404 for a directory path (not a file)."""
response = await client_zip.get("/navigate/folder/") response = await client_zip_navigate.get("/navigate/folder/")
assert response.status_code == 404 assert response.status_code == 404
async def test_returns_404_on_directory_indexer( async def test_returns_404_on_directory_indexer(
@ -67,35 +73,46 @@ class TestNavigatePage:
response = await client_dir.get("/navigate/some/path.txt") response = await client_dir.get("/navigate/some/path.txt")
assert response.status_code == 404 assert response.status_code == 404
async def test_page_has_data_hash_attribute(self, client_zip: AsyncClient) -> None: async def test_returns_404_when_navigate_disabled(
"""HTML page has data-hash attribute on image for toggle support.""" self, client_zip: AsyncClient
) -> None:
"""Returns 404 when navigate flag is not enabled."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip.get("/navigate/top.txt")
assert response.status_code == 404
async def test_page_has_data_hash_attribute(
self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page has data-hash attribute on image for toggle support."""
response = await client_zip_navigate.get("/navigate/top.txt")
assert "data-hash=" in response.text assert "data-hash=" in response.text
async def test_page_has_data_path_attribute(self, client_zip: AsyncClient) -> None: async def test_page_has_data_path_attribute(
self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page has data-path attribute on image for toggle support.""" """HTML page has data-path attribute on image for toggle support."""
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
assert 'data-path="top.txt"' in response.text assert 'data-path="top.txt"' in response.text
async def test_toggle_url_points_to_hash_mode( async def test_toggle_url_points_to_hash_mode(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Navigate page toggle URL points to hash-based URL.""" """Navigate page toggle URL points to hash-based URL."""
import re import re
response = await client_zip.get("/navigate/top.txt") response = await client_zip_navigate.get("/navigate/top.txt")
match = re.search(r'id="toggle-link" href="(/[^/][^"]*?)"', response.text) match = re.search(r'id="toggle-link" href="(/[^/][^"]*?)"', response.text)
assert match is not None assert match is not None
toggle_href = match.group(1) toggle_href = match.group(1)
assert not toggle_href.startswith("/navigate/") assert not toggle_href.startswith("/navigate/")
async def test_toggle_url_preserves_query_params( async def test_toggle_url_preserves_query_params(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Navigate page toggle URL preserves order/delay query params.""" """Navigate page toggle URL preserves order/delay query params."""
import re import re
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5} "/navigate/top.txt", params={"order": "next", "delay": 5}
) )
match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text) match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text)
@ -108,41 +125,49 @@ class TestNavigatePage:
class TestNavigatePageWithRefresh: class TestNavigatePageWithRefresh:
"""Tests for GET /navigate/{path}?order=...&delay=... (auto-refresh mode).""" """Tests for GET /navigate/{path}?order=...&delay=... (auto-refresh mode)."""
async def test_next_order_returns_html(self, client_zip: AsyncClient) -> None: async def test_next_order_returns_html(
self, client_zip_navigate: AsyncClient
) -> None:
"""Next order returns HTML with refresh meta tag.""" """Next order returns HTML with refresh meta tag."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5} "/navigate/top.txt", params={"order": "next", "delay": 5}
) )
assert response.status_code == 200 assert response.status_code == 200
assert 'http-equiv="refresh"' in response.text assert 'http-equiv="refresh"' in response.text
async def test_random_order_returns_html(self, client_zip: AsyncClient) -> None: async def test_random_order_returns_html(
self, client_zip_navigate: AsyncClient
) -> None:
"""Random order returns HTML with refresh meta tag.""" """Random order returns HTML with refresh meta tag."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "random", "delay": 3} "/navigate/top.txt", params={"order": "random", "delay": 3}
) )
assert response.status_code == 200 assert response.status_code == 200
assert 'http-equiv="refresh"' in response.text assert 'http-equiv="refresh"' in response.text
async def test_invalid_order_returns_400(self, client_zip: AsyncClient) -> None: async def test_invalid_order_returns_400(
self, client_zip_navigate: AsyncClient
) -> None:
"""Invalid order parameter returns 400.""" """Invalid order parameter returns 400."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "shuffle", "delay": 5} "/navigate/top.txt", params={"order": "shuffle", "delay": 5}
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None: async def test_returns_404_for_invalid_path(
self, client_zip_navigate: AsyncClient
) -> None:
"""Returns 404 for a path that doesn't exist.""" """Returns 404 for a path that doesn't exist."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/nonexistent.txt", params={"order": "next", "delay": 5} "/navigate/nonexistent.txt", params={"order": "next", "delay": 5}
) )
assert response.status_code == 404 assert response.status_code == 404
async def test_refresh_url_uses_navigate_path( async def test_refresh_url_uses_navigate_path(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Refresh meta tag uses /navigate/ URLs with paths.""" """Refresh meta tag uses /navigate/ URLs with paths."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5} "/navigate/top.txt", params={"order": "next", "delay": 5}
) )
assert "/navigate/" in response.text assert "/navigate/" in response.text
@ -150,10 +175,10 @@ class TestNavigatePageWithRefresh:
assert "delay=5" in response.text assert "delay=5" in response.text
async def test_pause_button_uses_navigate_url( async def test_pause_button_uses_navigate_url(
self, client_zip: AsyncClient self, client_zip_navigate: AsyncClient
) -> None: ) -> None:
"""Pause button links to /navigate/{path} without query params.""" """Pause button links to /navigate/{path} without query params."""
response = await client_zip.get( response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5} "/navigate/top.txt", params={"order": "next", "delay": 5}
) )
assert '/navigate/top.txt" class="play-btn"' in response.text assert '/navigate/top.txt" class="play-btn"' in response.text

View File

@ -16,6 +16,7 @@ def seeded_indexers(sample_files: dict[str, Path], tmp_path: Path) -> None:
host="127.0.0.1", host="127.0.0.1",
port=0, port=0,
salt="nav-test-salt", salt="nav-test-salt",
navigate=False,
) )
main.initialize_server(args) main.initialize_server(args)