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.ymlfile from an arbitrary directory of images, and in particular, set theorderfields based on the Exif datetime attribute, so the gallery is at least in chronological order to start. Adding in stubs for thecaptionfields is useful because it makes it faster to add those later by hand. - Generate the
index.txtfile as a stub to be filled later by hand, but populate thetitleanddatefields. - 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