Created
January 8, 2018 15:34
-
-
Save airblade/71bcaa21675088e65526c9fb6b3fc810 to your computer and use it in GitHub Desktop.
Exports Photos.app v1.5 albums and their photos to plain directories and files.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# Exports albums and their photos to plain directories and files. | |
# | |
# Built for Photos.app v1.5 on OS X 10.11.6. | |
# | |
# See also: | |
# https://github.com/koraktor/gallerist | |
# | |
# TODO: | |
# | |
# - exclude albums from export | |
require 'active_record' | |
require 'active_record/connection_adapters/sqlite3_adapter' | |
require 'fileutils' | |
PHOTOS_ROOT = '/Users/andy/Pictures/Photos Library.photoslibrary' | |
LIBRARY_DATABASE = "#{PHOTOS_ROOT}/Database/Library.apdb" | |
IMAGE_PROXIES_DATABASE = "#{PHOTOS_ROOT}/Database/ImageProxies.apdb" | |
MASTERS = "#{PHOTOS_ROOT}/Masters" | |
EXPORT_ROOT = '/Users/andy/photos/andrew' | |
$symlink = true | |
# | |
# Configuration | |
# | |
# Implement regexp() function. | |
class ActiveRecord::ConnectionAdapters::SQLite3Adapter | |
alias :original_initialize :initialize | |
def initialize(connection, logger, connection_options, config) | |
original_initialize connection, logger, connection_options, config | |
connection.create_function('regexp', 2) do |func, pattern, expression| | |
regexp = Regexp.new(pattern.to_s, Regexp::IGNORECASE) | |
func.result = expression.to_s.match(regexp) ? 1 : 0 | |
end | |
end | |
end | |
ActiveRecord::Base.establish_connection( | |
adapter: 'sqlite3', | |
database: LIBRARY_DATABASE | |
) | |
class Base < ActiveRecord::Base | |
self.primary_key = 'modelId' | |
self.inheritance_column = nil | |
end | |
# | |
# Classes | |
# | |
class Album < Base | |
self.table_name = 'RKAlbum' | |
has_many :album_versions, foreign_key: 'albumId' | |
has_many :versions, -> { order 'imageDate ASC' }, through: :album_versions | |
default_scope { | |
where(albumSubClass: 3). | |
where('isHidden != 1'). | |
where('isInTrash != 1'). | |
where('name != "Last Import"'). | |
where('name != "printAlbum"'). | |
where('name NOT REGEXP "Roll \d+"'). | |
where('name NOT REGEXP "\d{1,2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 20[01][0-9]"') | |
} | |
def dirname | |
@dirname ||= begin | |
earliest_photo_date = versions.first.image_date | |
formatted_date = earliest_photo_date.strftime '%Y%m' | |
"#{formatted_date} #{sanitize name}" | |
end | |
end | |
private | |
def sanitize(filename) | |
filename.gsub(%r{/}, '-') | |
end | |
end | |
class AlbumVersion < Base | |
self.table_name = 'RKAlbumVersion' | |
belongs_to :album, foreign_key: 'albumId' | |
belongs_to :version, foreign_key: 'versionId' | |
end | |
# More about the imagey stuff. | |
class Version < Base | |
self.table_name = 'RKVersion' | |
alias_attribute :adjustment_uuid, :adjustmentUuid | |
alias_attribute :image_date, :imageDate | |
has_many :album_versions, foreign_key: 'versionId' | |
has_many :albums, -> { order 'name ASC' }, through: :album_versions | |
belongs_to :master, foreign_key: 'masterId' | |
has_one :model_resource, foreign_key: 'attachedModelId' # edited photo | |
# https://developer.apple.com/reference/foundation/nsdate | |
APPLE_TIMESTAMP_OFFSET_SEC = Time.new(2001, 1, 1).to_i | |
SECONDS_IN_DAY = 60 * 60 * 24 | |
UNIX_EPOCH_JULIAN = Time.at(0).to_datetime.jd | |
def edited? | |
adjustment_uuid != 'UNADJUSTEDNONRAW' | |
end | |
scope :edited, -> { where 'adjustmentUuid != "UNADJUSTEDNONRAW"' } | |
# TODO - timezone adjustment | |
def image_date | |
# Example raw value: 164480537 (seconds since 01.01.2001) | |
Time.at(self[:imageDate] + APPLE_TIMESTAMP_OFFSET_SEC) | |
end | |
def export_filename(increment: false) | |
increment_counter if increment | |
name = image_date.strftime("%Y%m%d-%H%M-#{counter}") | |
"#{name}#{File.extname(master.file_name).downcase}" | |
end | |
# FIXME - don't know whether this works properly | |
# I don't know whether this is a julian date or an offset in seconds or something else... | |
# def createDate | |
# # Example raw value: 394014430.70306 | |
# Time.at(self[:createDate] + APPLE_TIMESTAMP_OFFSET_SEC) | |
# end | |
private | |
def counter | |
@counter ||= 1 | |
end | |
def increment_counter | |
counter | |
@counter += 1 | |
end | |
end | |
# More about the image file itself. | |
class Master < Base | |
self.table_name = 'RKMaster' | |
alias_attribute :image_path, :imagePath | |
alias_attribute :file_name, :fileName | |
has_one :version, foreign_key: 'masterId' | |
end | |
# An edited photo (as far as I know). | |
class ModelResource < Base | |
establish_connection( | |
adapter: 'sqlite3', | |
database: IMAGE_PROXIES_DATABASE | |
) | |
self.table_name = 'RKModelResource' | |
alias_attribute :resource_uuid, :resourceUuid | |
belongs_to :version, foreign_key: 'attachedModelId' | |
def image_path | |
# assumes photo, i.e. not video | |
first, second = resource_uuid[0].ord.to_s, resource_uuid[1].ord.to_s | |
File.join 'resources', 'modelresources', first, second, resource_uuid, filename | |
end | |
end | |
# | |
# Helpers | |
# | |
def log(msg) | |
puts msg | |
end | |
def copy_or_symlink(source, destination) | |
raise "missing #{source}" unless File.exist? source | |
raise "exists #{destination}" if File.exist? destination | |
if $symlink | |
log "symlink #{source} -> #{destination}" | |
File.symlink source, destination # absolute symlink | |
else | |
log "copy #{source} -> #{destination}" | |
File.cp source, destination, preserve: true | |
end | |
end | |
# | |
# Do it | |
# | |
total = copied_or_linked = 0 | |
log "#{Time.now.strftime '%d.%m.%Y-%H:%M'} exporting photos" | |
Album.all.each do |album| | |
log "export #{album.name}" | |
# album directory | |
album_path = File.join EXPORT_ROOT, album.dirname | |
if Dir.exist? album_path | |
log "exists #{album_path}" | |
else | |
log "create #{album_path}" | |
Dir.mkdir album_path | |
end | |
album.versions.each do |photo| | |
total += 1 | |
catch :skip do | |
master = File.join MASTERS, photo.master.image_path | |
# calculate destination file name, skipping already-exported files | |
destination = File.join album_path, photo.export_filename | |
while File.exist? destination | |
if FileUtils.identical? master, destination | |
log "exists #{master} -> #{destination}" | |
throw :skip | |
end | |
destination = File.join album_path, photo.export_filename(increment: true) | |
end | |
# copy/symlink file | |
copied_or_linked += 1 | |
copy_or_symlink master, destination | |
# edited photos | |
if photo.edited? | |
total += 1 | |
edit = photo.model_resource | |
source = File.join PHOTOS_ROOT, edit.image_path | |
target = destination | |
.sub(/[.](\w+)$/, '-edit.\1') # insert "-edit" at end of filename, before extension | |
.sub(/[.][^.]+$/, File.extname(source).downcase) # use edited file's extension | |
if File.exist? target | |
log "exists #{source} -> #{target}" | |
else | |
copied_or_linked += 1 | |
copy_or_symlink source, target | |
end | |
end | |
end | |
end | |
end | |
log "exported #{total} total, #{copied_or_linked} new" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment