Nikola Gallery Helper
| Amelia Meyer | home | Fury Weekend--"Another Brick In The Wall" | accomplished
Nikola can make galleries of images, and it does so pretty competently. But it does require metadata, and the creation of new galleries does not seem to be linked into any of the feed generation. So I made a helper.
There are two required files for a Nikola gallery:
-
index.txt
: This is an rST-format file that provides gallery-wide metadata and also a description of the gallery as a whole. -
metadata.yml
: This is a simple YAML file with an entry for each image, where things like captions and ordering get set.
In addition, because galleries are not part of feed generation, I want to optionally make a companion blog post that links to the new gallery.
So the goals were to:
- Generate the
metadata.yml
file from an arbitrary directory of images, and in particular, set theorder
fields based on the Exif datetime attribute, so the gallery is at least in chronological order to start. Adding in stubs for thecaption
fields is useful because it makes it faster to add those later by hand. - Generate the
index.txt
file as a stub to be filled later by hand, but populate thetitle
anddate
fields. - Optionally, generate a Markdown blog entry stub that links to where the new gallery will be, with the right front matter and a reasonable default body.
After about 4h mucking around with Python and Exif, I came up with this:
nikola_gallery_helper.py (Source)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
#!/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)
|
Seems to work well; it generated both my post for my recent trip to Meijer Gardens and my post for last year's trip to John Ball zoo