Lightroom Lua script to select a repeating annual date range
I need to find all the photos that I've taken in November over the last few years. I can't express this query easily in Lightroom, so I figured out how to write a Lua script that would make this query. These notes describe what I did, as someone who had never before programmed with Lua, nor used the scripting environment in Lightroom.
21 November 2015
Last year Apple stopped development of Aperture, their high-end photo management program, which I used to manage my photographs for many years. Thus I was forced to switch to a new tool, and went for Adobe Lightroom, which is pretty much the standard choice for serious photographers (and those like me with pretentions to seriousness).
On the whole I like Lightroom and despite my lesser familiarity, I think I prefer it to Aperture. But, of course, there are things missing. One major omission for me is the inability to query for a repeating annual date range.
If you follow my atom feed, you'll know that I regularly pump out photos to the feed. Initially I just put out a photo I fancied sharing onto the feed, but after the first few photos I decided on something a little more structured. A photo that appears on my feed now needs to be shot at about the same time of year as when I post it, and be at least a year old. That way the photo reflects the season we're currently in (at least here in the Northern Hemisphere) and is old enough for me to be reasonably confident in my liking for the photo. Aperture allowed me to do this easily, as I could select the month number as part of a query for a smart collection - so for a November picture I could query for photos with a month number of “11”.
While Lightroom has a powerful smart collections feature too, it lacks this ability. I can query for a date range, but that date range is anchored. I can query for “2014-11-01..2014-11-30” but I have to put the year in there. I could form a condition that is a disjunction with multiple anchored year queries, but that's both a pain to set up and means I have to alter every single one whenever I change the months I'm looking for.
As a programmer, I'm always looking for ways to spend several hours programming to save an hour's work. Lightroom offers a programming extension environment to write plugins using the Lua language. So I spent some time writing a Lua script to select these candidates. And as I'm a writer as well as a programmer, I can't help spending yet more hours to share what I did just in case someone else has a similar problem, or merely wants to explore writing Lua scripts for Lightroom. Several things weren't entirely clear to me in doing this, so hopefully my exercise will help someone setting out on a similar path.
Let me be clear, this is my first time attempting to do anything with Lightroom, and my first time programming with Lua. I'm not speaking from experience with this article and am likely just showing off all sorts of second-order ignorance.
Running my script populates a collection called “candidates” with the photos that match the date range, plus some other conditions which narrow down the photos to the ones I like to consider. Each time I run the script it removes what's already in the collection and repopulates it.
Setting up a plugin
As with any new programming environment, the first thing to do is to download the development environment and write a “hello world”. Adobe provides an SDK for this, which includes a manual, some API documentation, and some sample plugins. One of these plugins is a “hello world” plugin.
I copied this plugin to a suitable spot on my hard drive so I
could set it up as my own and make it a git repo. The plugin comes with several scripts
attached to different menu items. I removed all but the one
that's added to the file menu by editing the
Info.lua
file to look like this.
Info.lua…
return { LrSdkVersion = 3.0, LrSdkMinimumVersion = 1.3, -- minimum SDK version required by this plug-in LrToolkitIdentifier = 'com.martinfowler.candidates', LrPluginName = LOC "$$$/HelloWorld/PluginName=Candidates", -- Add the menu item to the File menu. LrExportMenuItems = { title = "Select Candidates", file = "ExportMenuItem.lua", }, VERSION = { major=6, minor=2, revision=0, build=1029764, }, }
You'll see I've provided a LrToolkitIdentifier
and LrPluginName
with obvious modifications to what
was in the original hello world example. I then also add a
“Select Candidates” menu item to the File menu - this appears
under
. I would prefer to rename the file
“ExportMenuItem.lua” to something more sensible, but when I
tried I got an error that I wasn't able to sort out, so I stuck
with that deceptive name.
I could then add this plug-in to Lightroom by using
. Once I'd done that I could use the new and run it to see the hello world dialog box. That told me that everything was connected up and running.Sadly re-running the script after a change is much more awkward than I'd like. If I change the Lua program I need to go to
, hit the Reload Plug-in button, hit the Done button, and then select . I'd prefer to reload and run the script with a single keypress, but I couldn't find a way to do that.Getting some trace output
When I'm working in an unfamiliar environment, I like to ensure the program can tell me what it's doing. The crude way to do that is to print messages out to somewhere I can see them. Getting the Lightroom Lua environment to do that was rather awkward. I ended up creating a log function which I put in the main file.
ExportMenuItem.lua…
local LrLogger = import 'LrLogger' local myLogger = LrLogger( 'lightroomLogger' ) myLogger:enable( "logfile" ) -- Pass either a string or a table of actions. local function log( message ) myLogger:trace( message ) end
This prints log messages to the file
~/Documents/lightroomLogger.log
. I couldn't see how to get it to send
messages to any other file, but at least once I'd got that file open in
auto-revert-mode I was able to see any messages I wanted my program to emit.
Running update commands
Lightroom requires a bit of wrapping around its commands, particularly those like this that update the catalog.
ExportMenuItem.lua…
local LrTasks = import 'LrTasks' local function runCommand () LrTasks.startAsyncTask(function() catalog:withWriteAccessDo("selecting candidates", selectCandidates) end ) end runCommand()
The way the command is set up in Info.lua
is to run the named file.
When I run a file like this I like to have a single function that I run at the top
level of the file, keeping all else inside defined functions. So the last line of
ExportMenuItem.lua
is runCommand()
. In Lua any function
must be defined before it's used, so I'll be going backwards through the program in
order to explain it.
The actual logic I want to run is in a function selectCandidates
. In
order to run this runCommand
wraps it in a couple of other functions.
LrTasks.startAsyncTask
runs the enclosed function in an asynchronous
task - I think I have to do this with anything I want to run in Lightrooom. The
second wrapper is catalog:withWriteAccessDo
which I need to use for any
function that modifies the state of the catalog. I think this function grabs a
write-lock on the catalog.
A couple of times I ran into a problem where I saw an error like
“LrCatalog:withWriteAccessDo: could not execute action 'selecting candidates'.
It was blocked by another write access call, and no timeout parameters were
provided.”
. I think this was due to something going wrong so that the
write-lock wasn't released. When this happend I had to quit and restart Lightroom.
It only happened a couple of times, so it wasn't a big deal, (at least not after I'd
spent some frustrating time trying to find out that to deal with the error I needed
to bounce Lightroom).
Repopulating the candidates collection
Now I can get to the logic that matters to me.
ExportMenuItem.lua…
local catalog = LrApplication.activeCatalog()
-- functions I'll come to later
local function selectCandidates() local candidates = getCollection("candidates") candidates:removeAllPhotos() candidates:addPhotos(findCandidates()) end
Three simple steps, first get hold of the candidates collection, remove all the photos currently in there, and then repopulate with new set of found candidates. Finding a collection with a given name was more awkward than I thought it would be, there doesn't seem to be an API function that will return a collection given a string, instead I have loop through all collections and test to select the one I'm after.
ExportMenuItem.lua…
local function getCollection(name) for i,v in ipairs(catalog:getChildCollections()) do if name == v:getName() then return v end end end
My candidates collection is a top-level collection. I assume if I wanted it inside another collection I'd need nested loops.
Removing the photos from the collection is a simple Lightroom API call. To find photos I need to run a query against the catalog by invoking the catalog API call with a table argument that specifies the query. The full query I'm after is a bit involved, but just to ensure the logic for populating the collection is sound, I can use a simpler query.
ExportMenuItem.lua…
local function findCandidates() return catalog:findPhotos { searchDesc = { criteria = "rating", operation = ">=", value = 4, } } end
Once I'm at this point I know I can populate a collection, I just have to refine the query to get the photos I want.
The repeating intervals query
The basic query I have so far is a single part query. To do more complex queries I need to combine multiple filters. Lightroom allows me to specify queries with a table combining a list of filters together with a composition operator such as “intersect” (an “and”) or “union” (“or”). So to form a query for 3-star pictures in October 2014 I can use this query:
ExportMenuItem.lua…
local function easysearchDesc() return { { criteria = "rating", operation = ">=", value = 3, }, { criteria = "captureTime", operation = "in", value = "2014-10-01", value2 = "2014-10-31", value_units = "days", }, combine = "intersect", } end
That's a simpler search description than the one I need, but that's the essence of the approach. Each filter is a table with a criteria, operation, value, and perhaps other keys. (full details are in chapter 4 of the SDK guide).
As well as the filters the query table for findPhotos
can also
incorporate sorting, so my findCandidates
function looks like this:
ExportMenuItem.lua…
local function findCandidates() return catalog:findPhotos { sort = "captureTime", ascending = true, searchDesc = searchDesc() } end
For my search description I need to combine my date ranges together with a couple of other filter criteria.
ExportMenuItem.lua…
local function searchDesc() result = { combine = "intersect", { criteria = "rating", operation = ">=", value = 3, }, { criteria = "keywords", operation = "noneOf", value = "Thoughtworks,geeks,performance", value2 = "", }, } table.insert(result, dateRanges()) return result end
My search description table starts by saying I want to intersect a bunch of
filters, following by two filters that select high rated photos and remove some
keywords that I don't want for this query. Finally it adds a third filter which I
generate with the dateRanges()
function.
ExportMenuItem.lua…
local startFragment = "-11-01" local endFragment = "-12-01" local years = {"2005", "2006", "2007", "2008", "2009", "2010", "2011", "2012", "2013", "2014"}
local function rangeForYear(year) return { criteria = "captureTime", operation = "in", value = year..startFragment, value2 = year..endFragment, value_units = "days", } end local function dateRanges() local result = { combine = "union", } for k,v in pairs(years) do table.insert(result, rangeForYear(v)) end return result end
This filter combines filters for each year using the same date range. I keep the start, end, and years in variables at the top of my code file so I can easily find and change them, since these are the things I have to change most often.1
1:
You might find it odd that I've enumerated the years in a literal list rather than generated with a simple function, such as
local latestYear = 2015 local function yearsFn() result = {} for i = 2004, latestYear do table.insert(result, tostring(i)) end return result end
I did try it, but it didn't work. I couldn't find any difference between the explicit table and the one I generated.
Reflections
The script does the job pretty well. It's annoying that I have to go through the whole
, hit Reload Plug-in, hit Done, and dance every time I want to change the query, but that's infrequent enough that I can live with it. It shouldn't be too hard to fix this by just importing and evaluating some Lua code each time the function is run, after all Lua is designed to be dynamic like that. But I haven't summoned up enough energy to figure that out yet.This is my first time programming with Lua. It's a pretty nice language, not too difficult to get the hang of. But maybe I'm old and crotchety: I feel life would be better if everyone just used a lisp for their extension language. The documentation on Lua is a bit thin on the ground, there's a nice basic online reference but is just enough out of date to cause some annoyances.
The Lightroom environment is pretty reasonable and there's a detailed manual and API guide in the SDK download. There are some differences between Lightroom Lua and regular Lua, which added a few speed bumps, but I still made reasonable progress considering it is an unfamiliar environment. The experience certainly encourages me to consider some other scripting tasks in Lightroom should I feel the need.
The biggest frustration really with this is that like many such scripting extensions, I'm limited in what I can get my hands on. Although it's good to have some form of scripting, what I really want is Internal Reprogrammability. I'd like to easily create my own little bits of behavior and weave them seamlessly into the Lightroom environment to smooth my workflow. I get spoiled that I can do that with my text editor and command line, I want the same facility for other tools I use regularly.
Footnotes
1: Generating the years with a function
You might find it odd that I've enumerated the years in a literal list rather than generated with a simple function, such as
local latestYear = 2015 local function yearsFn() result = {} for i = 2004, latestYear do table.insert(result, tostring(i)) end return result end
I did try it, but it didn't work. I couldn't find any difference between the explicit table and the one I generated.
Significant Revisions
21 November 2015: