photo_folder 1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|