~mro/Photos2Atom

1f53026c5823c7eec55ba5e80e8c33a411770e73 — Marcus Rohrmoser 8 years ago ebf3d0c
trim some fat. refs #1
5 files changed, 232 insertions(+), 142 deletions(-)

M Gemfile
M Gemfile.lock
M Photos2Atom.rb
M README.md
M run.sh
M Gemfile => Gemfile +1 -1
@@ 1,4 1,4 @@
source "https://rubygems.org"

gem 'sqlite3'
gem 'ratom'
# gem 'ratom' # https://github.com/seangeo/ratom

M Gemfile.lock => Gemfile.lock +0 -4
@@ 1,16 1,12 @@
GEM
  remote: https://rubygems.org/
  specs:
    libxml-ruby (2.8.0)
    ratom (0.9.0)
      libxml-ruby (~> 2.6)
    sqlite3 (1.3.11)

PLATFORMS
  ruby

DEPENDENCIES
  ratom
  sqlite3

BUNDLED WITH

M Photos2Atom.rb => Photos2Atom.rb +218 -71
@@ 18,80 18,206 @@
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


if ARGV[0].nil? || '-h' == ARGV[0] || '-?' == ARGV[0]
if ! ARGV[4].nil? || ARGV[1].nil? || '-h' == ARGV[0] || '-?' == ARGV[0]
  s = <<EOF
Usage: #{__FILE__} <Fotos-Mediathek.photoslibrary> <album name> <base url> <atom file output>
NAME
  #{__FILE__} -- turn an Apple™ Photos™ Album into an Atom image feed.

<Fotos-Mediathek.photoslibrary> complete filesystem path, usually inside $HOME/Pictures/
<album name>                    Fotos album name to extract as a Atom photo feed
<base url>                      final base url (after deployment)
<atom file>                     local Atom file to create (cache)
SYNOPSIS
  #{__FILE__} album atom_url [atom2html.xslt] [Fotos-Mediathek.photoslibrary]

DESCRIPTION
  album                           Fotos album name to extract as a Atom photo feed
  atom_url                        final Atom file url (after deployment)
  atom2html.xslt                  xslt transformation URL to use in <?xml-stylesheet type="text/xsl" href="assets/atom2html.xslt"?> or '-'
  Fotos-Mediathek.photoslibrary   complete filesystem path, usually inside $HOME/Pictures/

EXAMPLES
  #{__FILE__} 'Public Images' https://example.com/images/demo/photos.atom
    simple usage, include default assets/atom2html.xslt

  #{__FILE__} 'Public Images' https://example.com/images/demo/photos.atom '-'
    simple usage, no atom2html.xslt

  #{__FILE__} 'Public Images' https://example.com/images/demo/photos.atom foo/atom2html.xslt $HOME/Pictures/*-Mediathek.photoslibrary
    explicit atom2html.xslt, explicit Photos™ media location

EOF
  $stderr.puts s
  exit
end

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

# adapted from https://github.com/jondot/ratom/commit/68c5e9f0c25bceaf484e497f7afb3c7f3ba3f7c3
module Media
  class Thumbnail
    include Atom::Xml::Parseable
    include Atom::SimpleExtensions
    # attribute :url, :fileSize, :type, :medium, :isDefault, :expression, :bitrate, :height, :width, :duration, :lang
    attribute :url

    def initialize(o = {})
      case o
      when XML::Reader
        parse(o, :once => true)
      when Hash
        o.each do |k, v|
        self.send("#{k.to_s}=", v)
      end
      else
        raise ArgumentError, "Got #{o.class} but expected a Hash or XML::Reader"
      end
########################################################################
## First comes a very simple Atom generator (API inspired by https://github.com/seangeo/ratom
########################################################################

      yield(self) if block_given?
    end
class Object
  def to_xml
    self.to_s.gsub(/[<>&"']/, {'<'=>'&lt;', '>'=>'&gt;', '&'=>'&amp;', '"'=>'&quot;', "'"=>'&apos;'})
  end
  Atom::Feed.add_extension_namespace :media, "http://search.yahoo.com/mrss/"
  Atom::Entry.element "media:thumbnail", :class => Thumbnail
end

# inspired by https://github.com/jondot/ratom/commit/68c5e9f0c25bceaf484e497f7afb3c7f3ba3f7c3
module GeoRss
  class Point
    include Atom::Xml::Parseable
    include Atom::SimpleExtensions
class Time
  def to_xml
    self.strftime('%FT%T%z').gsub(/(\d{2})$/, ":\\1")
  end
  Atom::Feed.add_extension_namespace :georss, 'http://www.georss.org/georss'
  Atom::Entry.element "georss:point", :class => Point
end

# monkey path to support <content src="..."/>
class Atom::Content::External
  def initialize(o = {})
    case o
    when XML::Reader
      parse(o, :once => true)
    when Hash
      o.each do |k, v|
        self.send("#{k.to_s}=", v)
module MRO
  module Atom
    class Basic
      def initialize
        @xml = ''
      end
      def id= v
        @xml <<= "<id>#{v.to_xml}</id>"
      end
      def title= v
        @xml <<= "<title>#{v.to_xml}</title>"
      end
      def updated= v
        @xml <<= "<updated>#{v.to_xml}</updated>"
      end
  end

    class Feed < Basic
      attr_writer :xslt
      attr_reader :links, :authors, :entries

      def initialize
        super
        @links = []
        @authors = []
        @entries = []
        yield self
        self
      end

      def generator= v
        @xml <<= v
      end

      def to_xml
        s = ''
        if @xslt
          s <<= "<?xml-stylesheet type='text/xsl' href='#{@xslt.to_xml}'?>"
          s <<= <<END_OF_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.
-->
END_OF_XSLT
        end
        s <<= '<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:georss="http://www.georss.org/georss">'
        s <<= @xml
        s <<= self.links.join('')
        s <<= "<author>"
        s <<= self.authors.join('')
        s <<= "</author>"
        s <<= self.entries.collect{|c| c.to_xml}.join('')
        s <<= '</feed>'
        s
      end
    end

    class Entry < Basic
      attr_reader :title, :summary, :links, :categories

      def initialize
        super
        @links = []
        @categories = []
        yield self
        self
      end

      def title= v
        super(@title = v)
      end
      def content= v
        @xml <<= v
      end
      def summary= v
        @summary = v
        @xml <<= "<summary>#{v.to_xml}</summary>" unless v.nil?
      end
      def georss_point= v
        @xml <<= "<georss:point>#{v.to_xml}</georss:point>"
      end
      def media_thumbnail= v
        @xml <<= v
      end

      def to_xml
        s = '<entry>'
        s <<= @xml
        s <<= self.links.join('')
        s <<= self.categories.join('')
        s <<= '</entry>'
        s
      end
    end

    class Link < String
      def initialize h
        super "<link #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}/>"
      end
    end
    class Category < String
      def initialize h
        super "<category #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}/>"
      end
    else
      raise ArgumentError, "Got #{o.class} but expected a Hash or XML::Reader"
    end
    module Content
      class External < String
        def initialize h
          super "<content #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}/>"
        end
      end
    end
    class Person < String
      def initialize h
        name = h[:name]
        h.delete :name
        super "<name #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}>#{name.to_xml}</name>"
      end
    end
    class Generator < String
      def initialize h
        name = h[:name]
        h.delete :name
        super "<generator #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}>#{name.to_xml}</generator>"
      end
    end
  end

    yield(self) if block_given?
  module Media
    class Thumbnail < String
      def initialize h
        super "<media:thumbnail #{h.map{|k,v| "#{k.to_xml}='#{v.to_xml}'"}.join(' ')}/>"
      end
    end
  end
end

########################################################################
## Then access Apple™ Photos™
########################################################################

require 'sqlite3' # http://www.rubydoc.info/gems/sqlite3/1.3.11

def sh_escape str
  '"' + "#{str}".gsub(/[\\\n"]/){|s| {'\\'=>'\\\\', "\n"=>'\\n', '"'=>'\\"'}[s]} + '"'
end


@@ 99,13 225,16 @@ end
module MRO
  class Photos

    @@CACHE = {}

    def self.open fotosMediathekFilePath
      self.new(fotosMediathekFilePath)
    end

    def initialize fotosMediathekFilePath
      Dir.glob( File.join(ENV['HOME'], 'Pictures', '*.photoslibrary') ) do |d|
        fotosMediathekFilePath = d
        break
      end if fotosMediathekFilePath.nil?
      $stderr.puts "Photos Library: #{fotosMediathekFilePath}"
      t0 = Time.now
      @DB = SQLite3::Database.new(File.join(fotosMediathekFilePath, 'Database', 'Library.apdb'), :readonly => true)
      @DB.execute("ATTACH DATABASE ? AS ImageProxies", File.join(fotosMediathekFilePath, 'Database', 'ImageProxies.apdb'))


@@ 117,6 246,13 @@ module MRO
      @LibraryPath
    end

    def cache_atom_file atom_url
      host = atom_url.host
      path = atom_url.path
      path = File.join(path, 'photos.atom') if path.end_with?('/')
      File.join(ENV['HOME'], 'Library', 'Caches', 'name.mro.iPhoto2Atom', host, path.split('/'))
    end

    def find_images_for_album album_name, &block
      sql = <<END_OF_SQL
SELECT


@@ 170,18 306,32 @@ END_OF_SQL
      @DB.execute(sql, image_version_id) {|row| yield row[0]}
    end
  end
end

########################################################################
## Then bring them together…
########################################################################

require 'uri'
require 'fileutils'
require 'digest/sha1'

module MRO
  class CFG
    def initialize iphoto, album_name, base_url, dst_atom_file
    def initialize iphoto, album_name, dst_atom_url
      @IPHOTO = iphoto
      @ALBUM_NAME = album_name
      @BASE_URL = base_url
      @ATOM_FILE = File.expand_path dst_atom_file
      @ATOM_URL = @BASE_URL + File.basename( @ATOM_FILE )
      @ATOM_DIR = File.dirname @ATOM_FILE
      @ATOM_URL   = dst_atom_url
      # do sanity checks in case

      @BASE_URL = @ATOM_URL + '.'
      @ATOM_FILE  = iphoto.cache_atom_file(@ATOM_URL)
      @ATOM_DIR   = File.dirname @ATOM_FILE
      $stderr.puts "Atom file     : #{@ATOM_FILE}"
      $stderr.puts "Atom URL      : #{@ATOM_URL}"
    end

    def produce_atom_feed
    def produce_atom_feed xslt_url
      # $stderr.puts "convert album '#{@ALBUM['AlbumName']}' to #{@ATOM_URL}"

      dir2pxls = {


@@ 194,8 344,9 @@ END_OF_SQL

      maxDate = Time.at 0
      feed = Atom::Feed.new do |f|
        f.xslt = xslt_url
        f.title = @ALBUM_NAME
        f.links << Atom::Link.new(:href => @BASE_URL + 'photos.atom', :rel => :self)
        f.links << Atom::Link.new(:href => @ATOM_URL, :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"


@@ 295,17 446,13 @@ END_OF_SQL

      File.open(@ATOM_FILE, 'w') {|dst| dst.puts feed.to_xml}
      FileUtils.touch @ATOM_FILE, :mtime => maxDate+1

      $stdout.puts "#{@ATOM_URL} #{@ATOM_FILE}"
    end
  end
end

ip = MRO::Photos.open ARGV[0]
ARGV.shift
photos = MRO::Photos.open ARGV[3]
xslt_url = ('-' == ARGV[2]) ? nil : URI::parse( ARGV[2].nil? ? 'assets/atom2html.xslt' : ARGV[2] )

until ARGV[2].nil?
  base_url = URI::parse ARGV[1]
  MRO::CFG.new(ip, ARGV[0], base_url, ARGV[2]).produce_atom_feed
  ARGV.shift 3
end
atom_url = URI::parse ARGV[1]
album = ARGV[0]
MRO::CFG.new(photos, album, atom_url).produce_atom_feed(xslt_url)

M README.md => README.md +1 -2
@@ 9,7 9,7 @@ galleries.
    $ [brew](http://brew.sh/) install exiftool
    $ # optional:
    $  sh ./install-lightbox2.sh
    $ vim run.sh # enter iPhoto path, album names, dst path, dst base URL
    $ vim run.sh # enter album names, dst URL, deployment destination
    $ sh ./run.sh

## Prerequisites


@@ 20,7 20,6 @@ plus the already present (on OS X):

- [ruby](http://www.ruby-lang.org/de/)
- [xmllint](http://xmlsoft.org/xmllint.html)
- [xsltproc](http://xmlsoft.org/XSLT/xsltproc.html)
- [sips](https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/sips.1.html)
- [rsync](http://rsync.samba.org/)


M run.sh => run.sh +12 -64
@@ 18,79 18,27 @@

ruby --version >/dev/null     || { echo "Install ruby" && exit 1 ; }
bundle --version >/dev/null   || { echo "$ gem install bundler" && exit 1 ; }
xmllint --version 2>/dev/null || { echo "Install libxml2" && exit 1 ; }
xsltproc --version >/dev/null || { echo "Install xsltproc" && exit 1 ; }
# xmllint --version 2>/dev/null || { echo "Install libxml2" && exit 1 ; }
rsync --version >/dev/null    || { echo "Install rsync" && exit 1 ; }
exiftool -ver >/dev/null      || { echo "Install exiftool, e.g. via $ brew install exiftool" && exit 1 ; }
exiftool -ver >/dev/null      || { echo "Install exiftool, e.g. $ brew install exiftool" && exit 1 ; }

cwd="$(pwd)"
cd "$(dirname "$0")"
script_dir="$(pwd)"
# cd "$cwd"

photolibrary="$HOME/Pictures/Fotos-Mediathek.photoslibrary"
########################################################
## Adjust below as appropriate

#############################################################################
## below here is to
## - generate atom feed per album
## - generate index.html
## - publish
bundle exec ruby Photos2Atom.rb 'Foo'	"http://example.com/wherever/foo/" &
bundle exec ruby Photos2Atom.rb 'Bar'	"http://example.com/somewhereelse/bar/" &

read -r -d '' albums <<EOF
example/subdir0 Example iPhoto Album Name 0
example/subdir1 Example iPhoto Album Name 1
example/sub/dir2 Example iPhoto Album Name 2
EOF
dst_dir_parent="hostname:/path/to/web/directory/parent/"  # trailing slash!
dst_base_url_parent="http://myserver.example/parent/"     # trailing slash!
wait

# a post-processing step.
function process_xslt () {
  file="$1"
  xmllint --noout --relaxng "$script_dir/rfc4287.rng" "$file"
  if [ $? ] ; then
    dir="$(dirname "$file")"
# put html assets in place for deployment:
find "${HOME}/Library/Caches/name.mro.iPhoto2Atom" -type d -name "200p" -exec rsync --delete -a assets "{}/.." \;

    # add xslt processing instruction, sadly useless however.
    cat > "$file"~ <<EOF
<?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
# xmllint -format -relaxng rfc4287.rng -noout "${HOME}/Library/Caches/name.mro.iPhoto2Atom/example.com/foo/album0/photos.atom"

  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.
-->
EOF
    tail +2 "$file" >> "$file"~
    xmllint --encode UTF-8 --output "$file" "$file"~ && rm "$file"~

    # 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
}

echo "$albums" | while read subdir album
do
  echo "##########################################################################"
  echo "## Album '$album'"

  cache="$HOME/Library/Caches/name.mro.iPhoto2Atom/$subdir"

  mkdir -p "$cache" \
  && 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
# deploy to production
rsynv -avPz --delete "${HOME}/Library/Caches/name.mro.iPhoto2Atom/example.com/" "example.com:/var/www/.../example.com/public_html/"