~mro/Photos2Atom

11717597c23b3b3e060ee7d831a06d88f6934f23 — Marcus Rohrmoser 4 years ago e11c5a0
no more ruby/gems, use imagemagick and be linux compatible.
Sacrifice gps (for now).
13 files changed, 220 insertions(+), 669 deletions(-)

M .gitignore
D Gemfile
D Gemfile.lock
A Makefile
D Photos2Atom.rb
M README.md
M assets/atom2html.xslt
M assets/style.css
A atom.sh
M doap.rdf
M run.sh -rwxr-xr-x => -rw-r--r--
D sample.atom
A src/.gitkeep
M .gitignore => .gitignore +2 -0
@@ 1,1 1,3 @@
src/
_build/
assets/lightbox2/

D Gemfile => Gemfile +0 -4
@@ 1,4 0,0 @@
source "https://rubygems.org"

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

D Gemfile.lock => Gemfile.lock +0 -13
@@ 1,13 0,0 @@
GEM
  remote: https://rubygems.org/
  specs:
    sqlite3 (1.3.11)

PLATFORMS
  ruby

DEPENDENCIES
  sqlite3

BUNDLED WITH
   1.10.6

A Makefile => Makefile +36 -0
@@ 0,0 1,36 @@
#
# Example
#
# $ make title='My Photos' base='https://example.com/sub/my-photos'
#
# See http://purl.mro.name/Photos2Atom
#

.PHONY: all build clean large thumb

_build/200/%: src/%
	@-mkdir -p _build/200
	@# https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/
	@# https://www.linuxquestions.org/questions/linux-software-2/question-about-thumbnail-orientation-dimensions-with-imagemagick-493034/
	convert $< -thumbnail 200x200 -auto-orient -quality 30% -strip -define png:exclude-chunk=all $@
	@touch -r "$<" "$@"

_build/1200/%: src/%
	@-mkdir -p _build/1200
	convert $< -resize 1200x1200\> -auto-orient -quality 82% -strip -define png:exclude-chunk=all $@
	@touch -r "$<" "$@"

build: thumb large _build/index.xml

all: build

clean:
	rm -rf _build

SRC := $(wildcard src/*)

thumb: $(patsubst src/%,_build/200/%,${SRC})
large: $(patsubst src/%,_build/1200/%,${SRC})

_build/index.xml: ${SRC}
	sh ./atom.sh '$(title)' '$(base)' $^ > $@

D Photos2Atom.rb => Photos2Atom.rb +0 -466
@@ 1,466 0,0 @@
#!/usr/bin/env ruby
# encoding: UTF-8
#
# iPhoto2Atom, extract images from Apple™ Photos™ libraries
# Copyright (C) 2015-2016  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[4].nil? || ARGV[1].nil? || '-h' == ARGV[0] || '-?' == ARGV[0]
  s = <<EOF
NAME
  #{__FILE__} -- turn an Apple™ Photos™ Album into an Atom image feed.

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

########################################################################
## First comes a very simple Atom generator (API inspired by https://github.com/seangeo/ratom
########################################################################

class Object
  def to_xml
    self.to_s.gsub(/[<>&"']/, {'<'=>'&lt;', '>'=>'&gt;', '&'=>'&amp;', '"'=>'&quot;', "'"=>'&apos;'})
  end
end

class Time
  def to_xml
    self.strftime('%FT%T%z').gsub(/(\d{2})$/, ":\\1")
  end
end

module MRO
  module Atom
    class Basic
      attr_reader :authors
      def initialize
        @authors = []
        @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, :entries

      def initialize
        super
        @links = []
        @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 <<= '<author>'
        s <<= self.authors.join('')
        s <<= '</author>'
        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
    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
        super h.map{|k,v| "<#{k}>#{v.to_xml}</#{k}>"}.join(' ')
      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

  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

module MRO
  class Photos

    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'))
      # get & check DB version?
      @LibraryPath = fotosMediathekFilePath
    end

    def libraryPath
      @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
  version.name AS imageName
  , master.name AS masterName
  , version.latitude AS latitude
  , version.longitude AS longitude
  , datetime(version.imageDate + 978307200, 'unixepoch', 'localtime') AS imageDate -- NSDate, Jan 1st 2001
  , master.imagePath
  , master.uuid
  , master.modelId
  , version.modelId
  , version.adjustmentUuid
  , note_str.value AS description
  , img_prx_res.resourceUuid AS adjusted_resourceUuid
  , img_prx_res.fileName AS adjusted_fileName
  , version.rotation
  --  , 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
LEFT OUTER JOIN RKVersion_stringNote AS note_str
ON  version.modelId = note_str.attachedToId
AND 669 = note_str.keyPath -- where is this constant defined?
LEFT OUTER JOIN ImageProxies.RKModelResource AS img_prx_res
ON  'UNADJUSTEDNONRAW' != version.adjustmentUuid
AND version.adjustmentUuid = img_prx_res.resourceTag
WHERE album.name = ? -- version.name LIKE '%7665%'
ORDER BY
  imageDate DESC
  , adjusted_resourceUuid DESC
END_OF_SQL

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

    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
end

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

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

module MRO
  class CFG
    def initialize iphoto, album_name, dst_atom_url
      @IPHOTO = iphoto
      @ALBUM_NAME = album_name
      @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 xslt_url
      # $stderr.puts "convert album '#{@ALBUM['AlbumName']}' to #{@ATOM_URL}"

      dir2pxls = {
        # 'original' => {:rel => :image_src},
        '1600p' => {:pxls => 1600, :quality => '50%', :rel => :enclosure},
#        '1024p' => {:pxls => 1024, :quality => '50%', :rel => :enclosure},
        '200p' => {:pxls => 200, :quality => '30%', :rel => :previewimage},
      }
      dir2pxls.keys.each {|dir| FileUtils.mkpath File.join(@ATOM_DIR, dir)}

      maxDate = Time.at 0
      feed = Atom::Feed.new do |f|
        f.xslt = xslt_url
        f.title = @ALBUM_NAME
        f.links << Atom::Link.new(:href => @ATOM_URL, :rel => :self, :type => 'application/atom+xml')
        f.links << Atom::Link.new(:href => @ATOM_URL, :rel => :alternate, :type => 'text/html')
        f.generator = Atom::Generator.new(:uri => 'http://purl.mro.name/Photos2Atom', :name => 'iPhoto2Atom')
        artist = {:name => 'John Doe', :uri => 'http://example.com/~jd'}
        f.authors << Atom::Person.new(artist)
        f.id = @BASE_URL # "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6"
        prev = nil
        @IPHOTO.find_images_for_album(@ALBUM_NAME) do |row|
          # prevent duplicates:
          next if prev == row[8]
          prev = row[8]
          # $stderr.puts "#{img[:created]} #{img['Caption']}"
          # unless 2 == row[MediaType]
          #   $stderr.write "-(#{img['MediaType']})"
          #   next
          # end
          # $stderr.puts "img: #{row[5]}"
          src = nil          
          # use the largest adjusted image binary (if there is an adjustmend)
          src = Dir.glob(File.join(@IPHOTO.libraryPath,'resources','modelresources','*','*',row[11],row[12])).max_by {|fn| File.size(fn)} unless row[11].nil?
          src ||= File.join(@IPHOTO.libraryPath, 'Masters', row[5]) # fallback to unmodified master image
          raise src unless src.start_with?( @IPHOTO.libraryPath )
          dst_fmt = case File.extname(src)
            when /png/i   then :png
            when /jpe?g/i then :jpeg
            when /jp2/i   then :jpeg
            when /mov/i   then :mov
            else raise "odd extension: '#{File.extname(src)}'"
          end
          if :mov == dst_fmt
            $stderr.write 'o'
            next
          end
          sha = Digest::SHA1.hexdigest(row[5])   # (destination) file extension not part of sha.
          begin
            f.entries << Atom::Entry.new do |e|
              e.id = @BASE_URL + ('#_' + sha)
              # use GUID?
              # e.id = "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a"
              create_date = Time.parse row[4]
              e.updated = create_date
              maxDate = create_date if create_date > maxDate
              e.title = row[0] || row[1] # fallback to master name
              e.summary = (row[10] || '').strip # is mandatory if content/@src
              e.authors << Atom::Person.new(artist)
              # 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 '#{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) < create_date
                  $stderr.write '+'
                  FileUtils.cp(src, dst)
                  # bake exif rotation into image and reset exif to normal http://www.leancrew.com/all-this/2009/04/derotating-jpegs-with-exiftool/
                  rotation_exif = IO.popen("exiftool -Orientation -n '#{src}'") do |f|
                    m = /^Orientation\s*:\s*(\d+)\s*$/m.match(f.read) || [nil,nil]
                    m[1]
                  end
#                  rotation = row[11].nil? || '' == row[11] ? row[13].to_i : 0 # rotate only if unmodified
                  # fallback to Photos-DB rotation if exiftool fails (usually jpeg2000)
                  rotation = rotation_exif.nil? ? row[13].to_i : [0,90,180,270][(rotation_exif.to_i - 1) % 4]
                  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 = [create_date.year, Time.now.year].uniq.join('-')
                    properties = {
                      :copyright      => "© #{cyear}, #{artist[:name]}, #{artist[:uri]}",
                      :artist         => artist[:name],
                      :description    => [e.title, e.summary].collect{|s| '' == s ? nil : s}.compact.collect{|s| s.strip}.join(' / '),
                      :format         => dst_fmt,
                      :formatOptions  => h[:quality],
                    }
                    cmd = "sips --rotate #{rotation} #{properties.map{|k,v| "--setProperty #{k} #{sh_escape(v)}"}.join(' ')} -Z #{h[:pxls]} '#{dst}' 1>/dev/null 2>&1"
                    unless system(cmd)
                      $stderr.puts "  failed: #{cmd}"
                      FileUtils.rm dst
                    end
                    cmd = "exiftool -Orientation=1 -n -overwrite_original_in_place '#{dst}' 1>/dev/null 2>&1"
                    $stderr.puts "  failed: #{cmd}" unless system(cmd)
                    # @todo optional run pngquant / optipng
                  end
                  FileUtils.touch dst, :mtime => create_date+1
                else
                  $stderr.write '.'
                end
                e.links << Atom::Link.new(:href => (@ATOM_URL + (dir + '/') + "#{sha}.#{dst_fmt}"), :rel => h[:rel], :length => File.size(dst), :type => "image/#{dst_fmt}" )
              end
              e.links << Atom::Link.new(:href => (@ATOM_URL + ('1600p' + '/') + "#{sha}.#{dst_fmt}"), :type => "image/#{dst_fmt}" )
              e.content = Atom::Content::External.new :src => (@ATOM_URL + ('1600p' + '/') + "#{sha}.#{dst_fmt}"), :type => "image/#{dst_fmt}"
              e.media_thumbnail = Media::Thumbnail.new :url => (@ATOM_URL + ('200p' + '/') + "#{sha}.#{dst_fmt}")
              @IPHOTO.find_keywords_for_image_version_id(row[8]) do |keywor|
                e.categories << Atom::Category.new( :scheme => @BASE_URL, :term => keywor, :label => keywor )
              end
              e.georss_point = "#{row[2]} #{row[3]}" if row[2] or row[3]
            end
          rescue Exception => e
            $stderr.puts row
            $stderr.puts e
          end
        end
        f.updated = maxDate
        $stderr.write "\n"
      end

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

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

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 +10 -15
@@ 1,28 1,23 @@

Extract Apple™ [Photos™](http://www.apple.com/osx/photos/) (formerly
[iPhoto™](http://www.apple.com/mac/iphoto/)) albums to
[Atom](http://atomenabled.org/developers/syndication/) photo feeds and simple
HTML image galleries.
Prepare a static photo feed for publication on a webserver.

# Usage

    $ gem install bundler && bundle install
    $ [brew](http://brew.sh/) install exiftool
    $ # put the photos or softlinks into ./src/, then
    $ make title='My Photos' base='https://example.org/sub/my-photos'

    $ # optional:
    $  sh ./install-lightbox2.sh
    $ vim run.sh # enter album names, dst URL, deployment destination
    $ sh ./run.sh

## Prerequisites

- [exiftool](http://www.sno.phy.queensu.ca/~phil/exiftool/)
    $ rsync -avPz _build/ example.org:/var/www/.../
    $ rsync -avPz assets/ example.org:/var/www/.../assets/

plus the already present (on OS X):
## Prerequisites

- [ruby](http://www.ruby-lang.org/de/)
- [xmllint](http://xmlsoft.org/xmllint.html)
- [sips](https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/sips.1.html)
- [make](https://www.gnu.org/software/make/)
- [imagemagick](https://imagemagick.org/)
- [rsync](http://rsync.samba.org/)
- [curl](https://curl.haxx.se/)

## Appendix


M assets/atom2html.xslt => assets/atom2html.xslt +33 -19
@@ 83,30 83,30 @@
        <!-- http://www.quirksmode.org/blog/archives/2013/10/initialscale1_m.html -->
        <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
        <!-- meta name="viewport" content="width=400"/ -->
        <link href="assets/style.css" rel="stylesheet" type="text/css"/>
        <!-- http://lokeshdhakar.com/projects/lightbox2/#how-to-use -->
        <link href="assets/lightbox2/css/lightbox.min.css" rel="stylesheet" />
        <link href="assets/style.css" rel="stylesheet" type="text/css"/>
        <title>
          <xsl:value-of select="a:title"/>
        </title>
        <link href="photos.atom" rel="alternate" type="application/atom+xml"/>
      </head>
      <body id="photofeed">
        <h1><xsl:value-of select="a:title"/></h1>

        <ul id="photos">
          <xsl:for-each select="a:entry">
            <xsl:sort select="a:updated" order="descending"/>
            <li id="{substring-after(a:id, '#')}">
              <xsl:attribute name="class">
                <xsl:for-each select="a:category">cat_<xsl:value-of select="generate-id(key('CategorY',@term))"/><xsl:text> </xsl:text></xsl:for-each>
              </xsl:attribute>

              <xsl:variable name="time" select="substring(translate(a:updated,'T',' '), 1, 16)"/>
              <a href="{a:link[@rel='enclosure']/@href}" title="{normalize-space(a:title)}" data-lightbox="foo" data-title="{normalize-space(a:title)} | {$time}">
                <span style="display:block;width:200px;height:200px;text-align:center">
                  <img alt="{normalize-space(a:summary)}" src="{a:link[@rel='previewimage']/@href}"/>
                </span>
              <a href="{a:link[@rel='enclosure']/@href}" title="{a:updated} {normalize-space(a:title)}" data-lightbox="foo" data-title="{normalize-space(a:title)} | {$time}">
                <img alt="{normalize-space(a:summary)}" src="{a:link[@rel='previewimage']/@href}"/>
              </a>
              <br class='br'/>
              <span>
              <span style="display:none">
                <span class="title"><xsl:value-of select="a:title"/></span><br class='br'/>
                <span class="time"><xsl:value-of select="$time"/></span><br class='br'/>
                <xsl:variable name="lat" select="substring-before(georss:point, ' ')"/>


@@ 128,6 128,20 @@
          </xsl:for-each>
        </ul>

        <p style="clear:both">
          <xsl:for-each select="a:entry/a:updated">
            <xsl:sort select="." order="ascending"/>
            <xsl:if test="position() = 1"><xsl:value-of select="substring(., 1, 10)"/></xsl:if>
          </xsl:for-each>
          -
          <xsl:for-each select="a:entry/a:updated">
            <xsl:sort select="." order="descending"/>
            <xsl:if test="position() = 1"><xsl:value-of select="substring(., 1, 10)"/></xsl:if>
          </xsl:for-each>,
          #<xsl:value-of select="count(a:entry)"/>
        </p>

      <div style="display:none">
        <h2 style="clear:left">Schlagworte</h2>

        <ul id="categories" class="categories">


@@ 140,13 154,22 @@
            </li>
          </xsl:for-each>
        </ul>
      </div>

        <!-- http://lokeshdhakar.com/projects/lightbox2/#how-to-use -->
        <script src="assets/lightbox2/js/lightbox-plus-jquery.min.js" type="text/javascript"></script>
        <script src="assets/script.js" type="text/javascript"></script>

        <hr style="clear:left;"/>
        <p id="footer">
        <p id="footer" style="clear:both">
          Powered by <a href="http://purl.mro.name/Photos2Atom">purl.mro.name/Photos2Atom</a>
          and <a rel="license" href="https://creativecommons.org/licenses/by/2.5/"><img alt=
  "Creative Commons License" src=
  "assets/by.svg" style="border:0;width:88px;height:31px"/></a>
  <span xmlns:dct="http://purl.org/dc/terms/" property="dct:title">lightbox2</span> by <a xmlns:cc=
  "http://creativecommons.org/ns#" href="http://lokeshdhakar.com/projects/lightbox2/" property=
  "cc:attributionName" rel="cc:attributionURL">Lokesh Dhakar</a> is licensed under a <a rel=
  "license" href="https://creativecommons.org/licenses/by/2.5/">Creative Commons Attribution 2.5 License</a>.
          <br class="br"/>
          <a title="Validate my Atom 1.0 feed" href="https://validator.w3.org/feed/check.cgi?url={a:link[@rel='self']/@href}">
          <img alt="Valid Atom 1.0" src="assets/valid-atom.png" style="border:0;width:88px;height:31px"/>
          </a><xsl:text> </xsl:text>


@@ 156,17 179,8 @@
          <a href="https://jigsaw.w3.org/css-validator/check/referer?profile=css3&amp;usermedium=screen&amp;warning=2&amp;vextwarning=false&amp;lang=de">
          <img alt="CSS ist valide!" src="assets/valid-css-blue-v.svg" style="border:0;width:88px;height:31px"/>
          </a>
          <br class='br'/>
          <!-- https://i.creativecommons.org/l/by/2.5/88x31.png -->
          Powered by <a href="http://purl.mro.name/Photos2Atom">purl.mro.name/Photos2Atom</a><br class="br"/>
          and <a rel="license" href="https://creativecommons.org/licenses/by/2.5/"><img alt=
  "Creative Commons License" src=
  "assets/by.svg" style="border:0;width:88px;height:31px"/></a>
  <span xmlns:dct="http://purl.org/dc/terms/" property="dct:title">lightbox2</span> by <a xmlns:cc=
  "http://creativecommons.org/ns#" href="http://lokeshdhakar.com/projects/lightbox2/" property=
  "cc:attributionName" rel="cc:attributionURL">Lokesh Dhakar</a> is licensed under a <a rel=
  "license" href="https://creativecommons.org/licenses/by/2.5/">Creative Commons Attribution 2.5 License</a>.
        </p>
          </p>
      </body>
    </html>
  </xsl:template>

M assets/style.css => assets/style.css +22 -33
@@ 16,51 16,36 @@ You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/
html {
  background-color: #222;
  color: #DDD;
  --dx: 200px;
  background-color: hsl(0,0%,23%);
  color: hsl(30, 60%, 60%);
  font-family: sans-serif;
}
ul#photos {
  list-style-type: none;
  padding: 0;
  margin: 0;
  padding: 0;
}
ul#photos > li {
 /* border: solid lightgrey 2px; */
  display: block;
  float: left;
  margin: 2.5ex;
  height: var(--dx);
  margin: .5ex;
  padding: 0.25ex;
  border: solid lightgrey 2px;
  font-family: sans-serif;
}
ul#photos > li a:link { color: white; text-decoration:none; }
ul#photos > li a:hover { color: white; text-decoration:none; }
ul#photos > li a:active { color: white; text-decoration:none; }
ul#photos > li a:visited { color: white; text-decoration:none; }

ul#photos li a span img {
  max-width: 200px;
  max-height: 200px;
}

.title {
  font-weight: bold;
}
.time {
  font-size: 10pt;
  color: lightgrey;
}
.geo {
  font-size: 10pt;
  color: lightgrey;
  width: var(--dx);
  text-align: center;
}

.thumb {
  width: 200px;
  height: 200px;
ul#photos > li img {
  max-height: var(--dx);
  max-width: var(--dx);
}

#footer {
  background-color: #DDD;
  color: black;
  opacity: 40%;
}
a:any-link {
  color: hsl(115, 50%, 45%);
}

/* This is a workaround for Browsers that insert additional <br> tags.


@@ 85,3 70,7 @@ ul#photos.active li {
ul#photos.active li.active {
  display: block;
}

.lb-outerContainer {
  background-color: hsl(30, 60%, 40%);
}

A atom.sh => atom.sh +103 -0
@@ 0,0 1,103 @@
#!/bin/sh
# see http://purl.mro.name/Photos2Atom
#
cd "$(dirname "${0}")" || exit 1

if [ "" = "${2}" ] ; then
	# Should we enforce that $${2} is a url?
cat <<Endofmessage

Extract meta data from a set of images and write the photo feed atom xml to stdout.

SYNOPSIS

  $0 <Feed Name> <baseurl> [src/image.jpg]...

Endofmessage
	exit 1
fi

title="${1}"
shift
base="${1}"
shift

if [ "$(date -d '@123' --iso-8601=seconds 2>/dev/null)" = "1970-01-01T01:02:03+01:00" ] ; then
	file_date_iso8601 () {
		date -r "${1}" --iso-8601=seconds
	}
elif [ "$(date -r 123 +'%FT%T%z' | sed 's/..$/:&/g' 2>/dev/null)" = "1970-01-01T01:02:03+01:00" ] ; then
	file_date_iso8601 () {
		date -r "${1}" +'%FT%T%z' | sed 's/..$/:&/g'
	}
else
	file_date_iso8601 () {
		'1970-01-01T00:00:00+00:00'
	}
fi

file_size () {
	wc -c < "${0}" | tr -d ' '
}

youngest=$(ls -tr "$@" | tail -n 1)

cat <<Endofmessage
<?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">
	<title>${title}</title>
	<generator uri="http://purl.mro.name/Photos2Atom">Photos2Atom</generator>
	<id>${base}/</id>
	<updated>$(file_date_iso8601 "${youngest}")</updated>
	<link href="${base}/" rel="self" type="application/atom+xml"/>
	<link href="${base}/" rel="alternate" type="text/html"/>
	<author>
		<name>John Doe</name>
		<uri>http://example.com/~jd</uri>
	</author>
Endofmessage

while [ "" != "${1}" ]
do
	src="${1}"
	file="$(basename "${src}")"
	mime="$(file --brief --mime-type "${src}")"
	size="$(file_size "_build/200/${file}"y%)"

	cat <<Endofmessage
	<entry>
		<id>${base}/#${file}</id>
		<updated>$(file_date_iso8601 "${src}")</updated>
		<title></title>
		<summary/>
		<content src="${base}/1200/${file}" type="${mime}"/>
		<media:thumbnail url="${base}/200/${file}"/>
		<!-- georss:point>47.874091670000006 12.639475000000001</georss:point -->
		<link href="${base}/200/${file}" rel="previewimage" length="${size}" type="${mime}"/>
		<link href="${base}/1200/${file}" rel="enclosure" length="$(file_size "_build/1200/${file}")" type="${mime}"/>
		<link href="${base}/1200/${file}" type="${mime}"/>
	</entry>
Endofmessage
  shift
done

cat <<Endofmessage
</feed>
Endofmessage

M doap.rdf => doap.rdf +9 -9
@@ 2,24 2,24 @@
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns="http://usefulinc.com/ns/doap#">
  <Project>
    <bug-database rdf:resource="https://gogs.mro.name/mro/iPhoto2Atom/issues"/>
    <category rdf:resource="https://l.mro.name/=/t/Atom/"/>
    <category rdf:resource="https://l.mro.name/=/t/iphoto/"/>
    <bug-database rdf:resource="https://gogs.mro.name/mro/Photos2Atom/issues"/>
    <category rdf:resource="https://l.mro.name/o/t/Atom/"/>
    <category rdf:resource="https://l.mro.name/o/t/iphoto/"/>
    <homepage rdf:resource="http://purl.mro.name/Photos2Atom"/>
    <license rdf:resource="https://www.gnu.org/licenses/gpl-3.0.txt"/>
    <maintainer rdf:resource="http://mro.name/~me"/>
    <name>iPhoto2Atom</name>
    <name>Photos2Atom</name>
    <programming-language>css</programming-language>
    <programming-language>js</programming-language>
    <programming-language>ruby</programming-language>
    <programming-language>dash</programming-language>
    <programming-language>xslt</programming-language>
    <repository>
      <GitRepository>
        <browse rdf:resource="https://gogs.mro.name/mro/iPhoto2Atom"/>
        <location rdf:resource="https://gogs.mro.name/mro/iPhoto2Atom.git"/>
        <browse rdf:resource="https://gogs.mro.name/mro/Photos2Atom"/>
        <location rdf:resource="https://gogs.mro.name/mro/Photos2Atom.git"/>
      </GitRepository>
    </repository>
    <short-description>Extract Apple™ Photos™ (formerly iPhoto™) albums to Atom photo feeds and simple HTML image galleries.</short-description>
    <wiki rdf:resource="https://gogs.mro.name/mro/iPhoto2Atom/wiki"/>
    <short-description>Prepare a static photo feed for publication on a webserver.</short-description>
    <wiki rdf:resource="https://gogs.mro.name/mro/Photos2Atom/wiki"/>
  </Project>
</rdf:RDF>

M run.sh => run.sh +5 -50
@@ 1,53 1,8 @@
#!/bin/sh
#
# iPhoto2Atom, extract images from iPhoto™ libraries
# Copyright (C) 2015-2016  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/>.
cd "$(dirname "${0}")" || exit 1

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 ; }
rsync --version >/dev/null    || { echo "Install rsync" && exit 1 ; }
exiftool -ver >/dev/null      || { echo "Install exiftool, e.g. $ brew install exiftool" && exit 1 ; }
make title='My Photos' base='https://example.org/sub/my-photos'

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

########################################################
## Adjust below as appropriate

bundle exec ruby Photos2Atom.rb 'Foo' "http://example.com/wherever/foo/" &
bundle exec ruby Photos2Atom.rb 'Bar' "http://example.com/somewhereelse/bar/" &

wait

# put html assets in place for deployment:
find "${HOME}/Library/Caches/name.mro.iPhoto2Atom" -type d -name "200p" -exec rsync --delete -a assets "{}/.." \;
# run atom xml through xmllint:
find "${HOME}/Library/Caches/name.mro.iPhoto2Atom" -type f -name "photos.atom" | while read atom
do
  {
    xmllint --relaxng rfc4287.rng --encode utf-8 --nonet --format --output "$atom"~ "$atom" >/dev/null \
    && touch -r "$atom" "$atom"~ \
    && mv "$atom"~ "$atom"
  }
done

wait

# deploy to production
rsynv -avPz --delete "${HOME}/Library/Caches/name.mro.iPhoto2Atom/example.com/" "example.com:/var/www/.../example.com/public_html/"
dst="example.org:/var/www/vhosts/example.org/pages/sub/my-photos"
rsync -avP _build/ "${dst}/"
rsync -avP assets/ "${dst}/assets/"

D sample.atom => sample.atom +0 -60
@@ 1,60 0,0 @@
<?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>

A src/.gitkeep => src/.gitkeep +0 -0