Make navigation toggleable
This commit is contained in:
parent
e6a833f6f5
commit
7619f63191
@ -105,7 +105,10 @@
|
||||
window.location.href = buildUrl(params.hash, newOrder, params.delay);
|
||||
}
|
||||
} 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>
|
||||
|
||||
39
main.py
39
main.py
@ -22,6 +22,7 @@ app = FastAPI()
|
||||
file_mapping: dict[str, str] = {}
|
||||
indexers = []
|
||||
sorted_hashes: list[str] = []
|
||||
navigate_enabled = False
|
||||
|
||||
|
||||
class FileIndexer:
|
||||
@ -165,7 +166,8 @@ INDEXER_MAP = {".zip": ZipFileIndexer}
|
||||
|
||||
def initialize_server(args: argparse.Namespace):
|
||||
"""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)
|
||||
|
||||
@ -373,18 +375,21 @@ def _render_page(
|
||||
|
||||
# Build the toggle URL (alternate view mode)
|
||||
file_path = navigation_data.get("filename", "")
|
||||
if use_navigate_urls:
|
||||
toggle_url = _build_url(
|
||||
navigation_data["file_hash"],
|
||||
order=current_order,
|
||||
delay=current_delay,
|
||||
)
|
||||
if navigate_enabled:
|
||||
if use_navigate_urls:
|
||||
toggle_url = _build_url(
|
||||
navigation_data["file_hash"],
|
||||
order=current_order,
|
||||
delay=current_delay,
|
||||
)
|
||||
else:
|
||||
toggle_url = _build_navigate_url(
|
||||
file_path,
|
||||
order=current_order,
|
||||
delay=current_delay,
|
||||
)
|
||||
else:
|
||||
toggle_url = _build_navigate_url(
|
||||
file_path,
|
||||
order=current_order,
|
||||
delay=current_delay,
|
||||
)
|
||||
toggle_url = "#"
|
||||
|
||||
content = template.substitute(
|
||||
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
|
||||
@ -393,7 +398,7 @@ def _render_page(
|
||||
prev_url=prev_url,
|
||||
filename=navigation_data["filename"],
|
||||
file_hash=navigation_data["file_hash"],
|
||||
file_path=file_path,
|
||||
file_path=file_path if navigate_enabled else "",
|
||||
toggle_url=toggle_url,
|
||||
extra_meta=extra_meta,
|
||||
play_button=play_button,
|
||||
@ -646,6 +651,9 @@ async def navigate_page(
|
||||
order: Navigation order - 'next' for sequential, 'random' for random.
|
||||
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"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
|
||||
@ -723,6 +731,11 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
initialize_server(args)
|
||||
|
||||
@ -17,6 +17,7 @@ def _reset_state() -> None:
|
||||
main.file_mapping.clear()
|
||||
main.indexers.clear()
|
||||
main.sorted_hashes.clear()
|
||||
main.navigate_enabled = False
|
||||
|
||||
|
||||
@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",
|
||||
port=0,
|
||||
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",
|
||||
port=0,
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]:
|
||||
"""Copy the pre-existing testfile.zip to a temp location.
|
||||
@ -156,6 +189,7 @@ def args_encrypted_zip(
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
salt="test-salt",
|
||||
navigate=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -161,13 +161,13 @@ class TestHashPage:
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_toggle_url_points_to_navigate_mode(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""Hash page toggle URL points to navigate-based URL."""
|
||||
import re
|
||||
|
||||
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)
|
||||
assert match is not None
|
||||
toggle_href = match.group(1)
|
||||
|
||||
@ -8,56 +8,62 @@ import main
|
||||
class TestNavigatePage:
|
||||
"""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."""
|
||||
response = await client_zip.get("/navigate/top.txt")
|
||||
response = await client_zip_navigate.get("/navigate/top.txt")
|
||||
assert response.status_code == 200
|
||||
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."""
|
||||
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
|
||||
assert "/api/" in response.text
|
||||
assert "/data" in response.text
|
||||
|
||||
async def test_page_contains_navigate_nav_links(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""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
|
||||
|
||||
async def test_page_contains_prev_next_buttons(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""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 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."""
|
||||
response = await client_zip.get("/navigate/top.txt")
|
||||
response = await client_zip_navigate.get("/navigate/top.txt")
|
||||
assert "/navigate/" 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."""
|
||||
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 "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."""
|
||||
response = await client_zip.get("/navigate/nonexistent.txt")
|
||||
response = await client_zip_navigate.get("/navigate/nonexistent.txt")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_returns_404_for_directory_path(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""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
|
||||
|
||||
async def test_returns_404_on_directory_indexer(
|
||||
@ -67,35 +73,46 @@ class TestNavigatePage:
|
||||
response = await client_dir.get("/navigate/some/path.txt")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_page_has_data_hash_attribute(self, client_zip: AsyncClient) -> None:
|
||||
"""HTML page has data-hash attribute on image for toggle support."""
|
||||
async def test_returns_404_when_navigate_disabled(
|
||||
self, client_zip: AsyncClient
|
||||
) -> None:
|
||||
"""Returns 404 when navigate flag is not enabled."""
|
||||
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
|
||||
|
||||
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."""
|
||||
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
|
||||
|
||||
async def test_toggle_url_points_to_hash_mode(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""Navigate page toggle URL points to hash-based URL."""
|
||||
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)
|
||||
assert match is not None
|
||||
toggle_href = match.group(1)
|
||||
assert not toggle_href.startswith("/navigate/")
|
||||
|
||||
async def test_toggle_url_preserves_query_params(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""Navigate page toggle URL preserves order/delay query params."""
|
||||
import re
|
||||
|
||||
response = await client_zip.get(
|
||||
response = await client_zip_navigate.get(
|
||||
"/navigate/top.txt", params={"order": "next", "delay": 5}
|
||||
)
|
||||
match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text)
|
||||
@ -108,41 +125,49 @@ class TestNavigatePage:
|
||||
class TestNavigatePageWithRefresh:
|
||||
"""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."""
|
||||
response = await client_zip.get(
|
||||
response = await client_zip_navigate.get(
|
||||
"/navigate/top.txt", params={"order": "next", "delay": 5}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
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."""
|
||||
response = await client_zip.get(
|
||||
response = await client_zip_navigate.get(
|
||||
"/navigate/top.txt", params={"order": "random", "delay": 3}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
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."""
|
||||
response = await client_zip.get(
|
||||
response = await client_zip_navigate.get(
|
||||
"/navigate/top.txt", params={"order": "shuffle", "delay": 5}
|
||||
)
|
||||
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."""
|
||||
response = await client_zip.get(
|
||||
response = await client_zip_navigate.get(
|
||||
"/navigate/nonexistent.txt", params={"order": "next", "delay": 5}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_refresh_url_uses_navigate_path(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""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}
|
||||
)
|
||||
assert "/navigate/" in response.text
|
||||
@ -150,10 +175,10 @@ class TestNavigatePageWithRefresh:
|
||||
assert "delay=5" in response.text
|
||||
|
||||
async def test_pause_button_uses_navigate_url(
|
||||
self, client_zip: AsyncClient
|
||||
self, client_zip_navigate: AsyncClient
|
||||
) -> None:
|
||||
"""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}
|
||||
)
|
||||
assert '/navigate/top.txt" class="play-btn"' in response.text
|
||||
|
||||
@ -16,6 +16,7 @@ def seeded_indexers(sample_files: dict[str, Path], tmp_path: Path) -> None:
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
salt="nav-test-salt",
|
||||
navigate=False,
|
||||
)
|
||||
main.initialize_server(args)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user