Parsing an iPhoto catalog

Apple's iPhoto is a photo browser/cataloguer [L1 ]. It's not as widespread as iTunes, since it's only for the Mac and is part of their "iLife" suite.

Wanting to get the XML-based catalog data into a Metakit datafile, I came up with a Tcl script to do the work.

First a usage example:

  $ ls -l AlbumData.xml 
  -rw-r--r--   1 jcw  jcw  15241634 Jun 20 00:20 AlbumData.xml
  $ time iphoto2mk.tcl iphoto.db AlbumData.xml 
  iphoto.db (4786706 bytes):
    33 albums
    240 rolls
    16 keywords
    16776 images

  real    0m33.225s
  user    0m32.412s
  sys     0m0.424s

So while it's not very fast, it does get the job done and produced a useful dataset to play with.

The code is self-contained and uses a variation of TAX: A Tiny API for XML to do the parsing. Which is why the process takes a fair amount of memory and time to complete.

  #!/usr/bin/env tclkit85

  # Copyright (c) 2007 Jean-Claude Wippler
  # http://www.opensource.org/licenses/mit-license.php

  # Convert an iPhoto XML catalog to a Metakit datafile
  #
  # Usage: tclkit85 iphoto.tcl outfile ?infile?
  #
  # Will use iPhoto's default catalog if no input file is specified.
  # Requires Tcl 8.5 due to the use of dict and lassign.
  #
  # jcw, 2007-06-20

  package require Tcl 8.5
  package require Mk4tcl

  # TAX is a tiny SAX-like parser for XML, not perfect but good enough here
  # Adapted from https://wiki.tcl-lang.org/14534 by Eric Kemp-Benedict

  proc tax {cmd xml {top docstart}} {
    regsub -all {<(/?)([^\s/>]+)\s*([^/>]*)(/?)>} \
      [string map {\{ &ob; \} &cb;} $xml] "\};tax@ {\\2} {\\1} {\\4} {\\3} \{" xml
    eval "tax@ {$top} {} {} {} {$xml}; tax@ {$top} / {} {} {}"
  }

  proc tax@ {e a b p t} {
    set m {&ob; \{ &cb; \} &lt; < &gt; > &quot; \\\" &amp; & ' ' = " "}
    if {$b ne ""} { set u $t; set t "" }
    uplevel {$cmd} [list $e$a [string map $m $p] $t]
    if {$b ne ""} { uplevel {$cmd} [list $e/ $b $u] }
  }

  namespace eval props {
    namespace eval v { set out ""; set keyed 0 }

    proc key      {p t}   { 
      if {!$v::keyed} { error "keyed? $t" }
      set v::key $t 
    }
    proc string   {p t}   { tag $t }
    proc integer  {p t}   { tag $t }
    proc real     {p t}   { tag $t }

    proc true     {p t}   { tag 1 }
    proc false    {p t}   { tag 0 }

    proc dict     {p t}   { enter 1 }
    proc dict/    {p t}   { leave }    
    proc array    {p t}   { enter 0 }
    proc array/   {p t}   { leave }

    namespace export *

    proc tag {t} {
        if {$v::keyed} {
            append v::out " " [list $v::key $t]
        } else {
            append v::out " " [list $t]
        }
    }
    proc enter {k} {
      if {$v::keyed} {
            append v::out " " [list $v::key] " \{"
        } else {
            append v::out " \{"
      }
      lappend v::stack $v::keyed
      set v::keyed $k
    }
    proc leave {} {
      append v::out " \}" 
      set v::keyed [lindex $v::stack end]
      set v::stack [lrange $v::stack 0 end-1]
    }

    proc ?        {args}  {}

    namespace ensemble create \
      -unknown {apply {{args} { return "::props::? $args" }}}
  }

  proc get {vname {default ""}} {
    upvar $vname v
    if {![info exists v] || $v eq ""} { return $default }
    return $v
  }

  proc load2mk {data} {
    mk::view layout db.albums {
        id:I
        name
        keys
        master:I
        count:I
        play
        repeat
        rate:I
        titles:I
        playlist
        tdir:I
        tname
        tspeed:F
    }

    mk::view layout db.rolls {
        id:I
        name
        parent:I
        keys
        type
        count:I
    }

    mk::view layout db.keywords {
        id:I
        name
    }

    mk::view layout db.images {
        id:I
        media 
        caption
        comment
        aspect:F
        rating:I
        roll:I
        date:D
        mdate:D
        mmdate:D
        ipath
        opath
        tpath
    }

    foreach {id info} [dict get $data {List of Albums}] {
        dict with info {
            mk::row append db.albums \
                id       ${AlbumId} \
                name     ${AlbumName} \
                keys     ${KeyList} \
                master:I [get Master 0] \
                count:I  ${PhotoCount} \
                play     ${PlayMusic} \
                repeat   ${RepeatSlideShow} \
                rate     ${SecondsPerSlide} \
                titles   ${SlideShowUseTitles} \
                playlist [get PlaylistName] \
                tdir:I   ${TransitionDirection} \
                tname    ${TransitionName} \
                tspeed   ${TransitionSpeed}
        }
    }

    foreach {id info} [dict get $data {List of Rolls}] {
        dict with info {
            mk::row append db.rolls \
                id       ${AlbumId} \
                name     ${AlbumName} \
                parent   ${Parent} \
                keys     ${KeyList} \
                type     ${Album Type} \
                count    ${PhotoCount}
        }
    }

    foreach {id info} [dict get $data {List of Keywords}] {
        mk::row append db.keywords id $id name $info
    }

    foreach {id info} [dict get $data {Master Image List}] {
        dict with info {
            mk::row append db.images id $id \
                media    ${MediaType} \
                caption  ${Caption} \
                comment  ${Comment} \
                aspect   ${Aspect Ratio} \
                rating   ${Rating} \
                roll     ${Roll} \
                date     ${DateAsTimerInterval} \
                mdate    ${ModDateAsTimerInterval} \
                mmdate   ${MetaModDateAsTimerInterval} \
                ipath    ${ImagePath} \
                opath    [get OriginalPath] \
                tpath    ${ThumbPath}
        }
    }
  }

  lassign $argv outfile infile

  if {$outfile eq ""} {
    puts stderr "Usage: $argv0 outfile ?infile"
    exit 1
  }

  set fd [open [get infile "~/Pictures/iPhoto Library/AlbumData.xml"]]
  set data [read $fd]
  close $fd

  tax ::props $data

  set data [lindex $::props::v::out 0]
  #puts [dict keys $data]

  file delete $outfile
  mk::file open db $outfile

  load2mk $data

  mk::file commit db

  puts "$outfile ([file size $outfile] bytes):"
  foreach x {albums rolls keywords images} {
    puts "  [mk::view size db.$x] $x"
  }

Dates are not converted - they are stored as the same doubles present in the input file for now.