|
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
# SPDX-License-Identifier: FAFOL
|
|
"""A utility to help build image galleries for Nikola."""
|
|
|
|
import argparse
|
|
import logging
|
|
import sys
|
|
from datetime import datetime
|
|
from glob import glob
|
|
from os.path import abspath, basename, isdir, join
|
|
from typing import NamedTuple, Union
|
|
from urllib.parse import quote
|
|
|
|
from exif import Image
|
|
|
|
logging.basicConfig()
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
class Coord(NamedTuple):
|
|
"""A DMS coordinate"""
|
|
degrees: float
|
|
minutes: float
|
|
seconds: float
|
|
hemisphere: str = ''
|
|
|
|
def __str__(self) -> str:
|
|
"""
|
|
Make a string version using decimal degrees instead of DMS
|
|
|
|
Returns:
|
|
str: this coordinate in decimal degrees
|
|
"""
|
|
if self.hemisphere == 'W' or self.hemisphere == 'S':
|
|
sign = -1
|
|
else:
|
|
sign = 1
|
|
return f'{(self.degrees + self.minutes / 60.0 + self.seconds/3600.0) * sign}'
|
|
|
|
def __repr__(self) -> str:
|
|
return f'Coord(degrees={self.degrees}, minutes={self.minutes}, seconds={self.seconds}, hemisphere={self.hemisphere})'
|
|
|
|
|
|
class GPS(NamedTuple):
|
|
"""A GPS location in DMS"""
|
|
latitude: Coord
|
|
longitude: Coord
|
|
|
|
def __str__(self) -> str:
|
|
"""
|
|
Make a string version using decimal degrees instead of DMS
|
|
|
|
Returns:
|
|
str: this position in decimal degrees
|
|
"""
|
|
return f'{self.latitude}, {self.longitude}'
|
|
|
|
def __repr__(self) -> str:
|
|
return f'GPS(latitude={self.latitude!r}, longitude={self.longitude!r})'
|
|
|
|
|
|
class Metadata(NamedTuple):
|
|
"""Metadata for an image or gallery"""
|
|
datetime: Union[str, datetime]
|
|
filename: str
|
|
location: GPS = None
|
|
|
|
def __str__(self) -> str:
|
|
if self.location is None:
|
|
return f'{self.filename}, taken on {self.datetime}'
|
|
return f'{self.filename}, taken on {self.datetime}, at {self.location}'
|
|
|
|
|
|
def make_gallery(gallery: str, gps_caption: bool = False) -> Metadata:
|
|
"""
|
|
Make the gallery-specific metadata files
|
|
|
|
|
|
|
|
Args:
|
|
gallery (str): path to gallery of images
|
|
gps_caption (bool, optional): whether to add the GPS info from Exif into captions. Defaults to False.
|
|
|
|
Returns:
|
|
Metadata: gallery metadata
|
|
"""
|
|
images = set(glob(join(gallery, '*.jpg')) + glob(join(gallery, '*.JPG')) +
|
|
glob(join(gallery, '*.jpeg')) + glob(join(gallery, '*.JPEG')))
|
|
|
|
logger.debug(f'Found {len(images)} in gallery')
|
|
|
|
metadata = []
|
|
date = datetime.min
|
|
for image in images:
|
|
img = Image(image)
|
|
try:
|
|
gps = GPS(
|
|
latitude=Coord._make(img['gps_latitude'] +
|
|
(img['gps_latitude_ref'],)),
|
|
longitude=Coord._make(img['gps_longitude'] +
|
|
(img['gps_longitude_ref'],)),
|
|
)
|
|
except KeyError:
|
|
gps = None
|
|
metadata.append(
|
|
Metadata(
|
|
datetime=datetime.strptime(
|
|
img['datetime'], '%Y:%m:%d %H:%M:%S'),
|
|
filename=basename(image),
|
|
location=gps
|
|
)
|
|
)
|
|
if metadata[-1].datetime > date:
|
|
date = metadata[-1].datetime
|
|
logger.debug(f'Image: {metadata[-1]}')
|
|
|
|
path = join(gallery, 'metadata.yml')
|
|
with open(path, 'w', encoding='utf-8') as yml:
|
|
logger.info(f'Writing metadata for {len(images)} to {path}')
|
|
for order, data in enumerate(sorted(metadata, key=lambda d: d.datetime)):
|
|
yml.write(f'---\norder: {order}\nname: {data.filename}\ncaption: ')
|
|
if data.location is not None and gps_caption:
|
|
yml.write(f' (location {data.location})')
|
|
yml.write('\n')
|
|
yml.write('---')
|
|
|
|
path = join(gallery, 'index.txt')
|
|
title = basename(gallery).replace("_", " ")
|
|
with open(path, 'w', encoding='utf-8') as idx:
|
|
logger.info(
|
|
f'Writing index file to {path}: title {title}, date {date}')
|
|
idx.write(f'.. title: {title}\n')
|
|
idx.write(f'.. date: {date}\n')
|
|
idx.write('.. location: \n')
|
|
idx.write('.. tags: \n')
|
|
idx.write('\n')
|
|
|
|
return Metadata(datetime=date, filename=basename(gallery), location=None)
|
|
|
|
|
|
def make_blog(blog: str, gallery_data: Metadata) -> None:
|
|
"""
|
|
Make the blog entry stub to announce the new gallery
|
|
|
|
Args:
|
|
blog (str): path to blog posts
|
|
gallery_data (Metadata): metadata for gallery
|
|
"""
|
|
slug = gallery.filename.lower().replace(" ", "-")
|
|
path = join(blog, f'{slug}.md')
|
|
gallery_url = f'/galleries/{quote(gallery.filename)}/'
|
|
with open(path, 'w', encoding='utf-8') as blog:
|
|
logger.info(
|
|
f'Writing blog entry stub to {path}: gallery {gallery_url}, date {gallery.datetime}')
|
|
blog.write(f'slug: {slug}\n'
|
|
f'title: {gallery.filename}\n'
|
|
f'date: {gallery.datetime}\n'
|
|
f'location: \n'
|
|
f'tags: \n'
|
|
f'mood: \n'
|
|
f'category: blog\n'
|
|
f'status: draft\n'
|
|
f'\n'
|
|
f'A new gallery has been created: [{gallery.filename}]({gallery_url})\n'
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('gallery', help='path to the gallery of images')
|
|
parser.add_argument(
|
|
'--blog', help='if given, the directory to create a placeholder blog post')
|
|
parser.add_argument('--gps', action='store_true',
|
|
help='whether to include GPS tags in captions')
|
|
args = parser.parse_args()
|
|
|
|
if not isdir(args.gallery):
|
|
logger.critical(f'{args.gallery} is not a directory?')
|
|
sys.exit(-100)
|
|
args.gallery = abspath(args.gallery)
|
|
|
|
logger.debug(f'gallery: {args.gallery}')
|
|
|
|
gallery = make_gallery(args.gallery, args.gps)
|
|
|
|
if args.blog:
|
|
if not isdir(args.blog):
|
|
logger.critical(f'{args.blog} is not a directory?')
|
|
sys.exit(-200)
|
|
|
|
args.blog = abspath(args.blog)
|
|
logger.debug(f'blog: {args.blog}')
|
|
make_blog(args.blog, gallery)
|