diff --git a/fussel/generator/config.py b/fussel/generator/config.py index 5699c0d..083f319 100644 --- a/fussel/generator/config.py +++ b/fussel/generator/config.py @@ -48,6 +48,8 @@ def init(cls, yaml_config): cls._instance.site_name = str(yaml_config.getKey( 'site.title', DEFAULT_SITE_TITLE)) cls._instance.supported_extensions = ('.jpg', '.jpeg', '.gif', '.png') + cls._instance.sort_by = str(yaml_config.getKey('gallery.albums.sort-by', 'name')) + cls._instance.sort_order = str(yaml_config.getKey('gallery.albums.sort-order', 'asc')) _parallel_tasks = os.cpu_count()/2 if _parallel_tasks < 1: diff --git a/fussel/generator/generate.py b/fussel/generator/generate.py index 2382fe5..512cb6b 100755 --- a/fussel/generator/generate.py +++ b/fussel/generator/generate.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import datetime import os import shutil import json @@ -162,7 +163,7 @@ def has_thumbnail(self): class Photo: - def __init__(self, name, width, height, src, thumb, slug, srcSet): + def __init__(self, name, width, height, src, thumb, slug, srcSet, exif: dict[str, object]): self.width = width self.height = height @@ -172,6 +173,7 @@ def __init__(self, name, width, height, src, thumb, slug, srcSet): self.srcSet = srcSet self.faces: list = [] self.slug = slug + self.exif = exif @classmethod def process_photo(cls, external_path, photo, filename, slug, output_path, people_q: Queue): @@ -198,6 +200,7 @@ def process_photo(cls, external_path, photo, filename, slug, output_path, people with Image.open(new_original_photo) as im: original_size = im.size width, height = im.size + exif = extract_exif(im) except UnidentifiedImageError as e: shutil.rmtree(new_original_photo, ignore_errors=True) raise PhotoProcessingFailure(message=str(e)) @@ -247,7 +250,8 @@ def process_photo(cls, external_path, photo, filename, slug, output_path, people "%s/%s" % (quote(external_path), quote(os.path.basename(smallest_src))), slug, - srcSet + srcSet, + exif ) # Faces @@ -300,6 +304,36 @@ def add_album(self, album): def __getitem__(self, item): return list(self.albums.values())[item] + + def sort(self): + method = Config.instance().sort_by + order = Config.instance().sort_order + if method == 'name': + + def sort_by_name(item: tuple[str, Album]) -> str: + return item[1].name + + self.albums = dict(sorted(self.albums.items(), key=sort_by_name, reverse=(order == 'desc'))) + elif method == 'date': + time_albums: dict[str, float] = {} + + def sort_by_date(item: tuple[str, Album]) -> float: + return time_albums[item[1].name] + + album: Album + for album in self.albums.values(): + p: Photo + for p in album.photos: + t = time_albums.get(album.name, 0) + if "DateTimeOriginal" in p.exif.keys(): + t = max(t, datetime.datetime.strptime(p.exif["DateTimeOriginal"], "%Y:%m:%d %H:%M:%S").timestamp()) + # else: + # # creation time or modification time may introduce significant unexpected results + # t = max(t, os.path.getctime( + # os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "web", "build", p.src.removeprefix("/")) + # )) + time_albums[album.name] = t + self.albums = dict(sorted(self.albums.items(), key=sort_by_date, reverse=(order == 'desc'))) def process_path(self, root_path, output_albums_photos_path, external_root): @@ -313,6 +347,9 @@ def process_path(self, root_path, output_albums_photos_path, external_root): self.process_album_path( album_path, album_name, output_albums_photos_path, external_root) + print(f'Sort by: {Config.instance().sort_by} {Config.instance().sort_order}') + self.sort() + def process_album_path(self, album_dir, album_name, output_albums_photos_path, external_root): unique_album_slug = find_unique_slug( @@ -443,8 +480,7 @@ def generate(self): with open(output_albums_data_file, 'w') as outfile: output_str = 'export const albums_data = ' - output_str += json.dumps(Albums.instance(), - sort_keys=True, indent=3, cls=SimpleEncoder) + output_str += json.dumps(Albums.instance(), indent=3, cls=SimpleEncoder) output_str += ';' outfile.write(output_str) diff --git a/fussel/generator/util.py b/fussel/generator/util.py index 7f2240b..086b4bf 100644 --- a/fussel/generator/util.py +++ b/fussel/generator/util.py @@ -1,11 +1,33 @@ - - -from PIL import Image +from PIL import Image, ImageFile +from PIL.ExifTags import TAGS, GPSTAGS, IFD from slugify import slugify from .config import * import os +def extract_exif(im: ImageFile.ImageFile) -> dict: + result = {} + # https://stackoverflow.com/a/75357594 + exif = im.getexif() + if exif: + for tag, value in exif.items(): + if tag in TAGS: + result[TAGS[tag]] = value + for ifd_id in IFD: + try: + ifd = exif.get_ifd(ifd_id) + if ifd_id == IFD.GPSInfo: + resolve = GPSTAGS + else: + resolve = TAGS + for k, v in ifd.items(): + tag = resolve.get(k, k) + result[tag] = str(v) + except KeyError: + pass + return result + + def is_supported_album(path): folder_name = os.path.basename(path) return not folder_name.startswith(".") and os.path.isdir(path) @@ -36,7 +58,6 @@ def find_unique_slug(slugs, lock, name): slugs.add(slug) lock.release() - return slug diff --git a/fussel/web/src/component/App.js b/fussel/web/src/component/App.js index 5131a00..53a0b53 100644 --- a/fussel/web/src/component/App.js +++ b/fussel/web/src/component/App.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import Navbar from "./Navbar"; import Collections from "./Collections"; import Collection from "./Collection"; +import Info from "./Info"; import NotFound from "./NotFound"; import { site_data } from "../_gallery/site_data.js" import { Routes, Route } from "react-router-dom"; @@ -25,6 +26,7 @@ export default class App extends Component { } /> } /> } /> + } /> {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit diff --git a/fussel/web/src/component/Collection.js b/fussel/web/src/component/Collection.js index 0d5d69c..7bde63d 100644 --- a/fussel/web/src/component/Collection.js +++ b/fussel/web/src/component/Collection.js @@ -88,6 +88,10 @@ class Collection extends Component { // page.classList.remove('noscroll'); }; + openInfoModal = () => { + this.props.navigate("/collections/" + this.props.params.collectionType + "/" + this.props.params.collection + "/" + this.props.params.image + "/info"); + }; + title = (collectionType) => { var titleStr = "Unknown" if (collectionType == "albums") { @@ -155,7 +159,7 @@ class Collection extends Component { isOpen={this.state.viewerIsOpen} onRequestClose={this.closeModal} preventScroll={true} - + style={{ overlay: { backgroundColor: 'rgba(0, 0, 0, 0.3)' @@ -167,11 +171,22 @@ class Collection extends Component { } }} > + + { + var titleStr = "Unknown" + if (collectionType == "albums") { + titleStr = "Albums" + } + else if (collectionType == "people") { + titleStr = "People" + } + return titleStr + } + + collection = (collectionType, collection) => { + let data = {} + if (collectionType == "albums") { + data = albums_data + } + else if (collectionType == "people") { + data = people_data + } + if (collection in data) { + return data[collection] + } + return {} + } + + photo = (collection, slug) => { + return collection.photos.find(photo => photo.slug === slug) + } + + render() { + let collection_data = this.collection(this.props.params.collectionType, this.props.params.collection) + let photo = this.photo(collection_data, this.props.params.image) + return ( +
+
+
+ +
+
+
+ {photo.name} +
+
+ { + "Make" in photo.exif && <>
{photo.exif.Make}
| + } +
+ { + "Model" in photo.exif &&
{photo.exif.Model}
+ } + { + "LensModel" in photo.exif &&
{photo.exif.LensModel}
+ } +
+
+
{ + Object.entries(photo.exif).map((item, i) => ( +
+ {item[0]}: {item[1]} +
+ )) + }
+
+
+
+ ); + } +} + +export default withRouter(Info) \ No newline at end of file diff --git a/sample_config.yml b/sample_config.yml index 5497b4b..6f3caaf 100644 --- a/sample_config.yml +++ b/sample_config.yml @@ -32,6 +32,15 @@ gallery: # Default: "{parent_album} > {album}" recursive_name_pattern: "{parent_album} > {album}" + # Sort albums + # Options: name, date + # Default: name + sort-by: date + + # Options: asc, desc + # default: asc + sort-order: asc + people: # Face Tag detection. # Setting to True adds a faces button and virtual albums for detected people