~mro/Photos2Atom

064550924762b095d120f5f2fb42784f29d8b4cf — Marcus Rohrmoser 8 years ago 4675acc
a first – uncomplete – stab at Photos® support #1.
7 files changed, 143 insertions(+), 197 deletions(-)

M Gemfile
M Gemfile.lock
R iPhotoYaml2Atom.rb => Photos2Atom.rb
D iPhoto2yaml.rb
D keywords.sql
M run.sh
A sample.atom
M Gemfile => Gemfile +1 -1
@@ 1,4 1,4 @@
source "https://rubygems.org"

gem 'CFPropertyList'
gem 'sqlite3'
gem 'ratom'

M Gemfile.lock => Gemfile.lock +5 -2
@@ 1,14 1,17 @@
GEM
  remote: https://rubygems.org/
  specs:
    CFPropertyList (2.3.1)
    libxml-ruby (2.8.0)
    ratom (0.9.0)
      libxml-ruby (~> 2.6)
    sqlite3 (1.3.11)

PLATFORMS
  ruby

DEPENDENCIES
  CFPropertyList
  ratom
  sqlite3

BUNDLED WITH
   1.10.6

R iPhotoYaml2Atom.rb => Photos2Atom.rb +74 -42
@@ 20,19 20,19 @@

if ARGV[0].nil? || '-h' == ARGV[0] || '-?' == ARGV[0]
  s = <<EOF
Usage: #{__FILE__} <iphoto yaml> <album name> <base url> <atom file output>
Usage: #{__FILE__} <Fotos-Mediathek.photoslibrary> <album name> <base url> <atom file output>

<iphoto yaml>   as produced by iPhoto2yaml.rb
<album name>    iPhoto album to extract as a Atom photo feed
<base url>      base url after deployment
<atom file>     local Atom file to create (cache)
<Fotos-Mediathek.photoslibrary> complete filesystem path, usually inside $HOME/Pictures/
<album name>                    Fotos album to extract as a Atom photo feed
<base url>                      base url after deployment
<atom file>                     local Atom file to create (cache)

EOF
  $stderr.puts s
  exit
end

require 'yaml'
require 'sqlite3' # http://www.rubydoc.info/gems/sqlite3/1.3.11
require 'uri'
require 'digest/sha1'
require 'atom'            # https://github.com/seangeo/ratom


@@ 97,19 97,18 @@ def sh_escape str
end

module MRO
  class IPhoto
  class Photos

    @@CACHE = {}

    def self.open albumDataYaml
      @@CACHE[albumDataYaml] || (@@CACHE[albumDataYaml] = self.new(albumDataYaml))
    def self.open fotosMediathekFilePath
      self.new(fotosMediathekFilePath)
    end

    def initialize albumDataYaml
    def initialize fotosMediathekFilePath
      t0 = Time.now
      @AlbumData = File.open(albumDataYaml, 'r') { |yaml| YAML.load(yaml) }
      $stderr.puts "yaml load dt = #{Time.now - t0} s"
      @LibraryPath = File.dirname(albumDataYaml)
      @DB = SQLite3::Database.new(File.join(fotosMediathekFilePath, 'Database', 'Library.apdb'))
      @LibraryPath = fotosMediathekFilePath
    end

    def libraryPath


@@ 117,17 116,50 @@ module MRO
    end

    def album name
      @AlbumData[:albums].each{|a| return a if a['AlbumName'] == name}
      nil
      raise "Not supported."
    end

    def album_images album
      # album['KeyList'].sort{|x,y| @AlbumData['Master Image List'][x]['DateAsTimerIntervalGMT'] <=> @AlbumData['Master Image List'][y]['DateAsTimerIntervalGMT'] }.reverse.collect{|key| self.image(key)}
      album['KeyList'].collect{|k| self.image(k)}.sort{|x,y| x[:created] <=> y[:created]}.reverse
    def find_images_for_album album_name, &block
      sql = <<END_OF_SQL
SELECT
  version.name AS imageName
  , version.latitude AS latitude
  , version.longitude AS longitude
  , datetime(master.imageDate + 978307200, 'unixepoch', 'localtime') AS imageDate
  , master.imagePath
  , master.uuid
  , master.modelId
  , version.modelId
--  , keyword.name
FROM RKMaster AS master
INNER JOIN RKVersion as version
ON version.masterId = master.modelId
INNER JOIN RKAlbumVersion as album4version
ON album4version.versionId = version.modelId
INNER JOIN RKAlbum as album
ON album.modelId = album4version.albumId
-- INNER JOIN RKKeywordForVersion as keyword4version
-- ON keyword4version.versionId = version.modelId
-- INNER JOIN RKKeyword as keyword
-- ON keyword.modelId = keyword4version.keywordId
WHERE album.name = ?
ORDER BY imageDate DESC
END_OF_SQL

      @DB.execute(sql, album_name) {|row| yield row}
    end

    def image key
      @AlbumData[:images][key]
    def find_keywords_for_image_version_id image_version_id, &block
      sql = <<END_OF_SQL
SELECT DISTINCT keyword.name AS keywrd
FROM RKKeyword as keyword
INNER JOIN RKKeywordForVersion as keyword4version
ON  keyword4version.keywordId = keyword.modelId
AND keyword4version.versionId = ?
ORDER BY keyword.name ASC
END_OF_SQL

      @DB.execute(sql, image_version_id) {|row| yield row[0]}
    end
  end



@@ 135,7 167,6 @@ module MRO
    def initialize iphoto, album_name, base_url, dst_atom_file
      @IPHOTO = iphoto
      @ALBUM_NAME = album_name
      @ALBUM = @IPHOTO.album album_name
      @BASE_URL = base_url
      @ATOM_FILE = File.expand_path dst_atom_file
      @ATOM_URL = @BASE_URL + File.basename( @ATOM_FILE )


@@ 155,18 186,18 @@ module MRO

      maxDate = Time.at 0
      feed = Atom::Feed.new do |f|
        f.title = @ALBUM['AlbumName']
        f.title = @ALBUM_NAME
        f.links << Atom::Link.new(:href => @BASE_URL + 'photos.atom', :rel => :self)
        f.generator = Atom::Generator.new(:uri => 'http://purl.mro.name/iPhoto2Atom', :name => 'iPhoto2Atom')
        f.authors << Atom::Person.new(:name => 'John Doe')
        f.id = @BASE_URL # "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6"
        @IPHOTO.album_images(@ALBUM).each do |img|
        @IPHOTO.find_images_for_album(@ALBUM_NAME) do |row|
          # $stderr.puts "#{img[:created]} #{img['Caption']}"
          unless 'Image' == img['MediaType']
            $stderr.write "-(#{img['MediaType']})"
            next
          end
          src = File.exist?( img['ImagePath'] ) ? img['ImagePath'] : img['OriginalPath']
          # unless 2 == row[MediaType]
          #   $stderr.write "-(#{img['MediaType']})"
          #   next
          # end
          src = File.join @IPHOTO.libraryPath, 'Masters', row[4]
          raise src unless src.start_with?( @IPHOTO.libraryPath )
          dst_fmt = case File.extname(src)
            when /png/i then :png


@@ 180,26 211,27 @@ module MRO
              e.id = @BASE_URL + ('#_' + sha)
              # use GUID?
              # e.id = "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a"
              e.updated = img[:created]
              maxDate = img[:created] if img[:created] > maxDate
              e.title = img['Caption']
              e.summary = img['Comment']
              create_date = Time.parse row[3]
              e.updated = create_date
              maxDate = create_date if create_date > maxDate
              e.title = row[0]
              e.summary = ' '
              # use Rating ?
              dir2pxls.each do |dir,h|
                dst = File.join(@ATOM_DIR, dir, "#{sha}.#{dst_fmt}")              # (destination) file extension not part of sha.
                raise "Wrong type '#{img[:created].class}'" unless img[:created].kind_of? Time
                raise "Wrong type '#{create_date.class}'" unless create_date.kind_of? Time
                # unless FileUtils.uptodate?(dst, [src])                            # wrong, compare timestamp to img[:created]
                if !File.exist?(dst) || File.mtime(dst) < img[:created]
                if !File.exist?(dst) || File.mtime(dst) < create_date
                  $stderr.write '+'
                  FileUtils.cp(src, dst)
                  unless h[:pxls].nil?
                    # @todo - keep original pngs in high-res
                    # https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/sips.1.html
                    cyear = [img[:created].year, Time.now.year].uniq.join('-')
                    cyear = [create_date.year, Time.now.year].uniq.join('-')
                    properties = {
                      :copyright      => "© #{cyear}, Marcus Rohrmoser, http://mro.name/me",
                      :artist         => 'http://mro.name/me',
                      :description    => [img['Caption'], img['Comment']].collect{|s| '' == s ? nil : s}.flatten.collect{|s| s.strip}.join(' / '),
                      :description    => [row[0], nil].flatten.compact.collect{|s| '' == s ? nil : s}.flatten.collect{|s| s.strip}.join(' / '),
                      :format         => dst_fmt,
                      :formatOptions  => h[:quality],
                    }


@@ 211,7 243,7 @@ module MRO
                    end
                    # @todo optional run pngquant / optipng
                  end
                  FileUtils.touch dst, :mtime => img[:created]+1
                  FileUtils.touch dst, :mtime => create_date+1
                else
                  $stderr.write '.'
                end


@@ 219,13 251,13 @@ module MRO
              end
              e.content = Atom::Content::External.new :src => (@ATOM_URL + ('1600p' + '/') + "#{sha}.#{dst_fmt}").to_s, :type => "image/#{dst_fmt}"
              e.media_thumbnail = Media::Thumbnail.new :url => (@ATOM_URL + ('200p' + '/') + "#{sha}.#{dst_fmt}")
              (img[:keywords] || []).each do |keyword|
                e.categories << Atom::Category.new( :scheme => @BASE_URL, :term => keyword, :label => keyword )
              @IPHOTO.find_keywords_for_image_version_id(row[7]) do |keywor|
                e.categories << Atom::Category.new( :scheme => @BASE_URL, :term => keywor, :label => keywor )
              end
              e.georss_point = "#{img['latitude']} #{img['longitude']}" if img['latitude'] or img['longitude']
              e.georss_point = "#{row[1]} #{row[2]}" if row[1] or row[2]
            end
          rescue Exception => e
            $stderr.puts img
            $stderr.puts row
            $stderr.puts e
          end
        end


@@ 241,7 273,7 @@ module MRO
  end
end

ip = MRO::IPhoto.open ARGV[0]
ip = MRO::Photos.open ARGV[0]
ARGV.shift

until ARGV[2].nil?

D iPhoto2yaml.rb => iPhoto2yaml.rb +0 -113
@@ 1,113 0,0 @@
#!/usr/bin/env ruby
#
# iPhoto2Atom, extract images from iPhoto™ libraries
# Copyright (C) 2015  Marcus Rohrmoser, http://purl.mro.name/iPhoto2Atom
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

if ARGV[0].nil? || '-h' == ARGV[0] || '-?' == ARGV[0]
  s = <<EOF
Read iPhoto AlbumData.xml (plist), gently convert to AlbumData.yaml.

Usage: #{__FILE__} <.../iPhoto-Mediathek.photolibrary>
EOF
  $stderr.puts s
  exit
end

require 'cfpropertylist'
require 'yaml'

IPhotoLibPath = ARGV[0]
AlbumDataPlist = File.join( IPhotoLibPath, 'AlbumData.xml' )
AlbumDataYaml  = File.join( IPhotoLibPath, 'AlbumData.yaml' )
KeywordsYaml   = File.join( IPhotoLibPath, 'Keywords.yaml' )

unless FileUtils.uptodate?(AlbumDataYaml, [AlbumDataPlist])
  $stderr.puts "load #{AlbumDataPlist}"
  plist = CFPropertyList::List.new(:file => AlbumDataPlist)
  data = CFPropertyList.native_types(plist.value)

  data[:faces] = {}
  data['List of Faces'].each do |key,face|
    face['key'] = face['key'].to_i
    face['PhotoCount'] = face['PhotoCount'].to_i
    face[:image_keys] = []
    data[:faces][ face['key'] ] = face
  end
  data.delete 'List of Faces'

  T0 = Time.parse('2001-01-01 00:00:00Z')
  raise "ouch" if T0.nil?

  # List of Rolls, round I
  data[:rolls] = {}
  data['List of Rolls'].each do |roll|
    roll[:roll_date] = (T0 + roll['RollDateAsTimerInterval']).localtime #.strftime('%FT%T%z').gsub(/:(\d\d)$/, "\\1")
    roll.delete 'RollDateAsTimerInterval'
    roll['KeyList'].collect!{|key| key.to_i}
    roll['KeyPhotoKey'] = roll['KeyPhotoKey'].to_i
    data[:rolls][ roll['RollID'] ] = roll
  end
  data.delete 'List of Rolls'

  data[:images] = {}
  data['Master Image List'].each do |key,img|
    img[:created] = (T0 + img['DateAsTimerInterval']).localtime #.strftime('%FT%T%z').gsub(/:(\d\d)$/, "\\1")
    img.delete 'DateAsTimerInterval'
    img.delete 'DateAsTimerIntervalGMT'
    img[:modified] = (T0 + img['ModDateAsTimerInterval']).localtime #.strftime('%FT%T%z').gsub(/:(\d\d)$/, "\\1")
    img.delete 'ModDateAsTimerInterval'
    img.delete 'ModDateAsTimerIntervalGMT'
    img[:meta] = (T0 + img['MetaModDateAsTimerInterval']).localtime #.strftime('%FT%T%z').gsub(/:(\d\d)$/, "\\1")
    img.delete 'MetaModDateAsTimerInterval'
    img['Faces'].each do |f|
      f['face key'] = f['face key'].to_i
      data[:faces][ f['face key'] ][:image_keys] <<= key.to_i
      # f[:face][:image_keys] <<= key.to_i
    end unless img['Faces'].nil?
    data[:images][key.to_i] = img
  end
  data.delete 'Master Image List'

  data[:albums] = data['List of Albums'].collect do |album|
    album['KeyPhotoKey'] = album['KeyPhotoKey'].to_i
    album['KeyList'].collect!{|key| key.to_i}
    unless album['ProjectEarliestDateAsTimerInterval'].nil?
      album[:project_earliest] = (T0 + album['ProjectEarliestDateAsTimerInterval']).localtime #.strftime('%FT%T%z').gsub(/:(\d\d)$/, "\\1")
      album.delete 'ProjectEarliestDateAsTimerInterval'
    end
    album
  end
  data.delete 'List of Albums'

  # optional: Keywords.yaml
  if File.exists? KeywordsYaml
    keywords = File.open(KeywordsYaml, 'r') {|f| YAML.load f}
    keywords.each do |image_key, keywords|
      if data[:images][image_key].nil?
        $stderr.puts "how odd, no image #{image_key} to add keywords to."
      else
        data[:images][image_key][:keywords] = keywords
      end
    end
  end

  File.open(AlbumDataYaml, 'w') { |yaml| yaml.puts(YAML.dump(data)) }
  $stderr.puts "saved #{AlbumDataYaml}"
else
  $stderr.puts "found #{AlbumDataYaml}"
  # data = File.open( AlbumDataYaml, 'r') { |yaml| YAML.load(yaml) }
  # puts YAML.dump(data)
end

D keywords.sql => keywords.sql +0 -13
@@ 1,13 0,0 @@
SELECT DISTINCT
  RKVersion.modelId as image_key
  , RKKeyword.name as keyword_name
FROM
  RKKeywordForVersion
INNER JOIN RKVersion
ON RKKeywordForVersion.versionId = RKVersion.modelId
INNER JOIN RKKeyword
ON RKKeyword.modelId = RKKeywordForVersion.keywordId
-- WHERE RKKeywordForVersion.keywordId = 62
ORDER BY
  image_key ASC
  , keyword_name ASC;

M run.sh => run.sh +3 -26
@@ 27,31 27,7 @@ cd "$(dirname "$0")"
script_dir="$(pwd)"
# cd "$cwd"

photolibrary="$HOME/Pictures/iPhoto-Mediathek.photolibrary"

# extract keywords if sqlite3 is present.
if $(sqlite3 --version >/dev/null) ; then
  dst="$photolibrary/Keywords.yaml"
  echo "---" > "$dst~"
  sqlite3 -separator ' ' "$photolibrary/Database/Library.apdb" < "$script_dir/keywords.sql" \
  | while read image_key keyword_name
  do
    if [ "$recent_image_key" != "$image_key" ] ; then
      echo "$image_key:" >> "$dst~"
    fi
    recent_image_key="$image_key"
    echo "  - \"$keyword_name\"" >> "$dst~"
  done
  mv "$dst~" "$dst"
else
  echo "Optional: Install sqlite3 for keywords." 1>&2
fi

# prepare the iPhoto library yaml (3x faster loading)
bundle exec ruby "$script_dir/iPhoto2yaml.rb" "$photolibrary"
if [ $? -ne 0 ] ; then
  exit 1
fi
photolibrary="$HOME/Pictures/Fotos-Mediathek.photoslibrary"

#############################################################################
## below here is to


@@ 100,6 76,7 @@ EOF
    # add xslt file
    rsync -aP --exclude .DS_Store --delete --delete-excluded "$script_dir/assets" "$dir/"
    # run xslt => index.html
    # rm "$dir/index.html"
    # xsltproc --output "$dir/index.html" "$dir/assets/atom2html.xslt" "$file"
  fi
}


@@ 112,7 89,7 @@ do
  cache="$HOME/Library/Caches/name.mro.iPhoto2Atom/$subdir"

  mkdir -p "$cache" \
  && bundle exec ruby "$script_dir/iPhotoYaml2Atom.rb" "$photolibrary/AlbumData.yaml" "$album" "${dst_base_url_parent}$subdir/" "$cache/photos.atom"  \
  && bundle exec ruby "$script_dir/Photos2Atom.rb" "$photolibrary" "$album" "${dst_base_url_parent}$subdir/" "$cache/photos.atom"  \
  && process_xslt "$cache/photos.atom" \
  && rsync -avPz --exclude .DS_Store --delete --delete-excluded "$cache" "${dst_dir_parent}$(dirname "$subdir")"
done

A sample.atom => sample.atom +60 -0
@@ 0,0 1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="assets/atom2html.xslt"?>
<!--
  https://developer.mozilla.org/en/docs/XSL_Transformations_in_Mozilla_FAQ#Why_isn.27t_my_stylesheet_applied.3F

  Note that Firefox will override your XSLT stylesheet if your XML is
  detected as an RSS or Atom feed. A known workaround is to add a
  sufficiently long XML comment to the beginning of your XML file in
  order to "push" the <.feed> or <.rss> tag out of the first 512 bytes,
  which is analyzed by Firefox to determine if it's a feed or not. See
  the discussion on bug
  https://bugzilla.mozilla.org/show_bug.cgi?id=338621#c72 for more
  information.


  For best results serve both atom feed and xslt as 'text/xml' or
  'application/xml' without charset specified.
-->
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:georss="http://www.georss.org/georss">
  <id>https://drop.mro.name/galleries/demo/</id>
  <generator uri="http://purl.mro.name/iPhoto2Atom">iPhoto2Atom</generator>
  <title>Demo Album</title>
  <updated>2015-04-16T17:57:46+02:00</updated>
  <link rel="self" href="https://drop.mro.name/galleries/demo/photos.atom"/>
  <author>
    <name>John Doe</name>
  </author>
  <entry>
    <title>Osterglocken</title>
    <id>https://drop.mro.name/galleries/demo/#_022e7cb3e8b9d44155e7ce17866f629cd1418708</id>
    <summary> </summary>
    <updated>2015-04-16T17:57:46+02:00</updated>
    <link rel="enclosure" length="243347" href="https://drop.mro.name/galleries/demo/1600p/022e7cb3e8b9d44155e7ce17866f629cd1418708.jpeg"/>
    <link rel="previewimage" length="11621" href="https://drop.mro.name/galleries/demo/200p/022e7cb3e8b9d44155e7ce17866f629cd1418708.jpeg"/>
    <content type="image/jpeg" src="https://drop.mro.name/galleries/demo/1600p/022e7cb3e8b9d44155e7ce17866f629cd1418708.jpeg"/>
    <media:thumbnail url="https://drop.mro.name/galleries/demo/200p/022e7cb3e8b9d44155e7ce17866f629cd1418708.jpeg"/>
  </entry>
  <entry>
    <title>IMG_7639</title>
    <id>https://drop.mro.name/galleries/demo/#_e141de41c8e8e426b1bf3e72c18999987b04e22a</id>
    <summary> </summary>
    <updated>2015-03-24T17:20:44+01:00</updated>
    <link rel="enclosure" length="191218" href="https://drop.mro.name/galleries/demo/1600p/e141de41c8e8e426b1bf3e72c18999987b04e22a.png"/>
    <link rel="previewimage" length="13416" href="https://drop.mro.name/galleries/demo/200p/e141de41c8e8e426b1bf3e72c18999987b04e22a.png"/>
    <category label="Screen" scheme="https://drop.mro.name/galleries/demo/" term="Screen"/>
    <content type="image/png" src="https://drop.mro.name/galleries/demo/1600p/e141de41c8e8e426b1bf3e72c18999987b04e22a.png"/>
    <media:thumbnail url="https://drop.mro.name/galleries/demo/200p/e141de41c8e8e426b1bf3e72c18999987b04e22a.png"/>
  </entry>
  <entry>
    <title>IMG_6735</title>
    <id>https://drop.mro.name/galleries/demo/#_fcc1302401173e66ad6ab86d42deee2486f1ad9f</id>
    <summary> </summary>
    <updated>2014-11-14T17:09:13+01:00</updated>
    <link rel="enclosure" length="215330" href="https://drop.mro.name/galleries/demo/1600p/fcc1302401173e66ad6ab86d42deee2486f1ad9f.jpeg"/>
    <link rel="previewimage" length="8682" href="https://drop.mro.name/galleries/demo/200p/fcc1302401173e66ad6ab86d42deee2486f1ad9f.jpeg"/>
    <content type="image/jpeg" src="https://drop.mro.name/galleries/demo/1600p/fcc1302401173e66ad6ab86d42deee2486f1ad9f.jpeg"/>
    <media:thumbnail url="https://drop.mro.name/galleries/demo/200p/fcc1302401173e66ad6ab86d42deee2486f1ad9f.jpeg"/>
    <georss:point>47.86138833 12.64393667</georss:point>
  </entry>
</feed>