Redesigned gallery view layout and added sorting tests
This commit is contained in:
509
viewer/views.py
509
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)
|
||||
|
||||
Reference in New Issue
Block a user