From 16bd651cfb7a632e2b2b4108592352a83f7be3f8 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Sun, 22 Mar 2026 22:32:43 -0400 Subject: [PATCH] Redesigned gallery view layout and added sorting tests --- AGENTS.md | 167 +++++++ CLAUDE.md | 2 + viewer/templates/gallery_view.html | 733 ++++++++++++++++++++++------- viewer/test.py | 385 +++++++++++++++ viewer/views.py | 509 ++++++++++++++------ 5 files changed, 1503 insertions(+), 293 deletions(-) create mode 100644 AGENTS.md create mode 100644 viewer/test.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..860f1d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ +# AGENTS.md + +Guidance for coding agents working in `NibasaViewer`. +This document is intentionally explicit so tasks can be completed with minimal repo discovery. + +## 1) Project Snapshot + +- Stack: Python 3 + Django 4.2. +- App layout: single app, `viewer`. +- Data model: filesystem-first gallery; images/folders are not stored in Django models. +- Frontend constraint: minimize JavaScript dependencies; Bootstrap bundled JS is allowed when required, and custom JavaScript should be avoided unless explicitly requested. +- Auth: Django built-in auth; gallery views are login-protected. +- Image flow: originals from `GALLERY_ROOT`, thumbnails cached in `THUMBNAILS_ROOT`. + +## 2) Source of Truth and Rule Files + +- Primary in-repo agent guidance exists in `CLAUDE.md`. +- `AGENTS.md` (this file) complements `CLAUDE.md` for day-to-day agent execution. +- Cursor rules search result: no `.cursorrules` and no files in `.cursor/rules/`. +- Copilot rules search result: no `.github/copilot-instructions.md` present. +- If Cursor/Copilot rule files are added later, treat them as additional required constraints. + +## 3) Environment and Setup Commands + +- Create venv: `python -m venv .venv` +- Activate venv (bash): `source .venv/bin/activate` +- Install deps: `pip install -r requirements.txt` +- Apply migrations: `python manage.py migrate` +- Run server: `python manage.py runserver` + +Local configuration (`NibasaViewer/local_settings.py`) is required for real gallery usage: + +```python +from pathlib import Path + +GALLERY_ROOT = Path('/path/to/images') +THUMBNAILS_ROOT = Path('/path/to/thumb-cache') + +DEBUG = False +ALLOWED_HOSTS = ['yourdomain.example'] +SECRET_KEY = 'replace-me' +``` + +## 4) Build / Lint / Test Commands + +There is no dedicated linter or formatter config in-repo (no `ruff`, `flake8`, `black`, `mypy`, `pyright`, `tox`, or `pytest` config files detected). + +Use these commands as the standard validation set: + +- Django system checks: `python manage.py check` +- Migration consistency check: `python manage.py makemigrations --check --dry-run` +- Python syntax sanity: `python -m compileall NibasaViewer viewer` +- Run Django tests (all): `python manage.py test` + +Single-test execution patterns (important for fast iteration): + +- Single test module: `python manage.py test viewer.test` +- Single test class: `python manage.py test viewer.test.GalleryViewTests` +- Single test method: `python manage.py test viewer.test.GalleryViewTests.test_search_action_url_uses_nested_path` + +Notes: + +- If there are currently no tests, add focused tests near the changed behavior before large refactors. +- Prefer `python manage.py test ...` style labels over introducing a second test runner. + +## 5) Operational Commands + +- Pre-generate thumbnails: `python manage.py makethumbnails` +- Create auth user (shell): + - `python manage.py shell` + - `from django.contrib.auth.models import User` + - `User.objects.create_user('', '', '')` +- Collect static files (deploy-time): `python manage.py collectstatic` + +## 6) Architecture and Change Boundaries + +- Keep gallery metadata in filesystem, not DB tables. +- Do not introduce image/folder models unless explicitly requested by humans. +- Keep URL/data flow consistent: + - `/imgs/` -> `GALLERY_ROOT` + - `/thumbs/` -> `THUMBNAILS_ROOT` + - `/gallery/` -> directory browsing and image view routing +- Preserve lazy thumbnail generation behavior unless task explicitly changes performance strategy. + +## 7) Code Style Guidelines (Repository-Specific) + +### Imports + +- Use grouped imports with section comments where practical: + - Standard library imports + - Third-party/Django imports + - Project/local imports +- Keep import order deterministic within each group. +- Follow existing style in touched file; do not mass-reformat untouched files. + +### Formatting + +- Follow existing formatting conventions in each file. +- Current codebase uses a traditional style with explicit spacing and readability comments. +- Avoid introducing unrelated formatting churn in files not functionally changed. +- Keep lines readable; wrap long imports or argument lists clearly. + +### Naming + +- Functions/variables: `snake_case`. +- Constants: `UPPER_SNAKE_CASE` (see `CELLS_PER_ROW`, `THUMB_SIZE`). +- Class names: `PascalCase`. +- URL names: descriptive snake_case names (`gallery_view_root`, `gallery_view_path`). + +### Types and Data Shapes + +- `pathlib.Path` is the preferred filesystem primitive. +- Accept `str` inputs only when ergonomics require it; normalize to `Path` early. +- Type hints are not strictly enforced across the repo; add them when they clarify non-obvious APIs. +- Do not introduce heavy typing frameworks or strict type-checking configs unless asked. + +### Error Handling + +- Catch specific exceptions when behavior should differ by failure mode. +- Use broad `except Exception` only for intentionally best-effort operations (e.g., thumbnail generation), and keep side effects minimal. +- Never swallow exceptions in critical control paths without rationale. +- User-facing 404/invalid-path behavior should remain explicit and predictable. + +### Django View and URL Practices + +- Protect gallery routes with `@login_required` unless requirement changes. +- Keep view context keys explicit and template-friendly. +- Preserve pagination constants and behavior unless task explicitly changes UX. +- Use `redirect`/`render` idioms already established in `viewer/views.py`. + +### Template / Frontend Constraints + +- Minimize JavaScript dependencies wherever possible. +- Bootstrap's own bundled JavaScript is allowed when needed for UI behaviors (offcanvas/dropdowns/tooling). +- Avoid custom project JavaScript unless explicitly requested. +- Keep HTML/CSS compatible with older browsers; prefer conservative layout features. +- Maintain dark-theme image-viewing emphasis and existing navigation metaphors. + +## 8) Performance Expectations + +- Prefer extension-based image filtering (`is_image_file`) over MIME-content probing. +- Avoid repeated heavy filesystem traversals in hot request paths when simple caching/slicing works. +- Keep thumbnail generation on-demand unless batch generation is requested. + +## 9) Safety and Config Hygiene + +- Never commit secrets or production-specific local settings. +- Keep `local_settings.py` local; defaults in `settings.py` should remain safe placeholders. +- Avoid destructive git operations unless explicitly requested. +- Do not revert unrelated working tree changes made by humans. + +## 10) Agent Workflow Checklist + +When implementing a change, follow this minimum loop: + +1. Read relevant files and preserve local conventions. +2. Make the smallest change that satisfies the request. +3. Run focused validation commands first, then broader checks. +4. Report what changed, why, and exactly which commands were run. + +Suggested pre-handoff command bundle: + +- `python manage.py check` +- `python manage.py test` (or a targeted test label) +- `python -m compileall NibasaViewer viewer` + +This repository values stability, compatibility, and straightforward Django patterns over novelty. diff --git a/CLAUDE.md b/CLAUDE.md index 77a1c11..51aeb1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Additional guidance is provided in @AGENTS.md + ## Project Overview Nibasa Viewer is a lightweight, pure HTML+CSS gallery viewer designed for compatibility with older browsers. It uses Django as a backend framework but serves images directly from the filesystem rather than using database models. diff --git a/viewer/templates/gallery_view.html b/viewer/templates/gallery_view.html index a7f1dbe..0e36454 100644 --- a/viewer/templates/gallery_view.html +++ b/viewer/templates/gallery_view.html @@ -1,176 +1,591 @@ {% load static %} - + - - - Folder: {{request.path}} - - + + + Gallery + + + + - - - - - - - - - - {% if not search %} - - {% endif %} - - - + + - - {% if num_pages > 1 %} - - {% endif %} + + +
+ + Favorites + Most visited + Recently visited + +
+ + + +
+ + + {% csrf_token %} + + + + + + +
+
+ + + + + +
+ + + + +
+ + +
+ +
+
- -
- - - - - - - - -

- {% if not search %} - {{request.path}} - Files: {{num_files}} + +

-
-
- {% for page in pages %}{{page}}{% if not forloop.last %} {% endif %}{% endfor %} +
+ +
+
+ +
-
{% csrf_token %} -
-
- - - {% if not search %} -
- - - - - - - - - - - -
-

- Search: -

-
- - - -
-
- {% endif %} - - - {% if images|length > 0 %} - - - - {% if page != 1 %} - - {% endif %} - - - - - - {% if page != num_pages %} - - {% endif %} - -
- - - - - - - {% for row in images %} - - {% for image in row %} - - {% endfor %} - - {% endfor %} - -
- - - - -
- - - - {{image.name|truncatechars:15}} - -
-
-
- - - -
- {% endif %} - - {% if subdirs|length > 0 %} - -
-

- Sub-directories -

+ +
- -
- - - {% for row in subdirs %} - - {% for subdir in row %} - - {% endfor %} - - {% endfor %} - -
- - -
- - {{subdir.name}} - -
-
-
- {% endif %} + diff --git a/viewer/test.py b/viewer/test.py new file mode 100644 index 0000000..1aeca17 --- /dev/null +++ b/viewer/test.py @@ -0,0 +1,385 @@ +# Standard library imports. +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +# External library imports. +from PIL import Image + +# Django imports. +from django.test import Client, RequestFactory, TestCase, override_settings +from django.contrib.auth.models import User + +# Project imports. +from viewer.views import SORT_LABELS, do_recursive_search, gallery_view + + +class GalleryBaseTests(TestCase): + def setUp(self): + self.tmp_gallery = tempfile.TemporaryDirectory() + self.tmp_thumbs = tempfile.TemporaryDirectory() + + self.gallery_root = Path(self.tmp_gallery.name) + self.thumb_root = Path(self.tmp_thumbs.name) + + self.settings_override = override_settings( + GALLERY_ROOT=self.gallery_root, THUMBNAILS_ROOT=self.thumb_root + ) + self.settings_override.enable() + + self.user = User.objects.create_user( + "tester", "tester@example.com", "password123" + ) + self.client = Client() + self.client.force_login(self.user) + self.factory = RequestFactory() + + self._build_fixture_tree() + + def tearDown(self): + self.settings_override.disable() + self.tmp_gallery.cleanup() + self.tmp_thumbs.cleanup() + + def _create_image(self, image_path, color): + image_path.parent.mkdir(parents=True, exist_ok=True) + img = Image.new("RGB", (300, 200), color) + img.save(image_path) + + def _set_mtime(self, file_path, timestamp): + os.utime(file_path, (timestamp, timestamp)) + + def _build_fixture_tree(self): + self._create_image(self.gallery_root / "alpha.jpg", "red") + self._create_image(self.gallery_root / "beta.jpg", "green") + self._create_image(self.gallery_root / "gamma.jpg", "blue") + self._create_image(self.gallery_root / "match-root.jpg", "yellow") + + self._set_mtime(self.gallery_root / "alpha.jpg", 1000) + self._set_mtime(self.gallery_root / "beta.jpg", 2000) + self._set_mtime(self.gallery_root / "gamma.jpg", 3000) + self._set_mtime(self.gallery_root / "match-root.jpg", 4000) + + self.sub_a = self.gallery_root / "sub_a" + self.sub_b = self.gallery_root / "sub_b" + self.empty_sub = self.gallery_root / "empty_sub" + self.nested = self.gallery_root / "nested" + + self.sub_a.mkdir(parents=True, exist_ok=True) + self.sub_b.mkdir(parents=True, exist_ok=True) + self.empty_sub.mkdir(parents=True, exist_ok=True) + self.nested.mkdir(parents=True, exist_ok=True) + + self._create_image(self.sub_a / "cover.jpg", "orange") + self._create_image(self.sub_a / "same.jpg", "purple") + self._create_image(self.sub_b / "same.jpg", "pink") + self._create_image(self.nested / "match-nested.jpg", "cyan") + + self.symlink_supported = hasattr(os, "symlink") + self.symlink_dir = self.gallery_root / "symlink_sub" + self.symlink_file = self.gallery_root / "symlink_file.jpg" + + if self.symlink_supported: + try: + os.symlink(self.sub_a, self.symlink_dir) + os.symlink(self.gallery_root / "alpha.jpg", self.symlink_file) + except OSError: + self.symlink_supported = False + + +class GalleryViewTests(GalleryBaseTests): + def test_gallery_rejects_out_of_root_path(self): + request = self.factory.get("/gallery/../") + request.user = self.user + + response = gallery_view(request, "../") + self.assertEqual(response.status_code, 404) + + def test_gallery_missing_path_returns_404(self): + response = self.client.get("/gallery/does-not-exist/") + self.assertEqual(response.status_code, 404) + + def test_invalid_sort_defaults_to_abc(self): + response = self.client.get("/gallery/?sort=invalid") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["sort_key"], "abc") + + def test_invalid_theme_defaults_to_dark(self): + response = self.client.get("/gallery/?theme=invalid") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["theme"], "dark") + + def test_root_path_context_is_empty_string(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["path"], "") + + def test_context_has_no_pagination_keys(self): + response = self.client.get("/gallery/") + context = response.context + + self.assertNotIn("page", context) + self.assertNotIn("pages", context) + self.assertNotIn("num_pages", context) + self.assertNotIn("prev_page", context) + self.assertNotIn("next_page", context) + + def test_sort_accepts_all_required_keys(self): + for sort_key in ["abc", "cba", "old", "new", "recent", "tnecer"]: + response = self.client.get("/gallery/?sort=" + sort_key) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["sort_key"], sort_key) + + def test_sort_labels_match_required_text(self): + response = self.client.get("/gallery/?sort=recent") + self.assertEqual(response.status_code, 200) + + options = { + item["key"]: item["label"] for item in response.context["sort_options"] + } + self.assertEqual(options, SORT_LABELS) + self.assertEqual(response.context["sort_label"], SORT_LABELS["recent"]) + + def test_sort_tie_breaker_filename_then_relative_path(self): + response = self.client.get("/gallery/?search=same&sort=abc") + self.assertEqual(response.status_code, 200) + + image_paths = [item["path"] for item in response.context["images"]] + self.assertEqual(len(image_paths), 2) + self.assertTrue(image_paths[0].find("/gallery/sub_a/same.jpg") != -1) + self.assertTrue(image_paths[1].find("/gallery/sub_b/same.jpg") != -1) + + def test_sort_options_shape_and_is_active(self): + response = self.client.get("/gallery/?sort=cba&theme=light") + self.assertEqual(response.status_code, 200) + + options = response.context["sort_options"] + self.assertEqual(len(options), 6) + + for option in options: + self.assertIn("key", option) + self.assertIn("label", option) + self.assertIn("url", option) + self.assertIn("is_active", option) + + active = [option for option in options if option["is_active"]] + self.assertEqual(len(active), 1) + self.assertEqual(active[0]["key"], "cba") + + def test_breadcrumb_links_preserve_query(self): + response = self.client.get( + "/gallery/sub_a/?search=match&sort=recent&theme=light" + ) + self.assertEqual(response.status_code, 200) + + breadcrumbs = response.context["breadcrumbs"] + self.assertTrue(breadcrumbs[0]["path"].find("/gallery/?") == 0) + self.assertIn("search=match", breadcrumbs[0]["path"]) + self.assertIn("sort=recent", breadcrumbs[0]["path"]) + self.assertIn("theme=light", breadcrumbs[0]["path"]) + + def test_subdir_links_preserve_query(self): + response = self.client.get("/gallery/?search=match&sort=recent&theme=light") + self.assertEqual(response.status_code, 200) + + subdir_paths = [subdir["path"] for subdir in response.context["subdirs"]] + self.assertTrue(any(path.find("search=match") != -1 for path in subdir_paths)) + self.assertTrue(any(path.find("sort=recent") != -1 for path in subdir_paths)) + self.assertTrue(any(path.find("theme=light") != -1 for path in subdir_paths)) + + def test_image_links_preserve_query(self): + response = self.client.get("/gallery/?search=match&sort=recent&theme=light") + self.assertEqual(response.status_code, 200) + + image_paths = [image["path"] for image in response.context["images"]] + self.assertTrue(any(path.find("search=match") != -1 for path in image_paths)) + self.assertTrue(any(path.find("sort=recent") != -1 for path in image_paths)) + self.assertTrue(any(path.find("theme=light") != -1 for path in image_paths)) + + def test_theme_toggle_url_preserves_query(self): + response = self.client.get("/gallery/?search=match&sort=recent&theme=dark") + self.assertEqual(response.status_code, 200) + + url = response.context["theme_toggle_url"] + self.assertIn("search=match", url) + self.assertIn("sort=recent", url) + self.assertIn("theme=light", url) + + def test_search_action_url_preserves_query(self): + response = self.client.get("/gallery/?sort=recent&theme=light") + self.assertEqual(response.status_code, 200) + + url = response.context["search_action_url"] + self.assertTrue(url.find("/gallery/?") == 0) + self.assertIn("sort=recent", url) + self.assertIn("theme=light", url) + + def test_search_action_url_uses_nested_path(self): + response = self.client.get("/gallery/sub_a/?sort=recent&theme=light") + self.assertEqual(response.status_code, 200) + + url = response.context["search_action_url"] + self.assertTrue(url.find("/gallery/sub_a/?") == 0) + + def test_subdirs_use_immediate_children_only(self): + response = self.client.get("/gallery/?search=match") + self.assertEqual(response.status_code, 200) + + names = [subdir["name"] for subdir in response.context["subdirs"]] + self.assertIn("nested", names) + self.assertNotIn("match-nested.jpg", names) + + def test_subdir_order_is_deterministic(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + names = [subdir["name"] for subdir in response.context["subdirs"]] + self.assertEqual(names, sorted(names, key=lambda item: item.lower())) + + def test_subdir_without_images_uses_fallback(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + empty_sub = [ + subdir + for subdir in response.context["subdirs"] + if subdir["name"] == "empty_sub" + ][0] + self.assertIsNone(empty_sub["thumbnail"]) + + def test_symlink_entries_ignored_in_gallery(self): + if not self.symlink_supported: + self.skipTest("Symlinks not supported in this environment") + + response = self.client.get("/gallery/?search=symlink") + self.assertEqual(response.status_code, 200) + + names = [image["name"] for image in response.context["images"]] + self.assertNotIn("symlink_file.jpg", names) + + def test_recursive_search_ignores_symlink_directories(self): + if not self.symlink_supported: + self.skipTest("Symlinks not supported in this environment") + + subdirs, images = do_recursive_search(self.gallery_root, "cover") + subdir_names = [subdir.name for subdir in subdirs] + image_names = [image.name for image in images] + + self.assertNotIn("symlink_sub", subdir_names) + self.assertEqual(image_names.count("cover.jpg"), 1) + + def test_sidebar_subdirs_ignore_symlink_dirs(self): + if not self.symlink_supported: + self.skipTest("Symlinks not supported in this environment") + + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + names = [subdir["name"] for subdir in response.context["subdirs"]] + self.assertNotIn("symlink_sub", names) + + def test_thumbnail_failure_does_not_break_render(self): + with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")): + response = self.client.get("/gallery/") + + self.assertEqual(response.status_code, 200) + self.assertIn("images", response.context) + self.assertIn("subdirs", response.context) + + def test_login_required_for_gallery(self): + anon = Client() + response = anon.get("/gallery/") + self.assertEqual(response.status_code, 302) + + def test_image_view_still_renders_prev_next(self): + response = self.client.get("/gallery/beta.jpg/", follow=True) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.context["image_path"].name, "beta.jpg") + self.assertEqual(response.context["prev"], "alpha.jpg") + self.assertEqual(response.context["next"], "gamma.jpg") + + def test_sort_modes_recent_and_tnecer_use_mtime(self): + recent = self.client.get("/gallery/?sort=recent") + least_recent = self.client.get("/gallery/?sort=tnecer") + + self.assertEqual(recent.status_code, 200) + self.assertEqual(least_recent.status_code, 200) + + self.assertEqual(recent.context["images"][0]["name"], "match-root.jpg") + self.assertEqual(least_recent.context["images"][0]["name"], "alpha.jpg") + + def test_sort_modes_old_and_new_use_creation_timestamp(self): + creation_values = { + "alpha.jpg": 30, + "beta.jpg": 10, + "gamma.jpg": 20, + "match-root.jpg": 40, + } + + def fake_creation(path_obj): + return creation_values.get(path_obj.name, 0) + + with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation): + old = self.client.get("/gallery/?sort=old") + new = self.client.get("/gallery/?sort=new") + + self.assertEqual(old.status_code, 200) + self.assertEqual(new.status_code, 200) + + self.assertEqual(old.context["images"][0]["name"], "beta.jpg") + self.assertEqual(new.context["images"][0]["name"], "match-root.jpg") + + +class GalleryTemplateTests(GalleryBaseTests): + def test_template_includes_bootstrap_and_fontawesome(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn("bootstrap@5", body) + self.assertIn("font-awesome/7.0.0", body) + + def test_template_has_sidebar_controls_and_mobile_buttons(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn("fa-circle-user", body) + self.assertIn("fa-bars", body) + self.assertIn("fa-xmark", body) + self.assertIn("fa-arrow-up-from-bracket", body) + self.assertIn("Favorites", body) + self.assertIn("Most visited", body) + self.assertIn("Recently visited", body) + + def test_template_shows_fallback_icon_for_empty_subdir(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn("fa-solid fa-image", body) + + def test_search_placeholder_contrast_in_dark_theme(self): + response = self.client.get("/gallery/?theme=dark") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn(".search-input::placeholder", body) + self.assertIn("color: var(--muted);", body) + + def test_sort_dropdown_active_option_underlined(self): + response = self.client.get("/gallery/?sort=recent") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn("active-sort", body) + self.assertIn("Modification date most recent", body) + + def test_template_has_requested_drop_shadows(self): + response = self.client.get("/gallery/") + self.assertEqual(response.status_code, 200) + + body = response.content.decode("utf-8") + self.assertIn("box-shadow: 0 5px 20px", body) diff --git a/viewer/views.py b/viewer/views.py index 75b2770..26b69a3 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -1,53 +1,270 @@ # Standard library imports. from pathlib import Path -from math import ceil +from urllib.parse import urlencode +from functools import cmp_to_key # Django imports. -from django.http import HttpResponseNotFound -from django.conf import settings -from django.utils.http import urlencode +from django.http import HttpResponseNotFound +from django.conf import settings from django.contrib.auth.decorators import login_required -from django.shortcuts import (render, - redirect) +from django.shortcuts import render, redirect # Project imports. from .utils import make_thumbnail, is_image_file ########################################################################################### -# CONSTANTS. # +# Constants. # ########################################################################################### -CELLS_PER_ROW = 7 -ROWS_PER_PAGE = 4 -IMAGES_PER_PAGE = CELLS_PER_ROW * ROWS_PER_PAGE +SORT_OPTIONS = [ + ("abc", "Alphabetical A-Z"), + ("cba", "Alphabetical Z-A"), + ("old", "Creation date old to new"), + ("new", "Creation date new to old"), + ("recent", "Modification date most recent"), + ("tnecer", "Modification date least recent"), +] + +SORT_LABELS = dict(SORT_OPTIONS) ########################################################################################### # Helper functions. # ########################################################################################### +def normalize_sort(sort_value): + return sort_value if sort_value in SORT_LABELS else "abc" + + +def normalize_theme(theme_value): + return theme_value if theme_value in ("dark", "light") else "dark" + + +def get_creation_timestamp(path_obj): + try: + stat_data = path_obj.stat() + return getattr(stat_data, "st_birthtime", stat_data.st_ctime) + except OSError: + return 0 + + +def get_modification_timestamp(path_obj): + try: + return path_obj.stat().st_mtime + except OSError: + return 0 + + +def build_query(search_text, sort_key, theme): + query = {"sort": sort_key, "theme": theme} + + if search_text != "": + query["search"] = search_text + + return query + + +def append_query(url, query_dict): + if len(query_dict) == 0: + return url + + return url + "?" + urlencode(query_dict) + + +def gallery_url(path_obj=None, is_dir=False, query_dict=None): + if query_dict is None: + query_dict = {} + + if path_obj is None: + base_url = "/gallery/" + else: + path_text = str(path_obj).replace("\\", "/") + base_url = "/gallery/" + path_text + + if is_dir and not base_url.endswith("/"): + base_url += "/" + + return append_query(base_url, query_dict) + + def do_recursive_search(start_path, query): """ Gets all images and sub-directories inside the start_path whose name matches the given query, and then joins the results recursively by iterating in each sub-directory. """ - # Get all sub-dirs and images that match the query. - all_subdirs = sorted([i for i in start_path.iterdir() if i.is_dir()]) - subdirs = sorted([i for i in all_subdirs if query.lower() in i.name.lower()]) - images = sorted([i for i in start_path.iterdir() if i.is_file() and query.lower() in i.name.lower()]) + try: + entries = [entry for entry in start_path.iterdir() if not entry.is_symlink()] + except OSError: + return [], [] + + all_subdirs = sorted( + [entry for entry in entries if entry.is_dir()], + key=lambda item: (item.name.lower(), str(item).lower()), + ) + + subdirs = [entry for entry in all_subdirs if query.lower() in entry.name.lower()] + + images = sorted( + [ + entry + for entry in entries + if entry.is_file() + and is_image_file(entry) + and query.lower() in entry.name.lower() + ], + key=lambda item: (item.name.lower(), str(item).lower()), + ) - # For all sub-directories, regardless of the query. for subdir in all_subdirs: - # Do a recursive search. rec_subdirs, rec_images = do_recursive_search(subdir, query.lower()) - - # Join the results if any. subdirs.extend(rec_subdirs) images.extend(rec_images) return subdirs, images + +def sort_images(images, sort_key): + def compare(img_a, img_b): + name_a = img_a.name.lower() + name_b = img_b.name.lower() + rel_a = str(img_a.relative_to(settings.GALLERY_ROOT)).lower() + rel_b = str(img_b.relative_to(settings.GALLERY_ROOT)).lower() + + if sort_key == "abc": + if name_a < name_b: + return -1 + if name_a > name_b: + return 1 + + elif sort_key == "cba": + if name_a > name_b: + return -1 + if name_a < name_b: + return 1 + + elif sort_key == "old": + created_a = get_creation_timestamp(img_a) + created_b = get_creation_timestamp(img_b) + + if created_a < created_b: + return -1 + if created_a > created_b: + return 1 + + elif sort_key == "new": + created_a = get_creation_timestamp(img_a) + created_b = get_creation_timestamp(img_b) + + if created_a > created_b: + return -1 + if created_a < created_b: + return 1 + + elif sort_key == "recent": + modified_a = get_modification_timestamp(img_a) + modified_b = get_modification_timestamp(img_b) + + if modified_a > modified_b: + return -1 + if modified_a < modified_b: + return 1 + + elif sort_key == "tnecer": + modified_a = get_modification_timestamp(img_a) + modified_b = get_modification_timestamp(img_b) + + if modified_a < modified_b: + return -1 + if modified_a > modified_b: + return 1 + + if name_a < name_b: + return -1 + if name_a > name_b: + return 1 + if rel_a < rel_b: + return -1 + if rel_a > rel_b: + return 1 + return 0 + + return sorted(images, key=cmp_to_key(compare)) + + +def build_breadcrumbs(path_text, query_dict): + breadcrumbs = [{"label": "Gallery", "path": gallery_url(None, True, query_dict)}] + + if path_text == "": + return breadcrumbs + + segments = Path(path_text).parts + current = Path("") + + for segment in segments: + current = current.joinpath(segment) + breadcrumbs.append( + {"label": segment, "path": gallery_url(current, True, query_dict)} + ) + + return breadcrumbs + + +def build_sort_options(request, search_text, sort_key, theme): + options = [] + + for option_key, label in SORT_OPTIONS: + query = {"sort": option_key, "theme": theme} + + if search_text != "": + query["search"] = search_text + + options.append( + { + "key": option_key, + "label": label, + "url": append_query(request.path, query), + "is_active": option_key == sort_key, + } + ) + + return options + + +def get_first_image_thumbnail_url(subdir): + try: + images = sorted( + [ + entry + for entry in subdir.iterdir() + if entry.is_file() and not entry.is_symlink() and is_image_file(entry) + ], + key=lambda item: ( + item.name.lower(), + str(item.relative_to(settings.GALLERY_ROOT)).lower(), + ), + ) + except OSError: + return None + + if len(images) == 0: + return None + + first_image = images[0] + + try: + make_thumbnail(first_image) + rel_path = first_image.relative_to(settings.GALLERY_ROOT) + thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path) + + if thumb_path.exists(): + return "/thumbs/" + str(rel_path).replace("\\", "/") + except Exception: + pass + + return None + + ########################################################################################### # View functions. # ########################################################################################### @@ -55,132 +272,156 @@ def do_recursive_search(start_path, query): @login_required def index(request): - return redirect('gallery_view_root') + return redirect("gallery_view_root") + + +def _render_directory(request, path_text, full_path): + """ + Renders the gallery view related to directories, be it the contents of an actual directory + in the file system, or logical gallery directories like search result pages. + """ + + search_text = request.GET.get("search", "").strip() + sort_key = normalize_sort(request.GET.get("sort", "abc")) + theme = normalize_theme(request.GET.get("theme", "dark")) + query_state = build_query(search_text, sort_key, theme) + + try: + current_entries = [ + entry for entry in full_path.iterdir() if not entry.is_symlink() + ] + except OSError: + return HttpResponseNotFound("Not found") + + current_subdirs = sorted( + [entry for entry in current_entries if entry.is_dir()], + key=lambda item: ( + item.name.lower(), + str(item.relative_to(settings.GALLERY_ROOT)).lower(), + ), + ) + + if search_text == "": + images = [ + entry + for entry in current_entries + if entry.is_file() and is_image_file(entry) + ] + else: + _, images = do_recursive_search(full_path, search_text) + + images = sort_images(images, sort_key) + + image_data = [] + for image in images: + rel_path = image.relative_to(settings.GALLERY_ROOT) + image_url = gallery_url(rel_path, False, query_state) + + thumbnail = None + try: + make_thumbnail(image) + thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel_path) + if thumb_path.exists(): + thumbnail = "/thumbs/" + str(rel_path).replace("\\", "/") + except Exception: + pass + + image_data.append( + {"path": image_url, "name": image.name, "thumbnail": thumbnail} + ) + + subdir_data = [] + for subdir in current_subdirs: + rel_path = subdir.relative_to(settings.GALLERY_ROOT) + subdir_data.append( + { + "path": gallery_url(rel_path, True, query_state), + "name": subdir.name, + "thumbnail": get_first_image_thumbnail_url(subdir), + } + ) + + next_theme = "light" if theme == "dark" else "dark" + theme_query = {"sort": sort_key, "theme": next_theme} + + if search_text != "": + theme_query["search"] = search_text + + search_action_query = {"sort": sort_key, "theme": theme} + + context = { + "path": path_text, + "search_text": search_text, + "theme": theme, + "sort_key": sort_key, + "sort_label": SORT_LABELS[sort_key], + "sort_options": build_sort_options(request, search_text, sort_key, theme), + "breadcrumbs": build_breadcrumbs(path_text, query_state), + "images": image_data, + "subdirs": subdir_data, + "theme_toggle_url": append_query(request.path, theme_query), + "search_action_url": append_query(request.path, search_action_query), + } + + return render(request, "gallery_view.html", context) + + +def _render_image(request, path_text, full_path): + """ + Renders the view corresponding to an image file. + """ + + image = Path("/imgs/").joinpath(path_text) + img_dir = full_path.parent + + try: + siblings = [ + entry.name + for entry in img_dir.iterdir() + if entry.is_file() and not entry.is_symlink() and is_image_file(entry) + ] + except OSError: + return HttpResponseNotFound("Not found") + + images = sorted(siblings, key=lambda item: item.lower()) + + if image.name not in images: + return HttpResponseNotFound("Not found") + + index = images.index(image.name) + previous = index - 1 if index > 0 else None + following = index + 1 if index < len(images) - 1 else None + + context = { + "image_path": image, + "prev": images[previous] if previous is not None else None, + "next": images[following] if following is not None else None, + } + + return render(request, "image_view.html", context) @login_required -def gallery_view(request, path = None): +def gallery_view(request, path=None): """ Shows a list of subdirectories and image files inside the given path. The path should be inside the GALLERY_ROOT path, otherwise a 404 error will be thrown. """ - def list2rows(lst, cells = CELLS_PER_ROW): - """ - Converts a list into a matrix with the given amount of cells per row. - """ + root = settings.GALLERY_ROOT.resolve() - rows = [] - i = 0 - while i < len(lst): - row = [] - for c in range(cells): - try: - row.append(lst[i + c]) - except Exception: - pass - rows.append(row) - i += cells - return rows + try: + candidate = root.joinpath(path).resolve() if path is not None else root + candidate.relative_to(root) + except Exception: + return HttpResponseNotFound("Not found") - # Get the absolute path to show if any, else show the base gallery path. - full_path = settings.GALLERY_ROOT.joinpath(path) if path is not None else settings.GALLERY_ROOT + if not candidate.exists(): + return HttpResponseNotFound("Not found") - # Get the search query from the request, if any. - search = request.GET.get('search', default = '') + path_text = path if path is not None else "" - if full_path.exists(): - # If the path exists then check if it points to a directory or an image. - if full_path.is_dir(): - if search == '': - # If there is no search query then get all images and sub-directories of the current path. - subdirs = sorted([i for i in full_path.iterdir() if i.is_dir()]) - images = sorted([i for i in full_path.iterdir() if i.is_file() and is_image_file(i)]) + if candidate.is_dir(): + return _render_directory(request, path_text, candidate) - else: - # If there is a search query then search the current directory recursively. - subdirs, images = do_recursive_search(full_path, search) - - # Only keep image files. - images = [image for image in images if is_image_file(image)] - - # For every sub-directory found, prepare it's name and path for rendering. - subdir_data = [] - for subdir in subdirs: - subdir_data.append({ - 'path': '/gallery/' + str(subdir.relative_to(settings.GALLERY_ROOT)), - 'name': subdir.name - }) - - # Get the page number to show, if any. Default to the first one if unavailable or error. - try: - page = int(request.GET.get('page', 1)) - except ValueError: - page = 1 - - # Compute the page offset to show. - num_pages = ceil((len(images) / CELLS_PER_ROW) / ROWS_PER_PAGE) - page_offset = IMAGES_PER_PAGE * (page - 1) - - # Slice the images found with the page offset and prepare them for rendering. - img_data = [] - for image in images[page_offset : page_offset + IMAGES_PER_PAGE]: - # Create thumbnails as needed. - make_thumbnail(image) - - # Get the path of the image relative to the gallery root. - rel_path = image.relative_to(settings.GALLERY_ROOT) - - # For each image, prepare it's path, thumbnail path and name for rendering. - img_data.append({ - 'path': '/gallery/' + str(rel_path), - 'name': image.name, - 'thumbnail': '/thumbs/' + str(rel_path) - }) - - # Rendering context. - context = { - 'path': path, - 'subdirs': list2rows(subdir_data), - 'images': list2rows(img_data), - 'pages': range(1, num_pages + 1), - 'num_files': len(images), - 'page': page, - 'prev_page': page - 1, - 'next_page': page + 1, - 'num_pages': num_pages, - 'search': urlencode({'search': search}) if search != '' else None, - 'search_text': search - } - - # Glorious success! - return render(request, 'gallery_view.html', context) - - else: - # If the path points to an image, then get it's real path, and parent directory path. - image = Path('/imgs/').joinpath(path) - img_dir = settings.GALLERY_ROOT.joinpath(path).parent - - # Then get all sibling images. - images = sorted([i.name for i in img_dir.iterdir() if i.is_file() and is_image_file(i)]) - - # Get the current image's index in it's directory. - index = images.index(image.name) - - # Get the previous and next image indices. - previous = index - 1 if index > 0 else None - following = index + 1 if index < len(images) - 1 else None - - # Set the current, previous and next images as the rendering context. - context = { - 'image_path': image, - 'prev': images[previous] if previous is not None else None, - 'next': images[following] if following is not None else None - } - - # Glorious success! - return render(request, 'image_view.html', context) - - else: - # 404 if the path wasn't found. - return HttpResponseNotFound('Not found') + return _render_image(request, path_text, candidate)