From 3e209519e597f38b1fd4b7d08e22332a91a56dcb Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 23 Mar 2026 23:48:23 -0400 Subject: [PATCH] refactor: split directory and image rendering into viewer.directory, viewer.image; add viewer.common for shared helpers --- viewer/common.py | 221 ++++++++++++++++++ viewer/directory.py | 163 ++++++++++++++ viewer/image.py | 195 ++++++++++++++++ viewer/test.py | 8 +- viewer/views.py | 530 +------------------------------------------- 5 files changed, 595 insertions(+), 522 deletions(-) create mode 100644 viewer/common.py create mode 100644 viewer/directory.py create mode 100644 viewer/image.py diff --git a/viewer/common.py b/viewer/common.py new file mode 100644 index 0000000..b2a57b9 --- /dev/null +++ b/viewer/common.py @@ -0,0 +1,221 @@ +# Standard library imports. +from pathlib import Path +from urllib.parse import urlencode +from functools import cmp_to_key + +# Django imports. +from django.conf import settings + +# Project imports. +from .utils import make_thumbnail, is_image_file + +########################################################################################### +# Constants. # +########################################################################################### + +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) + + +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 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 diff --git a/viewer/directory.py b/viewer/directory.py new file mode 100644 index 0000000..7bdd841 --- /dev/null +++ b/viewer/directory.py @@ -0,0 +1,163 @@ +""" +Directory-related rendering helpers for the gallery view. +""" + +# Standard library imports. +from pathlib import Path + +# Django imports. +from django.http import HttpResponseNotFound +from django.shortcuts import render +from django.conf import settings + +# Project imports. +from .common import ( + normalize_sort, + normalize_theme, + build_query, + gallery_url, + sort_images, + build_sort_options, + build_breadcrumbs, + get_first_image_thumbnail_url, + is_image_file, + make_thumbnail, +) + + +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. + """ + + 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 subdir in all_subdirs: + rec_subdirs, rec_images = do_recursive_search(subdir, query.lower()) + subdirs.extend(rec_subdirs) + images.extend(rec_images) + + return subdirs, images + + +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: + # use shared make_thumbnail so tests can patch viewer.common.make_thumbnail + 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_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": gallery_url( + Path(path_text) if path_text != "" else None, True, theme_query + ), + "search_action_url": gallery_url( + Path(path_text) if path_text != "" else None, True, search_action_query + ), + } + + # sort_label depends on SORT_LABELS in common; import lazily to avoid circulars + from .common import SORT_LABELS + + context["sort_label"] = SORT_LABELS.get(sort_key, "") + + return render(request, "gallery_view.html", context) diff --git a/viewer/image.py b/viewer/image.py new file mode 100644 index 0000000..c7c3085 --- /dev/null +++ b/viewer/image.py @@ -0,0 +1,195 @@ +""" +Image-related rendering helpers for the gallery image view. +""" + +# Standard library imports. +from pathlib import Path +import datetime + +# Django imports. +from django.http import HttpResponseNotFound +from django.shortcuts import render +from django.conf import settings + +# Third-party +from PIL import Image + +# Project imports. +from .common import ( + normalize_sort, + normalize_theme, + build_query, + gallery_url, + sort_images, + build_sort_options, + build_breadcrumbs, + get_first_image_thumbnail_url, + is_image_file, + make_thumbnail, +) + + +def render_image(request, path_text, full_path): + """ + Renders the view corresponding to an image file. + """ + + # Preserve query state (sort, search, theme) similar to gallery view + 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) + + image = Path("/imgs/").joinpath(path_text) + img_dir = full_path.parent + + try: + entries = [ + entry + 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") + + # Sort siblings according to requested sort mode + images_sorted = sort_images(entries, sort_key) + + # Find index of current image + try: + index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name) + except StopIteration: + return HttpResponseNotFound("Not found") + + prev_path = images_sorted[index - 1] if index > 0 else None + next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None + + # Helper to produce thumb url for a Path or None + def thumb_for(path_obj): + if path_obj is None: + return None + try: + make_thumbnail(path_obj) + rel = path_obj.relative_to(settings.GALLERY_ROOT) + thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel) + if thumb_path.exists(): + return "/thumbs/" + str(rel).replace("\\", "/") + except Exception: + pass + return None + + # Build URLs (preserving query state) + prev_url = None + next_url = None + if prev_path is not None: + rel = prev_path.relative_to(settings.GALLERY_ROOT) + prev_url = gallery_url(rel, False, query_state) + + if next_path is not None: + rel = next_path.relative_to(settings.GALLERY_ROOT) + next_url = gallery_url(rel, False, query_state) + + # Back (directory) and Home (root) links and thumbnails + dir_rel = None + try: + # derive directory path text relative to gallery root + dir_rel = full_path.parent.relative_to(settings.GALLERY_ROOT) + dir_text = str(dir_rel).replace("\\", "/") + except Exception: + dir_text = "" + + back_url = gallery_url( + Path(dir_text) if dir_text != "" else None, True, query_state + ) + back_thumb = get_first_image_thumbnail_url(full_path.parent) + home_url = gallery_url(None, True, query_state) + home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) + + # Prev/next thumbnails + prev_thumb = thumb_for(prev_path) + next_thumb = thumb_for(next_path) + + # Image metadata + width = height = None + filesize = None + created_ts = None + modified_ts = None + try: + img_file = full_path + with Image.open(img_file) as im: + width, height = im.size + except Exception: + pass + + try: + stat = full_path.stat() + filesize = stat.st_size + created_ts = getattr(stat, "st_birthtime", stat.st_ctime) + modified_ts = stat.st_mtime + except Exception: + pass + + def human_size(bytes_val): + if bytes_val is None: + return None + kb = 1024.0 + if bytes_val < kb * 1024: + return f"{bytes_val / kb:.2f} KB" + return f"{bytes_val / (kb * kb):.2f} MB" + + def fmt_ts(ts): + if ts is None: + return None + try: + return datetime.datetime.fromtimestamp(ts).strftime("%c") + except Exception: + return None + + # Breadcrumbs: include directory breadcrumbs then append file name as label-only + # build_breadcrumbs expects a path_text representing directories only + dir_path_text = dir_text if dir_text != "" else "" + breadcrumbs = build_breadcrumbs(dir_path_text, query_state) + breadcrumbs.append({"label": full_path.name, "path": None}) + + next_theme = "light" if theme == "dark" else "dark" + theme_query = {"sort": sort_key, "theme": next_theme} + if search_text != "": + theme_query["search"] = search_text + + context = { + "image_path": image, + # keep legacy prev/next names for tests + "prev": prev_path.name if prev_path is not None else None, + "next": next_path.name if next_path is not None else None, + # new richer values + "prev_url": prev_url, + "next_url": next_url, + "prev_thumb": prev_thumb, + "next_thumb": next_thumb, + "back_url": back_url, + "back_thumb": back_thumb, + "home_url": home_url, + "home_thumb": home_thumb, + "image_meta": { + "filename": full_path.name, + "width": width, + "height": height, + "filesize": human_size(filesize), + "created": fmt_ts(created_ts), + "modified": fmt_ts(modified_ts), + }, + "breadcrumbs": breadcrumbs, + "theme": theme, + "sort_key": sort_key, + "sort_label": "", + "sort_options": build_sort_options(request, search_text, sort_key, theme), + "theme_toggle_url": gallery_url( + Path(dir_path_text) if dir_path_text != "" else None, True, theme_query + ), + } + + from .common import SORT_LABELS + + context["sort_label"] = SORT_LABELS.get(sort_key, "") + + return render(request, "image_view.html", context) diff --git a/viewer/test.py b/viewer/test.py index 1991db7..f93df07 100644 --- a/viewer/test.py +++ b/viewer/test.py @@ -12,7 +12,9 @@ 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 +from viewer.common import SORT_LABELS +from viewer.directory import do_recursive_search +from viewer.views import gallery_view class GalleryBaseTests(TestCase): @@ -280,7 +282,7 @@ class GalleryViewTests(GalleryBaseTests): self.assertNotIn("symlink_sub", names) def test_thumbnail_failure_does_not_break_render(self): - with patch("viewer.views.make_thumbnail", side_effect=Exception("fail")): + with patch("viewer.common.make_thumbnail", side_effect=Exception("fail")): response = self.client.get("/gallery/") self.assertEqual(response.status_code, 200) @@ -383,7 +385,7 @@ class GalleryViewTests(GalleryBaseTests): def fake_creation(path_obj): return creation_values.get(path_obj.name, 0) - with patch("viewer.views.get_creation_timestamp", side_effect=fake_creation): + with patch("viewer.common.get_creation_timestamp", side_effect=fake_creation): old = self.client.get("/gallery/?sort=old") new = self.client.get("/gallery/?sort=new") diff --git a/viewer/views.py b/viewer/views.py index ddef4bf..5908024 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -1,275 +1,18 @@ -# Standard library imports. -from pathlib import Path -from urllib.parse import urlencode -from functools import cmp_to_key +"""Top-level views module. + +After refactor this file only keeps the minimal public view entry points and imports +helpers from the new `directory` and `image` modules. +""" # Django imports. 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 redirect -# Project imports. -from .utils import make_thumbnail, is_image_file -from PIL import Image -import datetime - -########################################################################################### -# Constants. # -########################################################################################### - -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. - """ - - 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 subdir in all_subdirs: - rec_subdirs, rec_images = do_recursive_search(subdir, query.lower()) - 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. # -########################################################################################### +# Local helpers split into modules +from .directory import render_directory +from .image import render_image @login_required @@ -277,257 +20,6 @@ def index(request): 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. - """ - - # Preserve query state (sort, search, theme) similar to gallery view - 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) - - image = Path("/imgs/").joinpath(path_text) - img_dir = full_path.parent - - try: - entries = [ - entry - 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") - - # Sort siblings according to requested sort mode - images_sorted = sort_images(entries, sort_key) - - # Find index of current image - try: - index = next(i for i, p in enumerate(images_sorted) if p.name == full_path.name) - except StopIteration: - return HttpResponseNotFound("Not found") - - prev_path = images_sorted[index - 1] if index > 0 else None - next_path = images_sorted[index + 1] if index < len(images_sorted) - 1 else None - - # Helper to produce thumb url for a Path or None - def thumb_for(path_obj): - if path_obj is None: - return None - try: - make_thumbnail(path_obj) - rel = path_obj.relative_to(settings.GALLERY_ROOT) - thumb_path = settings.THUMBNAILS_ROOT.joinpath(rel) - if thumb_path.exists(): - return "/thumbs/" + str(rel).replace("\\", "/") - except Exception: - pass - return None - - # Build URLs (preserving query state) - prev_url = None - next_url = None - if prev_path is not None: - rel = prev_path.relative_to(settings.GALLERY_ROOT) - prev_url = gallery_url(rel, False, query_state) - - if next_path is not None: - rel = next_path.relative_to(settings.GALLERY_ROOT) - next_url = gallery_url(rel, False, query_state) - - # Back (directory) and Home (root) links and thumbnails - dir_rel = None - try: - # derive directory path text relative to gallery root - dir_rel = full_path.parent.relative_to(settings.GALLERY_ROOT) - dir_text = str(dir_rel).replace("\\", "/") - except Exception: - dir_text = "" - - back_url = gallery_url( - Path(dir_text) if dir_text != "" else None, True, query_state - ) - back_thumb = get_first_image_thumbnail_url(full_path.parent) - home_url = gallery_url(None, True, query_state) - home_thumb = get_first_image_thumbnail_url(settings.GALLERY_ROOT) - - # Prev/next thumbnails - prev_thumb = thumb_for(prev_path) - next_thumb = thumb_for(next_path) - - # Image metadata - width = height = None - filesize = None - created_ts = None - modified_ts = None - try: - img_file = full_path - with Image.open(img_file) as im: - width, height = im.size - except Exception: - pass - - try: - stat = full_path.stat() - filesize = stat.st_size - created_ts = getattr(stat, "st_birthtime", stat.st_ctime) - modified_ts = stat.st_mtime - except Exception: - pass - - def human_size(bytes_val): - if bytes_val is None: - return None - kb = 1024.0 - if bytes_val < kb * 1024: - return f"{bytes_val / kb:.2f} KB" - return f"{bytes_val / (kb * kb):.2f} MB" - - def fmt_ts(ts): - if ts is None: - return None - try: - return datetime.datetime.fromtimestamp(ts).strftime("%c") - except Exception: - return None - - # Breadcrumbs: include directory breadcrumbs then append file name as label-only - # build_breadcrumbs expects a path_text representing directories only - dir_path_text = dir_text if dir_text != "" else "" - breadcrumbs = build_breadcrumbs(dir_path_text, query_state) - breadcrumbs.append({"label": full_path.name, "path": None}) - - next_theme = "light" if theme == "dark" else "dark" - theme_query = {"sort": sort_key, "theme": next_theme} - if search_text != "": - theme_query["search"] = search_text - - context = { - "image_path": image, - # keep legacy prev/next names for tests - "prev": prev_path.name if prev_path is not None else None, - "next": next_path.name if next_path is not None else None, - # new richer values - "prev_url": prev_url, - "next_url": next_url, - "prev_thumb": prev_thumb, - "next_thumb": next_thumb, - "back_url": back_url, - "back_thumb": back_thumb, - "home_url": home_url, - "home_thumb": home_thumb, - "image_meta": { - "filename": full_path.name, - "width": width, - "height": height, - "filesize": human_size(filesize), - "created": fmt_ts(created_ts), - "modified": fmt_ts(modified_ts), - }, - "breadcrumbs": breadcrumbs, - "theme": theme, - "sort_key": sort_key, - "sort_label": SORT_LABELS[sort_key], - "sort_options": build_sort_options(request, search_text, sort_key, theme), - "theme_toggle_url": append_query(request.path, theme_query), - } - - return render(request, "image_view.html", context) - - @login_required def gallery_view(request, path=None): """ @@ -549,6 +41,6 @@ def gallery_view(request, path=None): path_text = path if path is not None else "" if candidate.is_dir(): - return _render_directory(request, path_text, candidate) + return render_directory(request, path_text, candidate) - return _render_image(request, path_text, candidate) + return render_image(request, path_text, candidate)