01 January 2016

Naming Photos with AppleScript and Python in Photos.app on OS X

I started using Apple’s Photos App a few months ago, and am quite impressed with it. But it has a few issues for me.

My long-established workflow consists of

  1. shooting raw files
  2. sorting and discarding most of them in Adobe Bridge
  3. converting the better ones to JPEGs and adjusting as appropriate in Photoshop
  4. naming the files containing the photos with a suitable CamelCase titles
  5. storing photos in hierarchical directories with names reflecting dates, locations and events
  6. importing the resulting folders into iPhoto for viewing, syncing etc.

This produced what are now referred to as iPhoto events with reasonably useful names, and I could search on the filename.

Importing things into Apple Photos created a couple of problems.

  • The iPhoto events are all relegated to albums inside and iPhoto Events folder.
  • The photo (file) names, while searchable on OS X, are invisible except through the metadata viewer; on iOS they’re completey invisible.

Obviously, the filename is not really the right place to store the title, especially if, like me, you’re a command-line guy who is allergic to spaces in paths (hence CamelCase). So it seems natural to want the file names to get promoted to photo titles in the EXIF data, preferably converted from CamelCase.

Ideally the way I’d like to do this is just to write a script, preferably in Python, to go through and set this. But how to do that once the pictures are ghetto’d inside the Photos App? I could set them externally and reimport them all, but that didn’t seem ideal, especially since I’ve invested in a fair amount of album structure. So the alternative is to try to script this change while leaving the photos in the app. But again, how?

There seemed to me to be two main possibilities. The first was to use whatever API’s Apple provides to photos in the Photos App to make the change using either Objective C or, more likely today, Swift. The other possibility was to see whether AppleScript could help, probably linking to a shell script or Python script or similar.

Problem #1: Apple’s App Names are Almost Unsearchable

In some ways, it’s hard to argue with Apple’s new app naming policy: rather than old-style names like iPhoto, iCal, iMessage, it now simply calls them Photos, Calendar and Messages. (Will Safari become Web and iTunes become Music?) This is commendably straightforward, but makes searching for information about these applications remarkably hard. Obviously searching on “Photos” is hopeless, but “Apple Photos”, “Apple Photos App” etc. are not that much better, because the names are too generic. Even searching Apple’s own Developer site for information about interfacing to the Photos App is hard, because there are too many references to photos (and Apple, and App) most of which are not to do with the Apple Photos App. Just as some techies talk about Mail.app, you can talk about Photos.app, but it only really helps if people have described it as that; and most don’t.

Anyway, the result is I gave up looking for information about how to access photos in the app from Swift.

Problem #2: AppleScript

I have never really used AppleScript. It looks like a toy language that’s trying a little too hard to look like natural language. And whenever I start reading Apple’s documentation on AppleScript I struggle to find anywhere that it succinctly describes how to use it.

But today, I found two things that help a lot. The first is how you find out what AppleScript functions are available in an application. It turns out, you do this:

  1. Open Script Editor
  2. From the File menu, choose Open Dictionary...
  3. Pick the app you are interested in from the list (in this case, Photos) …

… and lo, you will be presented with a list of functions you can use with that App. This helps considerably.

The second thing that helped me tremendously, was a useful cheat-sheet for AppleScript. Aurelio Jargas has a page AppleScript for Python Programmers (Comparison Chart) which very succinctly lists the basic hundred-odd things you need to know about AppleScript. This is great. The fact that it translates from Python especially convenient for me, but they key thing is that it translates between AppleScript and almost anything else mainstream.

So I present this photonames.scpt:

set basecmd to "python /Users/njr/python/misc/decamel.py \""
tell application "Photos"
    set N to count media item
    repeat with i from 1 to N
        if name of media item i is in {missing value, ""} then
            set cmd to basecmd & filename of media item i & "\""
            set newname to do shell script cmd
            if newname ≠ "" then
                set name of media item i to newname
                say newname
            end if
        end if
        if i mod 10 = 0 then
            say i
        end if
    end repeat
end tell

This isn’t very hard to follow, but I’ll talk through it:

  1. The first line sets a variable, basecmd to the start of the shell command I want to run to work out the picture name. I’ve written a small Python script to do this, called decamel.py, which gets passed the filename in double quotes. basecmd includes the open quote, which is escaped with a backslash.

  2. In AppleScript, whenever you want to use AppleScript functions from an application App, this needs to be in a tell application "App" block, hence `tell application “Photos”

  3. The next line sets N to the number of items in the library. (media item is the class of objects in the application to be counted.)

  4. The repeat line is a for loop (AppleScript indexes from 1)

  5. The items in the library can be accessed by number (media item i). Braces {...} are used to enclose ordered lists, and missing value is AppleScript’s null. So this condition restrict us to operating only on photos whose name is set and non-empty.

  6. String concatenation is performed with & and filename of media item i is the filename (excluding the directory) of the ith photo. So the next line sets cmd to the command we want to run.

  7. We run assign the output of running the shell script to newname

  8. If that result was was non-empty (note the not equal operator ), we assign the name of the photo to the result of running the script.

  9. I haven’t yet found a way to get the script simply to show what it’s doing by printing to stdout or stderr (it can pop up dialogue boxes, but that’s the last thing I want). But I can get it to speak, so slightly oddly I get it to speak any new titles it assigns using the say command. This is quite slow, but quite effective and fun. Obviously, delete that line if you don’t like it.

  10. The lack of an easy print function also makes progress reporting hard. Again, I decided to get it it speak its progress. So the final conditional gets it to speak the number it has processed after every tenth photo. Obviously, adjust that 10 for less (or more) frequent progress reporting.

That’s it, apart from the Python.

The Python Script is very simple. It just has to know how to recover spaced words from CamelCase. This does it.

# -*- coding: utf-8 -*-
import sys
import unittest

prefixes = (u'img', u'image', u'dsc', u'cnv')

def decamel(utf8):
    camel = utf8.decode('UTF-8')
    lc = camel.lower()
    for prefix in prefixes:
        if lc.startswith(prefix):
            return ""

    lastIsUpper = True
    words = []
    word = u''
    for i, c in enumerate(camel):
        if c == u'.' and lc[i:].lower() in (u'.jpg', u'.jpeg'):
            break
        elif c.isupper():
            if lastIsUpper:
                if word == u'A':
                    words.append(word)
                    word = c
                else:
                    word += c
            elif word:
                words.append(word)
                word = c
            lastIsUpper = True
        else:
            lastIsUpper = False
            word += c
    if word:
        words.append(word)
    return u' '.join(words).encode('UTF-8')


class TestDeCamel(unittest.TestCase):
    def testDeCamel(self):
        cases = {
            'IMG001.jpg': '',
            'IMAGE0001.jpg': '',
            'img': '',
            'image': '',

            'OneTwo': 'One Two',
            'oneTwo': 'one Two',
            'OneTwo.jpeg': 'One Two',
            'oneTwo.JPG': 'one Two',
            'ABird': 'A Bird',
            'SongForABird': 'Song For A Bird',
        }

        for orig, result in cases.items():
            self.assertEqual(decamel(orig), result)


if __name__ == '__main__':
    if len(sys.argv) == 2:
        arg = sys.argv[1]
        if arg in ('-h', '-help', '--help'):
            print '\nUSAGE: python decamel.py UTF8-filename\n   or:'
            print '       python decamel.py (to run tests)\n'
        else:
            print decamel(arg)
    elif len(sys.argv) == 1:
        unittest.main()
    else:
        print ""

As you can see, there are a few exclusions: it doesn’t construct a title if it looks like the filename comes straight out of a camera or scanner. You may need to adjust this if you have files with different automatically generated names. It turns out I hadn’t realise I have a few with names like PictureNNN.JPG and a few more with names like 011_11.JPG etc., so I would probably have been better to use a list of regular expressions rather than prefixes, but this was a 98% solution for me.

Labels: , , ,