Skip to main content

Nikola Gallery Helper

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:

  1. Generate the metadata.yml file from an arbitrary directory of images, and in particular, set the order fields based on the Exif datetime attribute, so the gallery is at least in chronological order to start. Adding in stubs for the caption fields is useful because it makes it faster to add those later by hand.
  2. Generate the index.txt file as a stub to be filled later by hand, but populate the title and date fields.
  3. 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