Photos from Darktable to my blog in one click

I fixed the graphics drivers on my laptop and Darktable—for the unaware, an extremely solid OSS photo editing software—went from essentially unusable to decently fast. After years of anticipation, I finally plugged my big hard drive into my little server and set up a network share (and backups!). I consolidated all my various photo libraries that were previously strewn across four portable HDDs and SSDs in various degrees of duplication into a single library on said big hard drive. And I even bought a new camera that online hype claimed would make it so I didn’t have to edit pictures anymore (then I felt bad about how much it cost, so returned it and bought a different one that was half the price and fit my needs much better).

These all helped me get out of my several-year drought where I occasionally decided to drag my camera around and took some photos, but rarely actually looked at the results. I’ve come to quite like Darktable now that it’s usable on my laptop, especially after spending some time learning about tone mapping and color theory and following a few tutorials. I’ve been carrying my new small camera around way more often and taking photos more frequently, but also more intentionally, as a result. I’ve produced some photos that I’m pretty happy with in the last couple months. I’ve wanted to post these photos on my website, but that so far has meant:

  1. Exporting the photo from Darktable into a folder somewhere
  2. Making a new folder in my website’s content/photos directory
  3. Copying the exported image file into that folder
  4. Copying an index.md from a previous photo post into the folder
  5. Updating the date and caption for the new photo and copying the filename into the featured_image field
  6. Commit and push with git to trigger the auto-build-and-deploy that happens on each push.

This is tedious enough that I so far have rarely posted photos to my website.

Luckily, though, Darktable provides a nice solution. Darktable exposes an API to Lua and allows integrating Lua scripts into the UI and, especially important for my purposes, it allows defining custom export destinations with these Lua scripts. So I set up a simple automation that combines a bash script in my website’s directory with a simple Lua exporter.

The bash script takes a single JPEG filename and produces a Hugo post from it (I use Just for this repo, but this could also just be a bash script). It reads the date and caption from the image EXIF data, but sets pubDate to the current date since I often edit old photos and still want these to show up when I publish them.

# Create a photo post from an image file, reading EXIF data for the date and description.
# Usage: just photo-from-file /path/to/photo.jpg
photo-from-file filepath:
    #!/usr/bin/env bash
    set -euo pipefail

    filepath=$(cd "{{invocation_directory()}}" && realpath "{{filepath}}")
    filename=$(basename "$filepath")

    # Get datetime from EXIF
    exif_datetime=$(exiftool -s3 -d "%Y-%m-%dT%H:%M:%S" -DateTimeOriginal "$filepath" 2>/dev/null || true)
    if [ -z "$exif_datetime" ]; then
        exif_datetime=$(exiftool -s3 -d "%Y-%m-%dT%H:%M:%S" -CreateDate "$filepath" 2>/dev/null || true)
    fi
    if [ -z "$exif_datetime" ]; then
        echo "Error: could not read date from EXIF data" >&2
        exit 1
    fi

    offset=$(exiftool -s3 -OffsetTimeOriginal "$filepath" 2>/dev/null | tr -d '[:space:]' || true)
    if [ -z "$offset" ]; then
        offset="+00:00"
    fi
    date_str="${exif_datetime}${offset}"

    publish_date_str=$(date --iso-8601=seconds)

    # Use EXIF ImageDescription as title/caption
    description=$(exiftool -s3 -ImageDescription "$filepath" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' || true)
    if [ -z "$description" ]; then
        echo "Error: no ImageDescription found in EXIF data" >&2
        exit 1
    fi

    # Derive slug from description: lowercase, replace spaces/special chars with hyphens
    slug=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')

    site_dir="{{justfile_directory()}}"
    post_dir="$site_dir/content/photos/$slug"
    mkdir -p "$post_dir/images"

    # Write index.md
    {
        echo '+++'
        echo "title=\"$description\""
        echo "publishDate=\"$publish_date_str\""
        echo "date=\"$date_str\""
        echo "draft=false"
        echo "caption=\"$description\""
        echo "featured_image='images/$filename'"
        echo '+++'
    } > "$post_dir/index.md"

    cp "$filepath" "$post_dir/images/$filename"

    echo "Created $post_dir/index.md"

And then, in Darktable, all we do is call this script to create the post and run the git commands to commit and push.

darktable = require "darktable"

local SITE_DIR = "/home/ben/dev/personal-website"
local JUSTFILE = SITE_DIR .. "/justfile"

local function export_image(storage, image, format, filename, number, total, high_quality, extra_data)
    -- filename is the absolute temp export path darktable wrote the image to.
    local cmd = string.format(
        "just -f %q photo-from-file %q 2>&1",
        JUSTFILE, filename
    )

    local base = filename:match("([^/]+)$")
    darktable.print("Creating photo post for " .. base .. "...")

    local result = darktable.control.execute(cmd)

    if result ~= 0 then
        darktable.print_error("photo-from-file failed for " .. base .. " (exit code " .. result .. ")")
        darktable.print("ERROR: photo-from-file failed for " .. base .. " — check the darktable log")
    else
        darktable.print("Photo post created for " .. base)
    end
end

local function finalize(storage, image_table, extra_data)
    local first_image, first_filename = next(image_table)
    local first_image_description = first_image.description
    local cmd = string.format(
        "cd %q && git add . && git commit -m 'export darktable image %q' && git push",
        SITE_DIR, first_image_description
    )
    local result = darktable.control.execute(cmd)
    darktable.print("Committed. Open " .. SITE_DIR .. "/content/photos/ to review posts.")
end

darktable.register_storage("website_export","Export to benkettle.xyz", export_image, finalize)

The full workflow looks like this now:

  1. Do my regular photo editing process and end up with a photo that I like (hopefully)
  2. Add a “description” to the photo in Darktable’s UI
  3. Export the photo using my custom exporter And with that, my photo is published! No filesystem manipulation or even command-line usage necessary.