photo_folder 1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +2 -0
- data/lib/models/photo.rb +115 -0
- data/lib/models/photo_collection.rb +96 -0
- data/lib/models/photo_tag.rb +3 -0
- data/lib/models/photo_tagging.rb +4 -0
- data/lib/photo_folder.rb +296 -0
- data/photo_folder.gemspec +19 -0
- data/vendor/acts_as_list.rb +257 -0
- data/vendor/acts_as_tree.rb +97 -0
- data/vendor/mini_exiftool.rb +416 -0
- metadata +83 -0
data/README.markdown
ADDED
data/lib/models/photo.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
class Photo < ActiveRecord::Base
|
2
|
+
belongs_to :photo_collection
|
3
|
+
has_many :photo_taggings, :dependent => :destroy
|
4
|
+
has_many :photo_tags, :through => :photo_taggings
|
5
|
+
acts_as_list :scope => :photo_collection
|
6
|
+
before_create :set_exif_data, :set_last_modified, :set_guid, :set_nsfw
|
7
|
+
after_create :set_tags
|
8
|
+
|
9
|
+
def self.guid(path,date)
|
10
|
+
Digest::MD5.hexdigest([
|
11
|
+
path,
|
12
|
+
date
|
13
|
+
].join(''))
|
14
|
+
end
|
15
|
+
|
16
|
+
def full_path
|
17
|
+
File.join(PhotoFolder.source_directory_location,path)
|
18
|
+
end
|
19
|
+
|
20
|
+
def update_meta
|
21
|
+
set_exif_data
|
22
|
+
set_last_modified
|
23
|
+
set_guid
|
24
|
+
set_nsfw
|
25
|
+
save
|
26
|
+
set_tags
|
27
|
+
end
|
28
|
+
|
29
|
+
def set_guid
|
30
|
+
self.guid = Photo.guid(path,date)
|
31
|
+
end
|
32
|
+
|
33
|
+
def set_tags
|
34
|
+
#@tags was set by set_exif_data, which is always called first
|
35
|
+
self.photo_taggings.destroy_all
|
36
|
+
@tags.each do |str|
|
37
|
+
self.photo_taggings.create({
|
38
|
+
:photo_tag_id => (PhotoTag.find_by_tag(str) || PhotoTag.create(:tag => str)).id,
|
39
|
+
:photo_id => self.id
|
40
|
+
})
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def set_nsfw
|
45
|
+
self.nsfw = @tags.collect(&:downcase).include?('nsfw') ? 1 : 0
|
46
|
+
end
|
47
|
+
|
48
|
+
def set_last_modified
|
49
|
+
self.last_modified = File.mtime(full_path)
|
50
|
+
end
|
51
|
+
|
52
|
+
def set_exif_data
|
53
|
+
begin
|
54
|
+
exif = EXIFData.new(MiniExiftool.new(full_path),logger)
|
55
|
+
|
56
|
+
[:focal_length,:iso,:shutter_speed,:f_stop,:camera,:caption,:location,:height,:width].each do |key|
|
57
|
+
self.send(key.to_s + '=',exif[key] || nil)
|
58
|
+
end
|
59
|
+
|
60
|
+
#date,title,lens and tags are special cases
|
61
|
+
if exif[:date].is_a? Time
|
62
|
+
self.date = exif[:date]
|
63
|
+
else
|
64
|
+
date_match = (exif[:date] || '').match(/([\d]+):([\d]+):([\d]+) ([\d]+):([\d]+):([\d]+)/)
|
65
|
+
self.date = Time.mktime(date_match[1],date_match[2],date_match[3],date_match[4],date_match[5],date_match[6]) if date_match
|
66
|
+
end
|
67
|
+
self.name = exif[:title] || full_path.split('/').pop.gsub(/\.[a-zA-Z]{1,4}/,'')
|
68
|
+
self.lens = exif[:lens] ? exif[:lens] : nil
|
69
|
+
@tags = exif[:tags] || []
|
70
|
+
rescue
|
71
|
+
self.name = full_path.split('/').pop.gsub(/\.[a-zA-Z]{1,4}/,'')
|
72
|
+
@tags = []
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class EXIFData
|
77
|
+
EXIF_TRANSLATIONS = {
|
78
|
+
:title => ['Title','ObjectName'],
|
79
|
+
:focal_length => ['FocalLength'],
|
80
|
+
:iso => ['ISO'],
|
81
|
+
:height => ['ImageHeight'],
|
82
|
+
:width => ['ImageWidth'],
|
83
|
+
:shutter_speed => ['ShutterSpeedValue'],
|
84
|
+
:f_stop => ['ApertureValue'],
|
85
|
+
:lens => ['Lens','LensType'],
|
86
|
+
:camera => ['Model'],
|
87
|
+
:date => ['DateTimeOriginal'],
|
88
|
+
:caption => ['Description','ImageDescription','Caption-Abstract'],
|
89
|
+
:tags => ['Keywords','Subject'],
|
90
|
+
:location => ['Location'],
|
91
|
+
}
|
92
|
+
|
93
|
+
EXIF_TRANSFORMATIONS = {
|
94
|
+
#:date => lambda{|exif_value| exif_value.to_s.split('-').shift},
|
95
|
+
:shutter_speed => lambda{|exif_value| exif_value.to_s.gsub(/1\//,'')},
|
96
|
+
:focal_length => lambda{|exif_value| exif_value.to_s.gsub(/\.([\d]{1,})?\s?.+$/,'')},
|
97
|
+
:tags => lambda{|exif_value| (exif_value.is_a?(Array) ? exif_value.join(',') : exif_value).gsub(/["']+/,'').gsub(/\s+?,\s+?/,',').split(',').collect(&:strip).collect(&:downcase)}
|
98
|
+
}
|
99
|
+
|
100
|
+
def initialize(exif_data,logger)
|
101
|
+
@exif_data = exif_data
|
102
|
+
@logger = logger
|
103
|
+
end
|
104
|
+
|
105
|
+
def [](key)
|
106
|
+
EXIF_TRANSLATIONS[key.to_sym].each do |exif_key_name|
|
107
|
+
exif_value = @exif_data[exif_key_name.to_s]
|
108
|
+
if !exif_value.nil? && exif_value != false && exif_value != '' && exif_value != 'false' && exif_value != 'nil'
|
109
|
+
return EXIF_TRANSFORMATIONS[key] ? EXIF_TRANSFORMATIONS[key].call(exif_value) : exif_value
|
110
|
+
end
|
111
|
+
end
|
112
|
+
false
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
class PhotoCollection < ActiveRecord::Base
|
2
|
+
has_many :photos, :order => 'position'
|
3
|
+
acts_as_list :scope => :parent_id
|
4
|
+
acts_as_tree :order => :name
|
5
|
+
after_create :set_children_parent_id
|
6
|
+
before_create :set_name
|
7
|
+
|
8
|
+
def self.set_correct_positions(collections,is_callback = false)
|
9
|
+
collections.sort_by(&:name).each_with_index do |collection,i|
|
10
|
+
collection.update_attribute :position, i
|
11
|
+
set_correct_positions(PhotoCollection.find_all_by_parent_id(collection.id),true)
|
12
|
+
end
|
13
|
+
if self.positions && !is_callback
|
14
|
+
self.positions.each_with_index do |file_name,i|
|
15
|
+
photo_collection = PhotoCollection.find_by_path(file_name,{
|
16
|
+
:conditions => {
|
17
|
+
:parent_id => 0
|
18
|
+
}
|
19
|
+
})
|
20
|
+
if photo_collection
|
21
|
+
photo_collection.insert_at i + 1
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.positions_file_path
|
28
|
+
File.join(PhotoFolder.source_directory_location,'positions.yml')
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.positions
|
32
|
+
return @positions if !@positions.nil?
|
33
|
+
if File.exists?(self.positions_file_path)
|
34
|
+
@positions = YAML.load(File.read(self.positions_file_path))
|
35
|
+
else
|
36
|
+
nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def set_children_parent_id
|
41
|
+
PhotoFolder::all_paths_in_database.select{|path| self.path.underscore.gsub(/\s/,'_') == path.gsub(/\/[^\/]+$/,'').underscore.gsub(/\s/,'_')}.each do |path|
|
42
|
+
Photo.update_all("photo_collection_id = #{self.id}","path = '#{path}'")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def set_parent_id
|
47
|
+
update_attribute :parent_id, parent_from_path ? parent_from_path.id : 0
|
48
|
+
end
|
49
|
+
|
50
|
+
def set_name
|
51
|
+
self.name = path.split('/').pop.gsub(/[\_\-]+/,' ').split(/\s+/).each(&:capitalize!).join(' ')
|
52
|
+
end
|
53
|
+
|
54
|
+
def parent_from_path
|
55
|
+
self.path.count('/') > 0 ? PhotoCollection.find_by_path(self.path.gsub(/\/[^\/]+$/,'')) : false
|
56
|
+
end
|
57
|
+
|
58
|
+
def full_path
|
59
|
+
File.join(PhotoFolder.source_directory_location,path)
|
60
|
+
end
|
61
|
+
|
62
|
+
def positions_file_path
|
63
|
+
File.join(full_path,'positions.yml')
|
64
|
+
end
|
65
|
+
|
66
|
+
def positions
|
67
|
+
return @positions if !@positions.nil?
|
68
|
+
if File.exists?(positions_file_path)
|
69
|
+
@positions = YAML.load(File.read(positions_file_path))
|
70
|
+
else
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def meta_file_path
|
76
|
+
File.join(full_path,'meta.json')
|
77
|
+
end
|
78
|
+
|
79
|
+
def set_meta
|
80
|
+
meta_data = File.exists?(meta_file_path) ? File.read(meta_file_path) : false
|
81
|
+
write_attribute :meta, meta_data
|
82
|
+
meta_data
|
83
|
+
end
|
84
|
+
|
85
|
+
def set_correct_positions
|
86
|
+
positions.each_with_index do |file_name,i|
|
87
|
+
photo = photos.find_by_path("#{path}/#{file_name}")
|
88
|
+
photo_collection = children.find_by_path("#{path}/#{file_name}")
|
89
|
+
if photo
|
90
|
+
photo.insert_at i + 1
|
91
|
+
elsif photo_collection
|
92
|
+
photo_collection.insert_at i + 1
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/lib/photo_folder.rb
ADDED
@@ -0,0 +1,296 @@
|
|
1
|
+
#external libraries
|
2
|
+
require 'rubygems'
|
3
|
+
gem 'sqlite3-ruby'
|
4
|
+
gem 'activerecord'
|
5
|
+
require 'activerecord'
|
6
|
+
require 'fileutils'
|
7
|
+
require 'tempfile'
|
8
|
+
require 'pstore'
|
9
|
+
require 'set'
|
10
|
+
require 'ftools'
|
11
|
+
require 'digest/md5'
|
12
|
+
require 'yaml'
|
13
|
+
|
14
|
+
#internal libraries
|
15
|
+
require 'lib/vendor/acts_as_tree'
|
16
|
+
require 'lib/vendor/acts_as_list'
|
17
|
+
require 'lib/vendor/mini_exiftool'
|
18
|
+
require 'lib/models/photo'
|
19
|
+
require 'lib/models/photo_tag'
|
20
|
+
require 'lib/models/photo_tagging'
|
21
|
+
require 'lib/models/photo_collection'
|
22
|
+
|
23
|
+
unless Numeric.instance_methods.include? 'to_a'
|
24
|
+
class Numeric
|
25
|
+
def to_a
|
26
|
+
[self]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
module PhotoFolder
|
32
|
+
def self.setup_database(database_location)
|
33
|
+
ActiveRecord::Base.establish_connection(
|
34
|
+
:adapter => "sqlite3",
|
35
|
+
:database => database_location,
|
36
|
+
:timeout => 5000
|
37
|
+
)
|
38
|
+
PhotoFolder::create_schema
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.create_schema
|
42
|
+
if !Photo.table_exists?
|
43
|
+
ActiveRecord::Base.connection.create_table("photos") do |t|
|
44
|
+
t.integer "photo_collection_id"
|
45
|
+
t.integer "position"
|
46
|
+
t.integer "height"
|
47
|
+
t.integer "width"
|
48
|
+
t.string "guid"
|
49
|
+
t.string "path"
|
50
|
+
t.string "name"
|
51
|
+
t.datetime "date"
|
52
|
+
t.string "location"
|
53
|
+
t.string "f_stop"
|
54
|
+
t.string "shutter_speed"
|
55
|
+
t.string "focal_length"
|
56
|
+
t.string "iso"
|
57
|
+
t.string "camera"
|
58
|
+
t.string "lens"
|
59
|
+
t.text "caption"
|
60
|
+
t.datetime "last_modified"
|
61
|
+
t.integer "nsfw"
|
62
|
+
t.integer "featured"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
if !PhotoCollection.table_exists?
|
67
|
+
ActiveRecord::Base.connection.create_table("photo_collections") do |t|
|
68
|
+
t.string "name"
|
69
|
+
t.string "path"
|
70
|
+
t.integer "parent_id"
|
71
|
+
t.integer "position"
|
72
|
+
t.text "meta"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
if !PhotoTag.table_exists?
|
77
|
+
ActiveRecord::Base.connection.create_table("photo_tags") do |t|
|
78
|
+
t.string "tag"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
if !PhotoTagging.table_exists?
|
83
|
+
ActiveRecord::Base.connection.create_table("photo_taggings") do |t|
|
84
|
+
t.integer "photo_tag_id"
|
85
|
+
t.integer "photo_id"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.photos_list
|
91
|
+
return @photos_list if !@photos_list.nil?
|
92
|
+
@photos_list = Dir[PhotoFolder.source_directory_location + '/**/*.{png,jpg}']
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.all_paths_in_database
|
96
|
+
return @all_paths_in_database if !@all_paths_in_database.nil?
|
97
|
+
@all_paths_in_database = Photo.find(:all).collect(&:path)
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.all_paths_in_filesystem
|
101
|
+
return @all_paths_in_filesystem if !@all_paths_in_filesystem.nil?
|
102
|
+
@all_paths_in_filesystem = PhotoFolder::photos_list.collect{|file| file.gsub(PhotoFolder.source_directory_location + '/','')}
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.flush_cached_path_information
|
106
|
+
@all_paths_in_filesystem = nil
|
107
|
+
@all_paths_in_database = nil
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.scan_photos
|
111
|
+
to_add = []
|
112
|
+
to_update = []
|
113
|
+
to_remove = []
|
114
|
+
|
115
|
+
PhotoFolder::all_paths_in_filesystem.each do |path_in_filesystem|
|
116
|
+
to_add.push(path_in_filesystem) if !PhotoFolder::all_paths_in_database.select{|path_in_database| path_in_database == path_in_filesystem}[0]
|
117
|
+
end
|
118
|
+
|
119
|
+
PhotoFolder::all_paths_in_database.each do |path_in_database|
|
120
|
+
to_remove.push(path_in_database) if !PhotoFolder::all_paths_in_filesystem.select{|path_in_filesystem| path_in_filesystem == path_in_database}[0]
|
121
|
+
end
|
122
|
+
|
123
|
+
PhotoFolder::all_paths_in_filesystem.each do |path_in_filesystem|
|
124
|
+
if !to_add.include?(path_in_filesystem) && !to_remove.include?(path_in_filesystem)
|
125
|
+
entry = Photo.find_by_path(path_in_filesystem)
|
126
|
+
if entry && File.mtime(entry.full_path) > entry.last_modified
|
127
|
+
to_update.push(path_in_filesystem)
|
128
|
+
end
|
129
|
+
elsif to_add.include?(path_in_filesystem)
|
130
|
+
#cleaned = Photo.clean_filename(path_in_filesystem)
|
131
|
+
entry = Photo.find_by_path(path_in_filesystem)
|
132
|
+
if entry
|
133
|
+
to_update.push(path_in_filesystem)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
to_add.reject!{|item| to_update.include?(item)}
|
139
|
+
|
140
|
+
[to_add,to_update,to_remove]
|
141
|
+
end
|
142
|
+
|
143
|
+
def self.scan_photo_collections
|
144
|
+
collection_paths_in_database = PhotoCollection.find(:all).collect(&:path)
|
145
|
+
|
146
|
+
collection_paths_in_filesystem = []
|
147
|
+
PhotoFolder::all_paths_in_database.each do |path|
|
148
|
+
collection = path.gsub(/\/[^\/]+$/,'')
|
149
|
+
collection_bits = collection.split('/')
|
150
|
+
collection_bits.each_with_index do |part,i|
|
151
|
+
joined = collection_bits[0,i].join('/')
|
152
|
+
collection_paths_in_filesystem.push(joined) if !collection_paths_in_filesystem.include?(joined) && joined != ''
|
153
|
+
end
|
154
|
+
collection_paths_in_filesystem.push(collection) if !collection_paths_in_filesystem.include?(collection)
|
155
|
+
end
|
156
|
+
|
157
|
+
to_add = []
|
158
|
+
to_remove = []
|
159
|
+
|
160
|
+
collection_paths_in_filesystem.each do |path_in_filesystem|
|
161
|
+
to_add.push(path_in_filesystem) if !collection_paths_in_database.include?(path_in_filesystem)
|
162
|
+
end
|
163
|
+
|
164
|
+
collection_paths_in_database.each do |path_in_database|
|
165
|
+
to_remove.push(path_in_database) if !collection_paths_in_filesystem.include?(path_in_database)
|
166
|
+
end
|
167
|
+
|
168
|
+
[to_add,to_remove]
|
169
|
+
end
|
170
|
+
|
171
|
+
def self.hash_from_database
|
172
|
+
{
|
173
|
+
:photo_collections => PhotoCollection,
|
174
|
+
:photos => Photo,
|
175
|
+
:photo_tags => PhotoTag,
|
176
|
+
:photo_taggings => PhotoTagging
|
177
|
+
}.inject(Hash.new) do |result,key_name_and_model|
|
178
|
+
key_name = key_name_and_model[0]
|
179
|
+
model = key_name_and_model[1]
|
180
|
+
result[key_name] = model.find(:all).inject(Hash.new) do |inner_result,item|
|
181
|
+
inner_result[item.id.to_s] = item.attributes.inject(Hash.new) do |attribute_result,key_and_value|
|
182
|
+
key = key_and_value[0]
|
183
|
+
value = key_and_value[1]
|
184
|
+
attribute_result[key.to_s] = value.to_s
|
185
|
+
attribute_result
|
186
|
+
end
|
187
|
+
inner_result
|
188
|
+
end
|
189
|
+
result
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def self.write_database_to_file(path)
|
194
|
+
File.open(path,'w'){|f| f.write(PhotoFolder::hash_from_database.to_json) }
|
195
|
+
end
|
196
|
+
|
197
|
+
def self.source_directory_location
|
198
|
+
@source_directory_location
|
199
|
+
end
|
200
|
+
|
201
|
+
def self.generate(source_directory_location,database_location,json_location,include_nsfw = false,exif_tool_location = 'exiftool')
|
202
|
+
Photo.send(:with_scope, :find => {:conditions => include_nsfw ? "nsfw = 1 OR nsfw = 0" : "nsfw = 0"}) do
|
203
|
+
|
204
|
+
@source_directory_location = source_directory_location
|
205
|
+
|
206
|
+
begin
|
207
|
+
MiniExiftool.configure(exif_tool_location)
|
208
|
+
rescue
|
209
|
+
puts "WARNING: Could not configure MiniExifTool, EXIF data will not be parsed."
|
210
|
+
end
|
211
|
+
|
212
|
+
puts "PhotoFolder Database Generator"
|
213
|
+
puts "=============================="
|
214
|
+
|
215
|
+
PhotoFolder::setup_database(database_location)
|
216
|
+
photos_list = PhotoFolder::photos_list
|
217
|
+
puts "Scanning #{photos_list.length} photos in #{source_directory_location}"
|
218
|
+
|
219
|
+
photos_to_add, photos_to_update, photos_to_remove = PhotoFolder::scan_photos
|
220
|
+
|
221
|
+
Photo.transaction do
|
222
|
+
if photos_to_add.length > 0
|
223
|
+
photos_to_add.each do |path|
|
224
|
+
puts "Adding photo: #{path}"
|
225
|
+
Photo.create :path => path
|
226
|
+
end
|
227
|
+
else
|
228
|
+
puts "No photos to add."
|
229
|
+
end
|
230
|
+
|
231
|
+
if photos_to_update.length > 0
|
232
|
+
photos_to_update.each do |path|
|
233
|
+
puts "Updating photo: #{path}"
|
234
|
+
photo = Photo.find_by_path(path)
|
235
|
+
photo.update_meta if photo
|
236
|
+
end
|
237
|
+
else
|
238
|
+
puts "No photos to update."
|
239
|
+
end
|
240
|
+
|
241
|
+
if photos_to_remove.length > 0
|
242
|
+
photos_to_remove.each do |path|
|
243
|
+
puts "Removing photo: #{path}"
|
244
|
+
Photo.find_all_by_path(path).each(&:destroy)
|
245
|
+
end
|
246
|
+
else
|
247
|
+
puts "No photos to remove."
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
PhotoFolder::flush_cached_path_information
|
252
|
+
collections_to_add, collections_to_remove = PhotoFolder::scan_photo_collections
|
253
|
+
|
254
|
+
PhotoCollection.transaction do
|
255
|
+
if collections_to_add.length > 0
|
256
|
+
collections_to_add.sort_by{|path| path.split('/').pop}.each do |path|
|
257
|
+
puts "Adding collection: #{path}"
|
258
|
+
PhotoCollection.create :path => path
|
259
|
+
end
|
260
|
+
else
|
261
|
+
puts "No photo collections to add."
|
262
|
+
end
|
263
|
+
|
264
|
+
if collections_to_remove.length > 0
|
265
|
+
collections_to_remove.each do |path|
|
266
|
+
puts "Removing collection: #{path}"
|
267
|
+
PhotoCollection.find_by_path(path).destroy if PhotoCollection.find_by_path(path)
|
268
|
+
end
|
269
|
+
else
|
270
|
+
puts "No photo collections to remove."
|
271
|
+
end
|
272
|
+
|
273
|
+
puts "Ensuring correct tree structure of photo collections."
|
274
|
+
PhotoCollection.find(:all).each(&:set_parent_id)
|
275
|
+
|
276
|
+
puts "Ensuring correct position of photos and photo collections."
|
277
|
+
if PhotoCollection.positions
|
278
|
+
puts "Setting positions for root collections from #{PhotoCollection.positions_file_path}"
|
279
|
+
end
|
280
|
+
PhotoCollection.set_correct_positions(PhotoCollection.find_all_by_parent_id(0))
|
281
|
+
PhotoCollection.find(:all).each do |photo_collection|
|
282
|
+
if photo_collection.positions
|
283
|
+
puts "Setting positions for #{photo_collection.name} from #{photo_collection.positions_file_path}"
|
284
|
+
photo_collection.set_correct_positions
|
285
|
+
end
|
286
|
+
if photo_collection.set_meta
|
287
|
+
puts "Setting meta data for #{photo_collection.name} from #{photo_collection.meta_file_path}"
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
puts "Writing database to #{json_location}"
|
293
|
+
PhotoFolder::write_database_to_file(json_location)
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "photo_folder"
|
3
|
+
s.version = "1.0"
|
4
|
+
s.date = "2010-05-11"
|
5
|
+
s.summary = "JavaScript gallery"
|
6
|
+
s.email = "ryan@syntacticx.com"
|
7
|
+
s.homepage = "http://photofolder.org/"
|
8
|
+
s.description = "JavaScript gallery"
|
9
|
+
s.has_rdoc = false
|
10
|
+
s.authors = ["Ryan Johnson"]
|
11
|
+
s.files = [
|
12
|
+
"README.markdown",
|
13
|
+
"photo_folder.gemspec"] +
|
14
|
+
Dir['lib/**/*'] +
|
15
|
+
Dir['vendor/**/*']
|
16
|
+
s.autorequire = "lib/photo_folder.rb"
|
17
|
+
s.add_dependency("sqlite3-ruby", ["> 0.0.0"])
|
18
|
+
s.add_dependency("activerecord", ["> 0.0.0"])
|
19
|
+
end
|
@@ -0,0 +1,257 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module Acts #:nodoc:
|
3
|
+
module List #:nodoc:
|
4
|
+
def self.included(base)
|
5
|
+
base.extend(ClassMethods)
|
6
|
+
end
|
7
|
+
|
8
|
+
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
|
9
|
+
# The class that has this specified needs to have a +position+ column defined as an integer on
|
10
|
+
# the mapped database table.
|
11
|
+
#
|
12
|
+
# Todo list example:
|
13
|
+
#
|
14
|
+
# class TodoList < ActiveRecord::Base
|
15
|
+
# has_many :todo_items, :order => "position"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# class TodoItem < ActiveRecord::Base
|
19
|
+
# belongs_to :todo_list
|
20
|
+
# acts_as_list :scope => :todo_list
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# todo_list.first.move_to_bottom
|
24
|
+
# todo_list.last.move_higher
|
25
|
+
module ClassMethods
|
26
|
+
# Configuration options are:
|
27
|
+
#
|
28
|
+
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
|
29
|
+
# * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
|
30
|
+
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
|
31
|
+
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
|
32
|
+
# Example: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
|
33
|
+
def acts_as_list(options = {})
|
34
|
+
configuration = { :column => "position", :scope => "1 = 1" }
|
35
|
+
configuration.update(options) if options.is_a?(Hash)
|
36
|
+
|
37
|
+
configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
|
38
|
+
|
39
|
+
if configuration[:scope].is_a?(Symbol)
|
40
|
+
scope_condition_method = %(
|
41
|
+
def scope_condition
|
42
|
+
if #{configuration[:scope].to_s}.nil?
|
43
|
+
"#{configuration[:scope].to_s} IS NULL"
|
44
|
+
else
|
45
|
+
"#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
)
|
49
|
+
else
|
50
|
+
scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
|
51
|
+
end
|
52
|
+
|
53
|
+
class_eval <<-EOV
|
54
|
+
include ActiveRecord::Acts::List::InstanceMethods
|
55
|
+
|
56
|
+
def acts_as_list_class
|
57
|
+
::#{self.name}
|
58
|
+
end
|
59
|
+
|
60
|
+
def position_column
|
61
|
+
'#{configuration[:column]}'
|
62
|
+
end
|
63
|
+
|
64
|
+
#{scope_condition_method}
|
65
|
+
|
66
|
+
before_destroy :remove_from_list
|
67
|
+
before_create :add_to_list_bottom
|
68
|
+
EOV
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
|
73
|
+
# by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
|
74
|
+
# lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
|
75
|
+
# the first in the list of all chapters.
|
76
|
+
module InstanceMethods
|
77
|
+
# Insert the item at the given position (defaults to the top position of 1).
|
78
|
+
def insert_at(position = 1)
|
79
|
+
insert_at_position(position)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Swap positions with the next lower item, if one exists.
|
83
|
+
def move_lower
|
84
|
+
return unless lower_item
|
85
|
+
|
86
|
+
acts_as_list_class.transaction do
|
87
|
+
lower_item.decrement_position
|
88
|
+
increment_position
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Swap positions with the next higher item, if one exists.
|
93
|
+
def move_higher
|
94
|
+
return unless higher_item
|
95
|
+
|
96
|
+
acts_as_list_class.transaction do
|
97
|
+
higher_item.increment_position
|
98
|
+
decrement_position
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Move to the bottom of the list. If the item is already in the list, the items below it have their
|
103
|
+
# position adjusted accordingly.
|
104
|
+
def move_to_bottom
|
105
|
+
return unless in_list?
|
106
|
+
acts_as_list_class.transaction do
|
107
|
+
decrement_positions_on_lower_items
|
108
|
+
assume_bottom_position
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Move to the top of the list. If the item is already in the list, the items above it have their
|
113
|
+
# position adjusted accordingly.
|
114
|
+
def move_to_top
|
115
|
+
return unless in_list?
|
116
|
+
acts_as_list_class.transaction do
|
117
|
+
increment_positions_on_higher_items
|
118
|
+
assume_top_position
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Removes the item from the list.
|
123
|
+
def remove_from_list
|
124
|
+
if in_list?
|
125
|
+
decrement_positions_on_lower_items
|
126
|
+
update_attribute position_column, nil
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Increase the position of this item without adjusting the rest of the list.
|
131
|
+
def increment_position
|
132
|
+
return unless in_list?
|
133
|
+
update_attribute position_column, self.send(position_column).to_i + 1
|
134
|
+
end
|
135
|
+
|
136
|
+
# Decrease the position of this item without adjusting the rest of the list.
|
137
|
+
def decrement_position
|
138
|
+
return unless in_list?
|
139
|
+
update_attribute position_column, self.send(position_column).to_i - 1
|
140
|
+
end
|
141
|
+
|
142
|
+
# Return +true+ if this object is the first in the list.
|
143
|
+
def first?
|
144
|
+
return false unless in_list?
|
145
|
+
self.send(position_column) == 1
|
146
|
+
end
|
147
|
+
|
148
|
+
# Return +true+ if this object is the last in the list.
|
149
|
+
def last?
|
150
|
+
return false unless in_list?
|
151
|
+
self.send(position_column) == bottom_position_in_list
|
152
|
+
end
|
153
|
+
|
154
|
+
# Return the next higher item in the list.
|
155
|
+
def higher_item
|
156
|
+
return nil unless in_list?
|
157
|
+
acts_as_list_class.find(:first, :conditions =>
|
158
|
+
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}"
|
159
|
+
)
|
160
|
+
end
|
161
|
+
|
162
|
+
# Return the next lower item in the list.
|
163
|
+
def lower_item
|
164
|
+
return nil unless in_list?
|
165
|
+
acts_as_list_class.find(:first, :conditions =>
|
166
|
+
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}"
|
167
|
+
)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Test if this record is in a list
|
171
|
+
def in_list?
|
172
|
+
!send(position_column).nil?
|
173
|
+
end
|
174
|
+
|
175
|
+
private
|
176
|
+
def add_to_list_top
|
177
|
+
increment_positions_on_all_items
|
178
|
+
end
|
179
|
+
|
180
|
+
def add_to_list_bottom
|
181
|
+
self[position_column] = bottom_position_in_list.to_i + 1
|
182
|
+
end
|
183
|
+
|
184
|
+
# Overwrite this method to define the scope of the list changes
|
185
|
+
def scope_condition() "1" end
|
186
|
+
|
187
|
+
# Returns the bottom position number in the list.
|
188
|
+
# bottom_position_in_list # => 2
|
189
|
+
def bottom_position_in_list(except = nil)
|
190
|
+
item = bottom_item(except)
|
191
|
+
item ? item.send(position_column) : 0
|
192
|
+
end
|
193
|
+
|
194
|
+
# Returns the bottom item
|
195
|
+
def bottom_item(except = nil)
|
196
|
+
conditions = scope_condition
|
197
|
+
conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except
|
198
|
+
acts_as_list_class.find(:first, :conditions => conditions, :order => "#{position_column} DESC")
|
199
|
+
end
|
200
|
+
|
201
|
+
# Forces item to assume the bottom position in the list.
|
202
|
+
def assume_bottom_position
|
203
|
+
update_attribute(position_column, bottom_position_in_list(self).to_i + 1)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Forces item to assume the top position in the list.
|
207
|
+
def assume_top_position
|
208
|
+
update_attribute(position_column, 1)
|
209
|
+
end
|
210
|
+
|
211
|
+
# This has the effect of moving all the higher items up one.
|
212
|
+
def decrement_positions_on_higher_items(position)
|
213
|
+
acts_as_list_class.update_all(
|
214
|
+
"#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}"
|
215
|
+
)
|
216
|
+
end
|
217
|
+
|
218
|
+
# This has the effect of moving all the lower items up one.
|
219
|
+
def decrement_positions_on_lower_items
|
220
|
+
return unless in_list?
|
221
|
+
acts_as_list_class.update_all(
|
222
|
+
"#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{send(position_column).to_i}"
|
223
|
+
)
|
224
|
+
end
|
225
|
+
|
226
|
+
# This has the effect of moving all the higher items down one.
|
227
|
+
def increment_positions_on_higher_items
|
228
|
+
return unless in_list?
|
229
|
+
acts_as_list_class.update_all(
|
230
|
+
"#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}"
|
231
|
+
)
|
232
|
+
end
|
233
|
+
|
234
|
+
# This has the effect of moving all the lower items down one.
|
235
|
+
def increment_positions_on_lower_items(position)
|
236
|
+
acts_as_list_class.update_all(
|
237
|
+
"#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}"
|
238
|
+
)
|
239
|
+
end
|
240
|
+
|
241
|
+
# Increments position (<tt>position_column</tt>) of all items in the list.
|
242
|
+
def increment_positions_on_all_items
|
243
|
+
acts_as_list_class.update_all(
|
244
|
+
"#{position_column} = (#{position_column} + 1)", "#{scope_condition}"
|
245
|
+
)
|
246
|
+
end
|
247
|
+
|
248
|
+
def insert_at_position(position)
|
249
|
+
remove_from_list
|
250
|
+
increment_positions_on_lower_items(position)
|
251
|
+
self.update_attribute(position_column, position)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
ActiveRecord::Base.class_eval { include ActiveRecord::Acts::List }
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module Acts
|
3
|
+
module Tree
|
4
|
+
def self.included(base)
|
5
|
+
base.extend(ClassMethods)
|
6
|
+
end
|
7
|
+
|
8
|
+
# Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
|
9
|
+
# association. This requires that you have a foreign key column, which by default is called +parent_id+.
|
10
|
+
#
|
11
|
+
# class Category < ActiveRecord::Base
|
12
|
+
# acts_as_tree :order => "name"
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# Example:
|
16
|
+
# root
|
17
|
+
# \_ child1
|
18
|
+
# \_ subchild1
|
19
|
+
# \_ subchild2
|
20
|
+
#
|
21
|
+
# root = Category.create("name" => "root")
|
22
|
+
# child1 = root.children.create("name" => "child1")
|
23
|
+
# subchild1 = child1.children.create("name" => "subchild1")
|
24
|
+
#
|
25
|
+
# root.parent # => nil
|
26
|
+
# child1.parent # => root
|
27
|
+
# root.children # => [child1]
|
28
|
+
# root.children.first.children.first # => subchild1
|
29
|
+
#
|
30
|
+
# In addition to the parent and children associations, the following instance methods are added to the class
|
31
|
+
# after calling <tt>acts_as_tree</tt>:
|
32
|
+
# * <tt>siblings</tt> - Returns all the children of the parent, excluding the current node (<tt>[subchild2]</tt> when called on <tt>subchild1</tt>)
|
33
|
+
# * <tt>self_and_siblings</tt> - Returns all the children of the parent, including the current node (<tt>[subchild1, subchild2]</tt> when called on <tt>subchild1</tt>)
|
34
|
+
# * <tt>ancestors</tt> - Returns all the ancestors of the current node (<tt>[child1, root]</tt> when called on <tt>subchild2</tt>)
|
35
|
+
# * <tt>root</tt> - Returns the root of the current node (<tt>root</tt> when called on <tt>subchild2</tt>)
|
36
|
+
module ClassMethods
|
37
|
+
# Configuration options are:
|
38
|
+
#
|
39
|
+
# * <tt>foreign_key</tt> - specifies the column name to use for tracking of the tree (default: +parent_id+)
|
40
|
+
# * <tt>order</tt> - makes it possible to sort the children according to this SQL snippet.
|
41
|
+
# * <tt>counter_cache</tt> - keeps a count in a +children_count+ column if set to +true+ (default: +false+).
|
42
|
+
def acts_as_tree(options = {})
|
43
|
+
configuration = { :foreign_key => "parent_id", :order => nil, :counter_cache => nil }
|
44
|
+
configuration.update(options) if options.is_a?(Hash)
|
45
|
+
|
46
|
+
belongs_to :parent, :class_name => name, :foreign_key => configuration[:foreign_key], :counter_cache => configuration[:counter_cache]
|
47
|
+
has_many :children, :class_name => name, :foreign_key => configuration[:foreign_key], :order => configuration[:order], :dependent => :destroy
|
48
|
+
|
49
|
+
class_eval <<-EOV
|
50
|
+
include ActiveRecord::Acts::Tree::InstanceMethods
|
51
|
+
|
52
|
+
def self.roots
|
53
|
+
find(:all, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.root
|
57
|
+
find(:first, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
|
58
|
+
end
|
59
|
+
EOV
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
module InstanceMethods
|
64
|
+
# Returns list of ancestors, starting from parent until root.
|
65
|
+
#
|
66
|
+
# subchild1.ancestors # => [child1, root]
|
67
|
+
def ancestors
|
68
|
+
node, nodes = self, []
|
69
|
+
nodes << node = node.parent while node.parent
|
70
|
+
nodes
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returns the root node of the tree.
|
74
|
+
def root
|
75
|
+
node = self
|
76
|
+
node = node.parent while node.parent
|
77
|
+
node
|
78
|
+
end
|
79
|
+
|
80
|
+
# Returns all siblings of the current node.
|
81
|
+
#
|
82
|
+
# subchild1.siblings # => [subchild2]
|
83
|
+
def siblings
|
84
|
+
self_and_siblings - [self]
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns all siblings and a reference to the current node.
|
88
|
+
#
|
89
|
+
# subchild1.self_and_siblings # => [subchild1, subchild2]
|
90
|
+
def self_and_siblings
|
91
|
+
parent ? parent.children : self.class.roots
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
ActiveRecord::Base.send :include, ActiveRecord::Acts::Tree
|
@@ -0,0 +1,416 @@
|
|
1
|
+
class MiniExiftool
|
2
|
+
|
3
|
+
def self.configure(exif_tool_location)
|
4
|
+
MiniExiftool.cmd = exif_tool_location
|
5
|
+
if Float(exiftool_version) < 7.41
|
6
|
+
@@separator = ', '
|
7
|
+
@@sep_op = ''
|
8
|
+
else
|
9
|
+
@@separator = '@@'
|
10
|
+
@@sep_op = '-sep @@'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Name of the Exiftool command-line application
|
15
|
+
@@cmd = nil
|
16
|
+
cattr_accessor :cmd
|
17
|
+
|
18
|
+
# Hash of the standard options used when call MiniExiftool.new
|
19
|
+
@@opts = { :numerical => false, :composite => true, :convert_encoding => false, :timestamps => Time }
|
20
|
+
|
21
|
+
attr_reader :filename
|
22
|
+
attr_accessor :numerical, :composite, :convert_encoding, :errors, :timestamps
|
23
|
+
|
24
|
+
VERSION = '1.0.1'
|
25
|
+
|
26
|
+
# +opts+ support at the moment
|
27
|
+
# * <code>:numerical</code> for numerical values, default is +false+
|
28
|
+
# * <code>:composite</code> for including composite tags while loading,
|
29
|
+
# default is +true+
|
30
|
+
# * <code>:convert_encoding</code> convert encoding (See -L-option of
|
31
|
+
# the exiftool command-line application, default is +false+
|
32
|
+
# * <code>:timestamps</code> generating DateTime objects instead of
|
33
|
+
# Time objects if set to <code>DateTime</code>, default is +Time+
|
34
|
+
#
|
35
|
+
# <b>ATTENTION:</b> Time objects are created using <code>Time.local</code>
|
36
|
+
# therefore they use <em>your local timezone</em>, DateTime objects instead
|
37
|
+
# are created <em>without timezone</em>!
|
38
|
+
def initialize filename=nil, opts={}
|
39
|
+
opts = @@opts.merge opts
|
40
|
+
@numerical = opts[:numerical]
|
41
|
+
@composite = opts[:composite]
|
42
|
+
@convert_encoding = opts[:convert_encoding]
|
43
|
+
@timestamps = opts[:timestamps]
|
44
|
+
@values = TagHash.new
|
45
|
+
@tag_names = TagHash.new
|
46
|
+
@changed_values = TagHash.new
|
47
|
+
@errors = TagHash.new
|
48
|
+
load filename unless filename.nil?
|
49
|
+
end
|
50
|
+
|
51
|
+
def initialize_from_hash hash # :nodoc:
|
52
|
+
hash.each_pair do |tag,value|
|
53
|
+
set_value tag, value
|
54
|
+
end
|
55
|
+
set_attributes_by_heuristic
|
56
|
+
self
|
57
|
+
end
|
58
|
+
|
59
|
+
# Load the tags of filename.
|
60
|
+
def load filename
|
61
|
+
unless filename && File.exist?(filename)
|
62
|
+
raise MiniExiftool::Error.new("File '#{filename}' does not exist.")
|
63
|
+
end
|
64
|
+
if File.directory?(filename)
|
65
|
+
raise MiniExiftool::Error.new("'#{filename}' is a directory.")
|
66
|
+
end
|
67
|
+
@filename = filename
|
68
|
+
@values.clear
|
69
|
+
@tag_names.clear
|
70
|
+
@changed_values.clear
|
71
|
+
opt_params = ''
|
72
|
+
opt_params << (@numerical ? '-n ' : '')
|
73
|
+
opt_params << (@composite ? '' : '-e ')
|
74
|
+
opt_params << (@convert_encoding ? '-L ' : '')
|
75
|
+
cmd = %Q(#@@cmd -q -q -s -t #{opt_params} #{@@sep_op} "#{filename}")
|
76
|
+
if run(cmd)
|
77
|
+
parse_output
|
78
|
+
else
|
79
|
+
raise MiniExiftool::Error.new(@error_text)
|
80
|
+
end
|
81
|
+
self
|
82
|
+
end
|
83
|
+
|
84
|
+
# Reload the tags of an already readed file.
|
85
|
+
def reload
|
86
|
+
load @filename
|
87
|
+
end
|
88
|
+
|
89
|
+
# Returns the value of a tag.
|
90
|
+
def [] tag
|
91
|
+
@changed_values[tag] || @values[tag]
|
92
|
+
end
|
93
|
+
|
94
|
+
# Set the value of a tag.
|
95
|
+
def []=(tag, val)
|
96
|
+
@changed_values[tag] = val
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns true if any tag value is changed or if the value of a
|
100
|
+
# given tag is changed.
|
101
|
+
def changed? tag=false
|
102
|
+
if tag
|
103
|
+
@changed_values.include? tag
|
104
|
+
else
|
105
|
+
!@changed_values.empty?
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Revert all changes or the change of a given tag.
|
110
|
+
def revert tag=nil
|
111
|
+
if tag
|
112
|
+
val = @changed_values.delete(tag)
|
113
|
+
res = val != nil
|
114
|
+
else
|
115
|
+
res = @changed_values.size > 0
|
116
|
+
@changed_values.clear
|
117
|
+
end
|
118
|
+
res
|
119
|
+
end
|
120
|
+
|
121
|
+
# Returns an array of the tags (original tag names) of the readed file.
|
122
|
+
def tags
|
123
|
+
@values.keys.map { |key| @tag_names[key] }
|
124
|
+
end
|
125
|
+
|
126
|
+
# Returns an array of all changed tags.
|
127
|
+
def changed_tags
|
128
|
+
@changed_values.keys.map { |key| MiniExiftool.original_tag(key) }
|
129
|
+
end
|
130
|
+
|
131
|
+
# Save the changes to the file.
|
132
|
+
def save
|
133
|
+
return false if @changed_values.empty?
|
134
|
+
@errors.clear
|
135
|
+
temp_file = Tempfile.new('mini_exiftool')
|
136
|
+
temp_file.close
|
137
|
+
temp_filename = temp_file.path
|
138
|
+
FileUtils.cp filename, temp_filename
|
139
|
+
all_ok = true
|
140
|
+
@changed_values.each do |tag, val|
|
141
|
+
original_tag = MiniExiftool.original_tag(tag)
|
142
|
+
arr_val = val.kind_of?(Array) ? val : [val]
|
143
|
+
arr_val.map! {|e| convert e}
|
144
|
+
tag_params = ''
|
145
|
+
arr_val.each do |v|
|
146
|
+
tag_params << %Q(-#{original_tag}="#{v}" )
|
147
|
+
end
|
148
|
+
opt_params = ''
|
149
|
+
opt_params << (arr_val.detect {|x| x.kind_of?(Numeric)} ? '-n ' : '')
|
150
|
+
opt_params << (@convert_encoding ? '-L ' : '')
|
151
|
+
cmd = %Q(#@@cmd -q -P -overwrite_original #{opt_params} #{tag_params} "#{temp_filename}")
|
152
|
+
result = run(cmd)
|
153
|
+
unless result
|
154
|
+
all_ok = false
|
155
|
+
@errors[tag] = @error_text.gsub(/Nothing to do.\n\z/, '').chomp
|
156
|
+
end
|
157
|
+
end
|
158
|
+
if all_ok
|
159
|
+
FileUtils.cp temp_filename, filename
|
160
|
+
reload
|
161
|
+
end
|
162
|
+
temp_file.delete
|
163
|
+
all_ok
|
164
|
+
end
|
165
|
+
|
166
|
+
# Returns a hash of the original loaded values of the MiniExiftool
|
167
|
+
# instance.
|
168
|
+
def to_hash
|
169
|
+
result = {}
|
170
|
+
@values.each do |k,v|
|
171
|
+
result[@tag_names[k]] = v
|
172
|
+
end
|
173
|
+
result
|
174
|
+
end
|
175
|
+
|
176
|
+
# Returns a YAML representation of the original loaded values of the
|
177
|
+
# MiniExiftool instance.
|
178
|
+
def to_yaml
|
179
|
+
to_hash.to_yaml
|
180
|
+
end
|
181
|
+
|
182
|
+
# Create a MiniExiftool instance from a hash
|
183
|
+
def self.from_hash hash
|
184
|
+
instance = MiniExiftool.new
|
185
|
+
instance.initialize_from_hash hash
|
186
|
+
instance
|
187
|
+
end
|
188
|
+
|
189
|
+
# Create a MiniExiftool instance from YAML data created with
|
190
|
+
# MiniExiftool#to_yaml
|
191
|
+
def self.from_yaml yaml
|
192
|
+
MiniExiftool.from_hash YAML.load(yaml)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Returns the command name of the called Exiftool application.
|
196
|
+
def self.command
|
197
|
+
@@cmd
|
198
|
+
end
|
199
|
+
|
200
|
+
# Setting the command name of the called Exiftool application.
|
201
|
+
def self.command= cmd
|
202
|
+
@@cmd = cmd
|
203
|
+
end
|
204
|
+
|
205
|
+
# Returns the options hash.
|
206
|
+
def self.opts
|
207
|
+
@@opts
|
208
|
+
end
|
209
|
+
|
210
|
+
# Returns a set of all known tags of Exiftool.
|
211
|
+
def self.all_tags
|
212
|
+
unless defined? @@all_tags
|
213
|
+
@@all_tags = pstore_get :all_tags
|
214
|
+
end
|
215
|
+
@@all_tags
|
216
|
+
end
|
217
|
+
|
218
|
+
# Returns a set of all possible writable tags of Exiftool.
|
219
|
+
def self.writable_tags
|
220
|
+
unless defined? @@writable_tags
|
221
|
+
@@writable_tags = pstore_get :writable_tags
|
222
|
+
end
|
223
|
+
@@writable_tags
|
224
|
+
end
|
225
|
+
|
226
|
+
# Returns the original Exiftool name of the given tag
|
227
|
+
def self.original_tag tag
|
228
|
+
unless defined? @@all_tags_map
|
229
|
+
@@all_tags_map = pstore_get :all_tags_map
|
230
|
+
end
|
231
|
+
@@all_tags_map[tag]
|
232
|
+
end
|
233
|
+
|
234
|
+
# Returns the version of the Exiftool command-line application.
|
235
|
+
def self.exiftool_version
|
236
|
+
output = `#{MiniExiftool.command} -ver 2>&1`
|
237
|
+
unless $?.exitstatus == 0
|
238
|
+
raise MiniExiftool::Error.new("Command '#{MiniExiftool.command}' not found")
|
239
|
+
end
|
240
|
+
output.chomp!
|
241
|
+
end
|
242
|
+
|
243
|
+
def self.unify tag
|
244
|
+
tag.gsub(/[-_]/,'').downcase
|
245
|
+
end
|
246
|
+
|
247
|
+
# Exception class
|
248
|
+
class MiniExiftool::Error < StandardError; end
|
249
|
+
|
250
|
+
############################################################################
|
251
|
+
private
|
252
|
+
############################################################################
|
253
|
+
|
254
|
+
@@error_file = Tempfile.new 'errors'
|
255
|
+
@@error_file.close
|
256
|
+
|
257
|
+
def run cmd
|
258
|
+
if $DEBUG
|
259
|
+
$stderr.puts cmd
|
260
|
+
end
|
261
|
+
@output = `#{cmd} 2>#{@@error_file.path}`
|
262
|
+
@status = $?
|
263
|
+
unless @status.exitstatus == 0
|
264
|
+
@error_text = File.readlines(@@error_file.path).join
|
265
|
+
return false
|
266
|
+
else
|
267
|
+
@error_text = ''
|
268
|
+
return true
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def convert val
|
273
|
+
case val
|
274
|
+
when Time
|
275
|
+
val = val.strftime('%Y:%m:%d %H:%M:%S')
|
276
|
+
end
|
277
|
+
val
|
278
|
+
end
|
279
|
+
|
280
|
+
def method_missing symbol, *args
|
281
|
+
tag_name = symbol.id2name
|
282
|
+
if tag_name.sub!(/=$/, '')
|
283
|
+
self[tag_name] = args.first
|
284
|
+
else
|
285
|
+
self[tag_name]
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def parse_output
|
290
|
+
@output.each_line do |line|
|
291
|
+
tag, value = parse_line line
|
292
|
+
set_value tag, value
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def parse_line line
|
297
|
+
if line =~ /^([^\t]+)\t(.*)$/
|
298
|
+
tag, value = $1, $2
|
299
|
+
case value
|
300
|
+
when /^\d{4}:\d\d:\d\d \d\d:\d\d:\d\d$/
|
301
|
+
arr = value.split /[: ]/
|
302
|
+
arr.map! {|elem| elem.to_i}
|
303
|
+
begin
|
304
|
+
if @timestamps == Time
|
305
|
+
value = Time.local *arr
|
306
|
+
elsif @timestamps == DateTime
|
307
|
+
value = DateTime.strptime(value,'%Y:%m:%d %H:%M:%S')
|
308
|
+
else
|
309
|
+
raise MiniExiftool::Error.new("Value #@timestamps not allowed for option timestamps.")
|
310
|
+
end
|
311
|
+
rescue ArgumentError
|
312
|
+
value = false
|
313
|
+
end
|
314
|
+
when /^\d+\.\d+$/
|
315
|
+
value = value.to_f
|
316
|
+
when /^0+[1-9]+$/
|
317
|
+
# nothing => String
|
318
|
+
when /^-?\d+$/
|
319
|
+
value = value.to_i
|
320
|
+
when /^[\d ]+$/
|
321
|
+
# nothing => String
|
322
|
+
when /#{@@separator}/
|
323
|
+
value = value.split @@separator
|
324
|
+
end
|
325
|
+
else
|
326
|
+
raise MiniExiftool::Error.new("Malformed line #{line.inspect} of exiftool output.")
|
327
|
+
end
|
328
|
+
unless value.respond_to?('to_a')
|
329
|
+
class << value
|
330
|
+
def to_a
|
331
|
+
[self]
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
335
|
+
return [tag, value]
|
336
|
+
end
|
337
|
+
|
338
|
+
def set_value tag, value
|
339
|
+
@tag_names[tag] = tag
|
340
|
+
@values[tag] = value
|
341
|
+
end
|
342
|
+
|
343
|
+
def set_attributes_by_heuristic
|
344
|
+
self.composite = tags.include?('ImageSize') ? true : false
|
345
|
+
self.numerical = self.file_size.kind_of?(Integer) ? true : false
|
346
|
+
# TODO: Is there a heuristic to determine @convert_encoding?
|
347
|
+
self.timestamps = self.FileModifyDate.kind_of?(DateTime) ? DateTime : Time
|
348
|
+
end
|
349
|
+
|
350
|
+
def temp_filename
|
351
|
+
unless @temp_filename
|
352
|
+
temp_file = Tempfile.new('mini-exiftool')
|
353
|
+
temp_file.close
|
354
|
+
FileUtils.cp(@filename, temp_file.path)
|
355
|
+
@temp_filename = temp_file.path
|
356
|
+
end
|
357
|
+
@temp_filename
|
358
|
+
end
|
359
|
+
|
360
|
+
def self.pstore_get attribute
|
361
|
+
load_or_create_pstore unless defined? @@pstore
|
362
|
+
result = nil
|
363
|
+
@@pstore.transaction(true) do |ps|
|
364
|
+
result = ps[attribute]
|
365
|
+
end
|
366
|
+
result
|
367
|
+
end
|
368
|
+
|
369
|
+
def self.load_or_create_pstore
|
370
|
+
# This will hopefully work on *NIX and Windows systems
|
371
|
+
home = ENV['HOME'] || ENV['HOMEDRIVE'] + ENV['HOMEPATH'] || ENV['USERPROFILE']
|
372
|
+
subdir = RUBY_PLATFORM =~ /win/i ? '_mini_exiftool' : '.mini_exiftool'
|
373
|
+
FileUtils.mkdir_p(File.join(home, subdir))
|
374
|
+
filename = File.join(home, subdir, 'exiftool_tags_' << exiftool_version.gsub('.', '_') << '.pstore')
|
375
|
+
@@pstore = PStore.new filename
|
376
|
+
if !File.exist?(filename) || File.size(filename) == 0
|
377
|
+
@@pstore.transaction do |ps|
|
378
|
+
ps[:all_tags] = all_tags = determine_tags('list')
|
379
|
+
ps[:writable_tags] = determine_tags('listw')
|
380
|
+
map = {}
|
381
|
+
all_tags.each { |k| map[unify(k)] = k }
|
382
|
+
ps[:all_tags_map] = map
|
383
|
+
end
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
def self.determine_tags arg
|
388
|
+
output = `#{@@cmd} -#{arg}`
|
389
|
+
lines = output.split /\n/
|
390
|
+
tags = Set.new
|
391
|
+
lines.each do |line|
|
392
|
+
next unless line =~ /^\s/
|
393
|
+
tags |= line.chomp.split
|
394
|
+
end
|
395
|
+
tags
|
396
|
+
end
|
397
|
+
|
398
|
+
|
399
|
+
# Hash with indifferent access:
|
400
|
+
# DateTimeOriginal == datetimeoriginal == date_time_original
|
401
|
+
class TagHash < Hash # :nodoc:
|
402
|
+
def[] k
|
403
|
+
super(unify(k))
|
404
|
+
end
|
405
|
+
def []= k, v
|
406
|
+
super(unify(k), v)
|
407
|
+
end
|
408
|
+
def delete k
|
409
|
+
super(unify(k))
|
410
|
+
end
|
411
|
+
|
412
|
+
def unify tag
|
413
|
+
MiniExiftool.unify tag
|
414
|
+
end
|
415
|
+
end
|
416
|
+
end
|
metadata
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: photo_folder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "1.0"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ryan Johnson
|
8
|
+
autorequire: lib/photo_folder.rb
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2010-05-11 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: sqlite3-ruby
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.0.0
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: activerecord
|
27
|
+
type: :runtime
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.0.0
|
34
|
+
version:
|
35
|
+
description: JavaScript gallery
|
36
|
+
email: ryan@syntacticx.com
|
37
|
+
executables: []
|
38
|
+
|
39
|
+
extensions: []
|
40
|
+
|
41
|
+
extra_rdoc_files: []
|
42
|
+
|
43
|
+
files:
|
44
|
+
- README.markdown
|
45
|
+
- photo_folder.gemspec
|
46
|
+
- lib/models/photo.rb
|
47
|
+
- lib/models/photo_collection.rb
|
48
|
+
- lib/models/photo_tag.rb
|
49
|
+
- lib/models/photo_tagging.rb
|
50
|
+
- lib/photo_folder.rb
|
51
|
+
- vendor/acts_as_list.rb
|
52
|
+
- vendor/acts_as_tree.rb
|
53
|
+
- vendor/mini_exiftool.rb
|
54
|
+
has_rdoc: true
|
55
|
+
homepage: http://photofolder.org/
|
56
|
+
licenses: []
|
57
|
+
|
58
|
+
post_install_message:
|
59
|
+
rdoc_options: []
|
60
|
+
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
version:
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: "0"
|
74
|
+
version:
|
75
|
+
requirements: []
|
76
|
+
|
77
|
+
rubyforge_project:
|
78
|
+
rubygems_version: 1.3.5
|
79
|
+
signing_key:
|
80
|
+
specification_version: 3
|
81
|
+
summary: JavaScript gallery
|
82
|
+
test_files: []
|
83
|
+
|