photostat 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/Gemfile +4 -0
- data/README.md +27 -0
- data/Rakefile +1 -0
- data/bin/photostat +7 -0
- data/junk/filehash.rb +25 -0
- data/junk/flickr-sync-old.rb +242 -0
- data/junk/rack-sample.rb +12 -0
- data/junk/trash.rb +41 -0
- data/lib/photostat/db/001_create_photos_with_tags.rb +33 -0
- data/lib/photostat/db/002_photos_index.rb +5 -0
- data/lib/photostat/db/003_photos_add_flickr_info.rb +7 -0
- data/lib/photostat/db/base.rb +26 -0
- data/lib/photostat/plugins/00_base.rb +111 -0
- data/lib/photostat/plugins/01_local.rb +141 -0
- data/lib/photostat/plugins/02_flickr.rb +225 -0
- data/lib/photostat/utils/os.rb +104 -0
- data/lib/photostat.rb +89 -0
- data/photostat.gemspec +30 -0
- metadata +155 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
Photostat
|
2
|
+
=========
|
3
|
+
|
4
|
+
For managing photos, like a hacker. It is already usable for me, but
|
5
|
+
work is still in progress and I don't recommend it to people yet.
|
6
|
+
|
7
|
+
Here's what this app currently does:
|
8
|
+
|
9
|
+
* it builds a local repository of photos organized in $REPO/$Y-$m directories
|
10
|
+
* movies (MOV) are copied in $REPO/movies
|
11
|
+
* it takes care of duplicates (the file's identity is based on an MD5 hash + the created date)
|
12
|
+
* the creation date is taken from the file's EXIF data
|
13
|
+
* in case of movies, it is the date of the last modification
|
14
|
+
* on flickr:sync it uploads missing files from local repo to Flickr
|
15
|
+
(takes care of duplicates) and also downloads tags / visibility info
|
16
|
+
|
17
|
+
|
18
|
+
Rationale
|
19
|
+
---------
|
20
|
+
|
21
|
+
I'm tired of graphical UIs that suck.
|
22
|
+
|
23
|
+
I also need consistent backup in the cloud.
|
24
|
+
Now I'm working on Flickr integration, Picasa coming next.
|
25
|
+
|
26
|
+
I want 2 cheap cloud backups and a properly managed local repository.
|
27
|
+
These photos are too important for me.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/photostat
ADDED
data/junk/filehash.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'all_your_base'
|
2
|
+
|
3
|
+
module FileHash
|
4
|
+
|
5
|
+
def self.file_md5(file)
|
6
|
+
out = `md5sum #{file}`
|
7
|
+
out =~ /^(\S+)/
|
8
|
+
$1.strip
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.calculate(file)
|
12
|
+
base16charset = ('0'..'9').to_a + ('a'..'f').to_a
|
13
|
+
base16 = AllYourBase::Are.new({:charset => base16charset})
|
14
|
+
base62 = AllYourBase::Are.new({:charset => AllYourBase::Are::BASE_62_CHARSET})
|
15
|
+
|
16
|
+
md5 = file_md5(file).to_s.strip
|
17
|
+
ret = base16.convert_to_base_10(md5)
|
18
|
+
base62.convert_from_base_10(ret)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
if __FILE__ == $0 and ARGV.length
|
24
|
+
puts FileHash.calculate(ARGV[0])
|
25
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
require "optparse"
|
2
|
+
require 'yaml'
|
3
|
+
require 'flickraw'
|
4
|
+
require 'ostruct'
|
5
|
+
require 'benchmark'
|
6
|
+
|
7
|
+
|
8
|
+
module FlickrCLI
|
9
|
+
|
10
|
+
class Image
|
11
|
+
attr_accessor :md5, :local_path, :photoid
|
12
|
+
|
13
|
+
def self.from_path(path)
|
14
|
+
img = Image.new
|
15
|
+
img.local_path = path
|
16
|
+
img.md5 = calculate_md5(path)
|
17
|
+
return img
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.calculate_md5(file)
|
21
|
+
out = `md5sum #{file}`
|
22
|
+
out =~ /^(\S+)/
|
23
|
+
$1.strip
|
24
|
+
end
|
25
|
+
|
26
|
+
def flickr_id!
|
27
|
+
if not @photoid and md5
|
28
|
+
photos = flickr.photos.search(:user_id => "me", :tags => "checksum:md5=" + md5)
|
29
|
+
@photoid = photos[0].id if photos and photos.size > 0
|
30
|
+
end
|
31
|
+
@photoid
|
32
|
+
end
|
33
|
+
|
34
|
+
def upload(options)
|
35
|
+
set_name = options.delete(:set)
|
36
|
+
|
37
|
+
options[:title] ||= File.basename(local_path)
|
38
|
+
options[:tags] ||= ''
|
39
|
+
options[:tags] += ' sync checksum:md5=' + md5
|
40
|
+
options[:safety_level] ||= '1'
|
41
|
+
options[:content_type] ||= '1'
|
42
|
+
options[:is_family] ||= '0'
|
43
|
+
options[:is_friend] ||= '0'
|
44
|
+
options[:is_public] ||= '0'
|
45
|
+
options[:hidden] ||= '2'
|
46
|
+
|
47
|
+
photoid = flickr.upload_photo(local_path, options)
|
48
|
+
raise "Upload failed for #{local_path}" if not photoid
|
49
|
+
|
50
|
+
#if set_name
|
51
|
+
# set = flickr.photosets.getList.find {|set| set.title == set_name}
|
52
|
+
# if not set
|
53
|
+
# set = flickr.photosets.create(:title => set_name, :primary_photo_id => photoid)
|
54
|
+
# else
|
55
|
+
# flickr.photosets.addPhoto(:photoset_id => set.id, :photo_id => photoid)
|
56
|
+
# end
|
57
|
+
#end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
class ImageService
|
63
|
+
def initialize
|
64
|
+
@cache_file = ENV['HOME'] + "/.flickr-cli/flickr-id-md5-cache"
|
65
|
+
@local_images = []
|
66
|
+
|
67
|
+
if File.exists? @cache_file
|
68
|
+
@cache = YAML::load_file(@cache_file)
|
69
|
+
else
|
70
|
+
@cache = {}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def image_from_path(path)
|
75
|
+
img = Image.from_path(path)
|
76
|
+
img.photoid = @cache[img.md5]
|
77
|
+
|
78
|
+
if not img.photoid
|
79
|
+
photoid = img.flickr_id!
|
80
|
+
@cache[img.md5] = photoid
|
81
|
+
end
|
82
|
+
|
83
|
+
@local_images << img
|
84
|
+
return img
|
85
|
+
end
|
86
|
+
|
87
|
+
def save
|
88
|
+
File.open(@cache_file, 'w') do |f|
|
89
|
+
f.write(@cache.to_yaml)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.open
|
94
|
+
fh = ImageService.new
|
95
|
+
yield fh
|
96
|
+
ensure
|
97
|
+
fh.save if fh
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.search_local_dir(directory)
|
101
|
+
files = Dir[File.join(directory, "**", "*")].find_all{|path| path =~ /\.(jpe?g|png)$/i }
|
102
|
+
total = files.size
|
103
|
+
count = 0
|
104
|
+
|
105
|
+
benchmarks = []
|
106
|
+
last_avg = nil
|
107
|
+
|
108
|
+
self.open do |service|
|
109
|
+
files.each {|path|
|
110
|
+
|
111
|
+
uploaded = false
|
112
|
+
bm = Benchmark.measure do
|
113
|
+
count += 1
|
114
|
+
img = service.image_from_path(path)
|
115
|
+
|
116
|
+
stats = OpenStruct.new
|
117
|
+
stats.count = count
|
118
|
+
stats.total = total
|
119
|
+
stats.unit_time = last_avg
|
120
|
+
|
121
|
+
est = last_avg ? (total - count) * last_avg : nil
|
122
|
+
stats.estimate_time = est
|
123
|
+
|
124
|
+
unless img.photoid
|
125
|
+
yield img, stats
|
126
|
+
uploaded = true
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
if uploaded
|
131
|
+
benchmarks << bm.to_a[-1]
|
132
|
+
bp = benchmarks.size >= 10 ? benchmarks[-10,10] : benchmarks
|
133
|
+
last_avg = bp.inject{|m,e| m+e} / bp.size
|
134
|
+
end
|
135
|
+
}
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
class << self
|
142
|
+
|
143
|
+
# Takes a period of time in seconds and returns it in human-readable form (down to minutes)
|
144
|
+
def time_period_to_s(time_period)
|
145
|
+
begin
|
146
|
+
time_period = time_period.to_i
|
147
|
+
return nil if time_period == 0
|
148
|
+
rescue
|
149
|
+
return nil
|
150
|
+
end
|
151
|
+
|
152
|
+
interval_array = [ [:weeks, 604800], [:days, 86400], [:hours, 3600], [:mins, 60] ]
|
153
|
+
parts = []
|
154
|
+
|
155
|
+
interval_array.each do |sub|
|
156
|
+
if time_period>= sub[1] then
|
157
|
+
time_val, time_period = time_period.divmod( sub[1] )
|
158
|
+
name = sub[0].to_s
|
159
|
+
|
160
|
+
if time_val > 0
|
161
|
+
parts << "#{time_val} #{sub[0]}"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
return parts.join(', ')
|
167
|
+
end
|
168
|
+
|
169
|
+
def get_config_options
|
170
|
+
conf_file = ENV['HOME'] + "/.flickr-cli/config"
|
171
|
+
YAML::load_file(conf_file)
|
172
|
+
end
|
173
|
+
|
174
|
+
def authenticate(config)
|
175
|
+
FlickRaw.api_key = config[:app][:api_key]
|
176
|
+
FlickRaw.shared_secret = config[:app][:shared_secret]
|
177
|
+
|
178
|
+
flickr.auth.checkToken :auth_token => config[:user][:auth_token]
|
179
|
+
end
|
180
|
+
|
181
|
+
def synchronize_directory(directory, options)
|
182
|
+
config = get_config_options
|
183
|
+
auth = authenticate(config)
|
184
|
+
|
185
|
+
puts "UPLOADING to Flickr ...\n"
|
186
|
+
ImageService.search_local_dir(directory) do |img, stats|
|
187
|
+
estimate = time_period_to_s(stats.estimate_time)
|
188
|
+
$stdout.write "\rProgress: #{stats.count} / #{stats.total}" + (estimate ? " (remaining: #{estimate})" : '')
|
189
|
+
img.upload(:tags => "cristian baby sync private", :is_public => '0', :is_friend => "0", :is_family => "1", :safety_level => "1", :hidden => "2")
|
190
|
+
end
|
191
|
+
|
192
|
+
puts "\nDONE!\n"
|
193
|
+
end
|
194
|
+
|
195
|
+
def run
|
196
|
+
options = OpenStruct.new
|
197
|
+
|
198
|
+
command = nil
|
199
|
+
command = ARGV[0] if ARGV.length
|
200
|
+
|
201
|
+
parser = OptionParser.new do |opts|
|
202
|
+
opts.program_name = "Flickr Sync"
|
203
|
+
opts.banner = "\nUsage: #$0 <directory> [options]\n\n"
|
204
|
+
|
205
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
206
|
+
puts opts
|
207
|
+
puts
|
208
|
+
exit
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
begin
|
213
|
+
directory = ARGV && ARGV[0]
|
214
|
+
if not directory or not File.directory?(directory)
|
215
|
+
raise OptionParser::MissingArgument, "<directory>"
|
216
|
+
end
|
217
|
+
|
218
|
+
parser.parse!
|
219
|
+
|
220
|
+
conf_file = ENV['HOME'] + "/.flickr-cli/config"
|
221
|
+
if not File.exists?(conf_file)
|
222
|
+
raise "Configuration file missing, run ./flickr-conf"
|
223
|
+
end
|
224
|
+
|
225
|
+
rescue Exception => e
|
226
|
+
unless e.to_s == 'exit'
|
227
|
+
puts
|
228
|
+
puts "ERROR: #{e}"
|
229
|
+
puts
|
230
|
+
end
|
231
|
+
exit
|
232
|
+
end
|
233
|
+
|
234
|
+
synchronize_directory(directory, options)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
|
240
|
+
if __FILE__ == $0
|
241
|
+
FlickrCLI::run
|
242
|
+
end
|
data/junk/rack-sample.rb
ADDED
data/junk/trash.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
puts "Fetching photos in set ..."
|
2
|
+
|
3
|
+
set = flickr.photosets.getList.find {|set| set.title == options.set_name}
|
4
|
+
has_set = (set ? true : false)
|
5
|
+
|
6
|
+
debugger
|
7
|
+
|
8
|
+
if has_set
|
9
|
+
resp = flickr.photosets.getPhotos(:photoset_id => set.id)
|
10
|
+
|
11
|
+
puts resp.to_yaml
|
12
|
+
resp.each do |photo|
|
13
|
+
puts photo.to_yaml
|
14
|
+
end
|
15
|
+
exit
|
16
|
+
end
|
17
|
+
|
18
|
+
exit
|
19
|
+
puts "Indexing local files ..."
|
20
|
+
|
21
|
+
available_files = {}
|
22
|
+
photos_list(directory) do |fpath, md5|
|
23
|
+
puts "#{md5} #{fpath}"
|
24
|
+
raise Exception, "File conflict: #{fpath} #{available_files[md5]}" if available_files[md5]
|
25
|
+
available_files[md5] = fpath
|
26
|
+
end
|
27
|
+
|
28
|
+
puts "Uploading ..."
|
29
|
+
|
30
|
+
# uploading photos !!!
|
31
|
+
available_files.each do |md5, path|
|
32
|
+
puts path
|
33
|
+
photoid = flickr.upload_photo(path, :title => md5, :tags => "private", :is_public => '0', :is_friend => "0", :is_family => "0", :safety_level => "1", :hidden => "2")
|
34
|
+
if not has_set
|
35
|
+
set = flickr.photosets.create(:title => "Private", :primary_photo_id => photoid)
|
36
|
+
has_set = true
|
37
|
+
else
|
38
|
+
flickr.photosets.addPhoto(:photoset_id => set.id, :photo_id => photoid)
|
39
|
+
end
|
40
|
+
puts "... done!"
|
41
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
Sequel.migration do
|
2
|
+
up do
|
3
|
+
create_table :photos do
|
4
|
+
primary_key :id
|
5
|
+
|
6
|
+
String :uid, :null => false
|
7
|
+
String :type, :null => false
|
8
|
+
|
9
|
+
String :local_path, :null => false
|
10
|
+
String :visibility, :null => false
|
11
|
+
DateTime :created_at, :null => false
|
12
|
+
|
13
|
+
String :md5, :null => true
|
14
|
+
|
15
|
+
index :uid, :unique => true
|
16
|
+
index :local_path
|
17
|
+
index :type
|
18
|
+
index :md5
|
19
|
+
end
|
20
|
+
|
21
|
+
create_table :tags do
|
22
|
+
primary_key :id
|
23
|
+
String :name, :null => false
|
24
|
+
String :photo_id, :null => false
|
25
|
+
|
26
|
+
index [:photo_id, :name], :unique => true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
down do
|
31
|
+
drop_column :photos, :tags
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
Sequel.datetime_class = DateTime
|
3
|
+
|
4
|
+
|
5
|
+
module Photostat
|
6
|
+
module DB
|
7
|
+
def self.instance
|
8
|
+
unless @DB
|
9
|
+
config = Photostat.config
|
10
|
+
system = File.join(config[:repository_path], 'system')
|
11
|
+
Dir.mkdir system unless File.directory? system
|
12
|
+
|
13
|
+
path = File.join(system, 'photostat.db')
|
14
|
+
@DB = Sequel.sqlite(path)
|
15
|
+
end
|
16
|
+
return @DB
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.migrate!
|
20
|
+
db = self.instance
|
21
|
+
Sequel.extension :migration
|
22
|
+
Sequel::Migrator.apply(db, File.dirname(__FILE__))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Photostat
|
2
|
+
|
3
|
+
module Plugins
|
4
|
+
def self.all_in_order
|
5
|
+
unless @plugins_ordered
|
6
|
+
@plugins_ordered ||= []
|
7
|
+
end
|
8
|
+
@plugins_ordered
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.all
|
12
|
+
unless @plugins
|
13
|
+
@plugins ||= {}
|
14
|
+
end
|
15
|
+
@plugins
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.load_all!
|
19
|
+
# loads all available plugins
|
20
|
+
Photostat.root.join('plugins').children.sort.each do |plugin_path|
|
21
|
+
next if File.directory? plugin_path
|
22
|
+
next unless plugin_path.to_s =~ /\/\d+_\w+.rb$/
|
23
|
+
next unless File.basename(plugin_path) != '00_base.rb'
|
24
|
+
require plugin_path.to_s
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class Base
|
29
|
+
def self.help_text(msg=nil)
|
30
|
+
@help_text = msg if msg
|
31
|
+
@help_text
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.exposes_help
|
35
|
+
@exposes_help
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.exposes(name=nil, help_text=nil)
|
39
|
+
@exposes_names ||= []
|
40
|
+
@exposes_help ||= {}
|
41
|
+
|
42
|
+
if name
|
43
|
+
@exposes_names << name
|
44
|
+
@exposes_help[name] = help_text if help_text
|
45
|
+
end
|
46
|
+
@exposes_names
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.register_plugin(sub)
|
50
|
+
sub.name =~ /Photostat[:]{2}(\w+)/
|
51
|
+
name = $1
|
52
|
+
|
53
|
+
new_name = ''
|
54
|
+
name.each_char do |ch|
|
55
|
+
if ch != ch.downcase
|
56
|
+
ch = ch.downcase
|
57
|
+
ch = "_" + ch if new_name.length > 0
|
58
|
+
end
|
59
|
+
new_name += ch
|
60
|
+
end
|
61
|
+
|
62
|
+
sub.instance_variable_set :@plugin_name, new_name
|
63
|
+
::Photostat::Plugins.all[new_name] = sub
|
64
|
+
::Photostat::Plugins.all_in_order << sub
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.inherited(sub)
|
68
|
+
register_plugin sub
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.included(sub)
|
72
|
+
register_plugin sub
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.extended(sub)
|
76
|
+
register_plugin sub
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.plugin_name
|
80
|
+
@plugin_name
|
81
|
+
end
|
82
|
+
|
83
|
+
def activate!
|
84
|
+
# blank
|
85
|
+
end
|
86
|
+
|
87
|
+
def help
|
88
|
+
puts "Photostat version #{Photostat::VERSION}"
|
89
|
+
puts "Plugin: " + self.class.plugin_name.upcase
|
90
|
+
puts
|
91
|
+
puts "Usage: photostat <command> [options]*"
|
92
|
+
puts "For help on each individual command: photostat <command> --help"
|
93
|
+
puts
|
94
|
+
puts "Where command is one of the following:"
|
95
|
+
self.class.exposes.each do |name|
|
96
|
+
msg = " #{self.class.plugin_name}:#{name}"
|
97
|
+
if self.class.exposes_help[name]
|
98
|
+
msg += "\t- " + self.class.exposes_help[name]
|
99
|
+
end
|
100
|
+
puts msg
|
101
|
+
end
|
102
|
+
puts
|
103
|
+
end
|
104
|
+
|
105
|
+
def config
|
106
|
+
# blank
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module Photostat
|
2
|
+
|
3
|
+
class Local < Plugins::Base
|
4
|
+
include OSUtils
|
5
|
+
include FileUtils
|
6
|
+
|
7
|
+
help_text "Manages your local photos repository"
|
8
|
+
|
9
|
+
exposes :config, "Configures your local database, repository path and Flickr login"
|
10
|
+
exposes :import, "Imports images from a directory path (recursively) to your Photostat repository"
|
11
|
+
|
12
|
+
def activate!
|
13
|
+
unless @activated
|
14
|
+
@db = Photostat::DB.instance
|
15
|
+
Photostat::DB.migrate!
|
16
|
+
@activated = true
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def import
|
21
|
+
opts = Trollop::options do
|
22
|
+
opt :path, "Local path to import", :required => true, :type => :string, :short => "-p"
|
23
|
+
opt :tags, "List of tags to classify imported pictures", :type => :strings
|
24
|
+
opt :visibility, "Choices are 'private', 'protected' and 'public'", :required => true, :type => :string
|
25
|
+
opt :move, "Move, instead of copy (better performance, defaults to false, careful)", :type => :boolean
|
26
|
+
opt :dry, "Just fake it and print the resulting files", :type => :boolean
|
27
|
+
end
|
28
|
+
|
29
|
+
Trollop::die :path, "must be a valid directory" unless File.directory? opts[:path]
|
30
|
+
Trollop::die :visibility, "is invalid. Choices are: private, protected and public" unless ['private', 'protected', 'public'].member? opts[:visibility]
|
31
|
+
opts[:tags] ||= []
|
32
|
+
|
33
|
+
activate!
|
34
|
+
|
35
|
+
source = File.expand_path opts[:path]
|
36
|
+
config = Photostat.config
|
37
|
+
|
38
|
+
files = files_in_dir(source, :match => /(.jpe?g|.mov)$/i, :absolute? => true)
|
39
|
+
count, total = 0, files.length
|
40
|
+
puts
|
41
|
+
|
42
|
+
interrupted = false
|
43
|
+
trap("INT") { interrupted = true }
|
44
|
+
|
45
|
+
files.each do |fpath|
|
46
|
+
break if interrupted
|
47
|
+
count += 1
|
48
|
+
|
49
|
+
STDOUT.print "\r - processed: #{count} / #{total}"
|
50
|
+
STDOUT.flush
|
51
|
+
|
52
|
+
if fpath =~ /.jpe?g/i
|
53
|
+
type = 'jpg'
|
54
|
+
exif = EXIFR::JPEG.new fpath
|
55
|
+
dt = (exif.date_time || File.mtime(fpath)).getgm
|
56
|
+
else
|
57
|
+
type = 'mov'
|
58
|
+
dt = File.mtime(fpath).getgm
|
59
|
+
end
|
60
|
+
|
61
|
+
md5 = partial_file_md5 fpath
|
62
|
+
uid = dt.strftime("%Y%m%d%H%M%S") + "-" + md5[0,6] + "." + type
|
63
|
+
|
64
|
+
local_dir = type == 'jpg' ? dt.strftime("%Y-%m") : 'movies'
|
65
|
+
local_path = File.join(local_dir, uid)
|
66
|
+
dest_dir = File.join(config[:repository_path], local_dir)
|
67
|
+
dest_path = File.join(config[:repository_path], local_path)
|
68
|
+
|
69
|
+
photo = @db[:photos].where(:uid => uid).first
|
70
|
+
photo_id = photo ? photo[:id] : nil
|
71
|
+
|
72
|
+
unless photo || opts[:dry]
|
73
|
+
photo_id = @db[:photos].insert(
|
74
|
+
:uid => uid,
|
75
|
+
:type => type,
|
76
|
+
:local_path => local_path,
|
77
|
+
:visibility => opts[:visibility],
|
78
|
+
:created_at => dt,
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
opts[:tags].each do |name|
|
83
|
+
next if opts[:dry]
|
84
|
+
next unless @db[:tags].where(:name => name, :photo_id => photo_id).empty?
|
85
|
+
@db[:tags].insert(:name => name, :photo_id => photo_id)
|
86
|
+
end
|
87
|
+
|
88
|
+
next if File.exists? dest_path
|
89
|
+
next if File.expand_path(dest_path) == File.expand_path(fpath)
|
90
|
+
|
91
|
+
unless opts[:dry]
|
92
|
+
mkdir_p dest_dir
|
93
|
+
cp fpath, dest_path unless opts[:move]
|
94
|
+
mv fpath, dest_path if opts[:move]
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
if !files or files.length == 0
|
99
|
+
puts " - nothing to do"
|
100
|
+
end
|
101
|
+
|
102
|
+
if interrupted
|
103
|
+
puts
|
104
|
+
puts " - interrupted by user"
|
105
|
+
puts
|
106
|
+
exit 0
|
107
|
+
end
|
108
|
+
|
109
|
+
puts
|
110
|
+
end
|
111
|
+
|
112
|
+
def config
|
113
|
+
puts
|
114
|
+
config_file = File.expand_path "~/.photostat"
|
115
|
+
|
116
|
+
config = {}
|
117
|
+
config = YAML::load(File.read config_file) if File.exists? config_file
|
118
|
+
|
119
|
+
config[:repository_path] ||= "~/Photos"
|
120
|
+
config[:repository_path] = input(
|
121
|
+
"Wanted location for your Photostat repository",
|
122
|
+
:dir? => true, :default => config[:repository_path])
|
123
|
+
config[:repository_path] = File.expand_path(config[:repository_path])
|
124
|
+
|
125
|
+
puts
|
126
|
+
unless File.directory? config[:repository_path]
|
127
|
+
Dir.mkdir config[:repository_path]
|
128
|
+
puts " >>>> repository #{config[:repository_path]} created"
|
129
|
+
end
|
130
|
+
|
131
|
+
File.open(config_file, 'w') do |fh|
|
132
|
+
fh.write(YAML::dump(config))
|
133
|
+
puts " >>>> generated ~/.photostat config"
|
134
|
+
end
|
135
|
+
|
136
|
+
puts
|
137
|
+
end
|
138
|
+
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
module Photostat
|
2
|
+
class Flickr < Plugins::Base
|
3
|
+
include OSUtils
|
4
|
+
|
5
|
+
help_text "Manages your Flickr account"
|
6
|
+
|
7
|
+
exposes :config, "Configures Flickr login"
|
8
|
+
exposes :sync, "Uploads local photos to Flickr, downloads Flickr tags / visibility info"
|
9
|
+
|
10
|
+
def activate!
|
11
|
+
return if @activated
|
12
|
+
@activated = 1
|
13
|
+
|
14
|
+
# authenticating to Flickr
|
15
|
+
require 'flickraw'
|
16
|
+
|
17
|
+
@config = Photostat.config
|
18
|
+
config unless @config[:flickr]
|
19
|
+
|
20
|
+
FlickRaw.api_key = @config[:flickr][:api_key]
|
21
|
+
FlickRaw.shared_secret = @config[:flickr][:shared_secret]
|
22
|
+
|
23
|
+
flickr.access_token = @config[:flickr][:access_token]
|
24
|
+
flickr.access_secret = @config[:flickr][:access_secret]
|
25
|
+
|
26
|
+
begin
|
27
|
+
login = flickr.test.login
|
28
|
+
rescue FlickRaw::FailedResponse => e
|
29
|
+
STDERR.puts "ERROR: Flickr Authentication failed : #{e.msg}"
|
30
|
+
exit 1
|
31
|
+
end
|
32
|
+
|
33
|
+
Photostat::DB.migrate!
|
34
|
+
@db = Photostat::DB.instance
|
35
|
+
end
|
36
|
+
|
37
|
+
def update_md5_info_on_files!
|
38
|
+
activate!
|
39
|
+
|
40
|
+
rs = @db[:photos].where(:type => "jpg", :md5 => nil)
|
41
|
+
|
42
|
+
count = 0
|
43
|
+
total = rs.count
|
44
|
+
puts if total > 0
|
45
|
+
|
46
|
+
@db[:photos].where(:type => "jpg", :md5 => nil).all do |obj|
|
47
|
+
md5 = file_md5 File.join(@config[:repository_path], obj[:local_path])
|
48
|
+
@db[:photos].where(:uid => obj[:uid]).update(:md5 => md5)
|
49
|
+
|
50
|
+
count += 1
|
51
|
+
STDOUT.write("\r - processed md5 hash for local files: #{count} / #{total}")
|
52
|
+
STDOUT.flush
|
53
|
+
end
|
54
|
+
|
55
|
+
puts if total > 0
|
56
|
+
end
|
57
|
+
|
58
|
+
def update_flickr_info!
|
59
|
+
activate!
|
60
|
+
update_md5_info_on_files!
|
61
|
+
|
62
|
+
db = Photostat::DB.instance
|
63
|
+
|
64
|
+
rs = flickr.photos.search(:user_id => "me", :extras => 'machine_tags, tags, date_taken, date_upload', :per_page => 500)
|
65
|
+
pages_nr = rs.pages
|
66
|
+
page_idx = 1
|
67
|
+
|
68
|
+
count = 0
|
69
|
+
not_local = 0
|
70
|
+
not_tagged = 0
|
71
|
+
are_valid = 0
|
72
|
+
total = rs.total.to_i
|
73
|
+
|
74
|
+
valid_ids = []
|
75
|
+
|
76
|
+
puts if total > 0
|
77
|
+
while rs.length > 0
|
78
|
+
|
79
|
+
rs.each do |fphoto|
|
80
|
+
count += 1
|
81
|
+
|
82
|
+
unless fphoto.machine_tags =~ /checksum:md5=(\w+)/
|
83
|
+
not_tagged += 1
|
84
|
+
next
|
85
|
+
end
|
86
|
+
|
87
|
+
md5 = $1
|
88
|
+
obj = db[:photos].where(:md5 => md5).first
|
89
|
+
|
90
|
+
if not obj
|
91
|
+
not_local += 1
|
92
|
+
next
|
93
|
+
end
|
94
|
+
|
95
|
+
db[:tags].where(:photo_id => obj[:id]).delete
|
96
|
+
if fphoto[:tags] and !fphoto.tags.empty?
|
97
|
+
tags = fphoto.tags.split.select{|x| ! ['private', 'sync'].member?(x) && x !~ /checksum:/}
|
98
|
+
tags.each do |tag_name|
|
99
|
+
db[:tags].insert(:name => tag_name, :photo_id => obj[:id])
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
visibility = 'private'
|
104
|
+
visibility = 'protected' if fphoto.isfamily
|
105
|
+
visibility = 'public' if fphoto.ispublic
|
106
|
+
|
107
|
+
db[:photos].where(:md5 => md5).update(:has_flickr_upload => true, :visibility => visibility)
|
108
|
+
|
109
|
+
STDOUT.write("\r - processed #{count} of #{total}, with #{not_tagged} not tagged on flickr, #{not_local} not local")
|
110
|
+
STDOUT.flush
|
111
|
+
end
|
112
|
+
|
113
|
+
page_idx += 1
|
114
|
+
break if page_idx > pages_nr
|
115
|
+
rs = flickr.photos.search(:user_id => "me", :extras => 'machine_tags', :per_page => 500, :page => page_idx)
|
116
|
+
end
|
117
|
+
|
118
|
+
puts("\r - processed #{count} of #{total}, with #{not_tagged} not tagged and #{not_local} not local")
|
119
|
+
end
|
120
|
+
|
121
|
+
def sync
|
122
|
+
activate!
|
123
|
+
|
124
|
+
update_md5_info_on_files!
|
125
|
+
update_flickr_info!
|
126
|
+
|
127
|
+
db = Photostat::DB.instance
|
128
|
+
config = Photostat.config
|
129
|
+
|
130
|
+
rs = db[:photos].where(:type => 'jpg', :has_flickr_upload => false)
|
131
|
+
total = rs.count
|
132
|
+
count = 0
|
133
|
+
|
134
|
+
rs.order('created_at').all do |obj|
|
135
|
+
count += 1
|
136
|
+
STDOUT.write("\r - uploading file: #{count} / #{total}")
|
137
|
+
STDOUT.flush
|
138
|
+
|
139
|
+
src_path = File.join(config[:repository_path], obj[:local_path])
|
140
|
+
|
141
|
+
options = {}
|
142
|
+
options[:title] = obj[:uid]
|
143
|
+
options[:tags] = 'sync checksum:md5=' + obj[:md5]
|
144
|
+
options[:safety_level] = '1'
|
145
|
+
options[:content_type] = '1'
|
146
|
+
options[:is_family] = '0'
|
147
|
+
options[:is_friend] = '0'
|
148
|
+
options[:is_public] = '0'
|
149
|
+
options[:hidden] = '2'
|
150
|
+
|
151
|
+
begin
|
152
|
+
photoid = flickr.upload_photo(src_path, options)
|
153
|
+
rescue
|
154
|
+
sleep 5
|
155
|
+
# retrying again
|
156
|
+
photoid = flickr.upload_photo(src_path, options)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
puts "\n"
|
161
|
+
end
|
162
|
+
|
163
|
+
def config
|
164
|
+
puts
|
165
|
+
config_file = File.expand_path "~/.photostat"
|
166
|
+
|
167
|
+
unless File.exists? config_file
|
168
|
+
Photostat.configure_plugin! :local
|
169
|
+
end
|
170
|
+
|
171
|
+
config = YAML::load(File.read config_file)
|
172
|
+
|
173
|
+
config[:flickr] ||= {
|
174
|
+
:api_key => nil,
|
175
|
+
:shared_secret => nil
|
176
|
+
}
|
177
|
+
|
178
|
+
puts "Configuring Flickr!"
|
179
|
+
puts "-------------------"
|
180
|
+
puts
|
181
|
+
puts "You need to create an app to access your account, see:"
|
182
|
+
puts " http://www.flickr.com/services/apps/create/apply/"
|
183
|
+
puts "Or if you already have an app key available, find it here:"
|
184
|
+
puts " http://www.flickr.com/services/apps/"
|
185
|
+
puts
|
186
|
+
|
187
|
+
config[:flickr][:api_key] = input("Flickr Authentication :: Api Key",
|
188
|
+
:default => config[:flickr][:api_key])
|
189
|
+
config[:flickr][:shared_secret] = input("Flickr Authentication :: Shared Secret",
|
190
|
+
:default => config[:flickr][:shared_secret])
|
191
|
+
|
192
|
+
require 'flickraw'
|
193
|
+
|
194
|
+
FlickRaw.api_key = config[:flickr][:api_key]
|
195
|
+
FlickRaw.shared_secret = config[:flickr][:shared_secret]
|
196
|
+
|
197
|
+
token = flickr.get_request_token
|
198
|
+
auth_url = flickr.get_authorize_url(token['oauth_token'], :perms => 'delete')
|
199
|
+
|
200
|
+
puts
|
201
|
+
puts "Open this url in your process to complete the authication process:"
|
202
|
+
puts " " + auth_url
|
203
|
+
|
204
|
+
verify = input("Copy here the number given when visiting the link above")
|
205
|
+
begin
|
206
|
+
flickr.get_access_token(token['oauth_token'], token['oauth_token_secret'], verify)
|
207
|
+
login = flickr.test.login
|
208
|
+
puts "You are now authenticated as #{login.username} with token #{flickr.access_token} and secret #{flickr.access_secret}"
|
209
|
+
rescue FlickRaw::FailedResponse => e
|
210
|
+
STDERR.puts "ERROR: Flickr Authentication failed : #{e.msg}"
|
211
|
+
exit 1
|
212
|
+
end
|
213
|
+
|
214
|
+
config[:flickr][:access_token] = flickr.access_token
|
215
|
+
config[:flickr][:access_secret] = flickr.access_secret
|
216
|
+
|
217
|
+
File.open(config_file, 'w') do |fh|
|
218
|
+
fh.write(YAML::dump(config))
|
219
|
+
puts
|
220
|
+
puts " >>>> modified ~/.photostat config"
|
221
|
+
puts
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module Photostat
|
4
|
+
module OSUtils
|
5
|
+
def input(msg, options=nil)
|
6
|
+
options ||= {}
|
7
|
+
default = options[:default]
|
8
|
+
is_dir = options[:dir?]
|
9
|
+
|
10
|
+
if default and !default.empty?
|
11
|
+
msg = msg + " (#{default}): "
|
12
|
+
else
|
13
|
+
msg = msg + ": "
|
14
|
+
end
|
15
|
+
|
16
|
+
print msg
|
17
|
+
resp = nil
|
18
|
+
|
19
|
+
while not resp
|
20
|
+
resp = STDIN.readline.strip
|
21
|
+
resp = default if !resp or resp.empty?
|
22
|
+
resp = File.expand_path resp if is_dir and resp and !resp.empty?
|
23
|
+
|
24
|
+
error_msg = "Invalid response"
|
25
|
+
|
26
|
+
is_not_dir = false
|
27
|
+
if resp and !resp.empty? and !File.directory? resp
|
28
|
+
is_not_dir = true if File.exists? resp
|
29
|
+
is_not_dir = true unless File.directory? File.dirname(resp)
|
30
|
+
error_msg = "Invalid directory path"
|
31
|
+
end
|
32
|
+
|
33
|
+
if !resp or resp.empty? or is_not_dir
|
34
|
+
puts "ERROR: #{error_msg}!"
|
35
|
+
print "\nTry again, #{msg}"
|
36
|
+
resp = nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
return resp
|
41
|
+
end
|
42
|
+
|
43
|
+
def exec(*params)
|
44
|
+
cmd = Escape.shell_command(params)
|
45
|
+
out = `#{cmd} 2>&1`
|
46
|
+
unless $?.exitstatus == 0
|
47
|
+
raise "Command exit with error!\nCommand: #{cmd}\nOut: #{out}"
|
48
|
+
end
|
49
|
+
return out
|
50
|
+
end
|
51
|
+
|
52
|
+
def files_in_dir(dir, options=nil)
|
53
|
+
files = []
|
54
|
+
dirs = []
|
55
|
+
dir = File.expand_path dir
|
56
|
+
current = dir
|
57
|
+
|
58
|
+
return [] unless File.directory? current
|
59
|
+
|
60
|
+
match = options[:match] if options
|
61
|
+
not_match = options[:not_match] if options
|
62
|
+
non_recursive = options[:non_recursive]
|
63
|
+
is_abs = options[:absolute?] or false
|
64
|
+
|
65
|
+
while current
|
66
|
+
Dir.entries(current).each do |name|
|
67
|
+
next unless name != '.' and name != '..'
|
68
|
+
path = File.join(current, name)
|
69
|
+
|
70
|
+
valid = true
|
71
|
+
valid = path =~ match if match and valid
|
72
|
+
valid = path !~ not_match if not_match and valid
|
73
|
+
|
74
|
+
if valid
|
75
|
+
rpath = path[dir.length+1,path.length]
|
76
|
+
yielded = is_abs ? File.join(dir, rpath) : rpath
|
77
|
+
files.push yielded
|
78
|
+
yield yielded if block_given?
|
79
|
+
end
|
80
|
+
|
81
|
+
dirs.push path if !non_recursive and File.directory? path
|
82
|
+
end
|
83
|
+
|
84
|
+
current = dirs.pop
|
85
|
+
end
|
86
|
+
|
87
|
+
files
|
88
|
+
end
|
89
|
+
|
90
|
+
def file_md5(file)
|
91
|
+
Digest::MD5.file(file).hexdigest
|
92
|
+
end
|
93
|
+
|
94
|
+
def partial_file_md5(file)
|
95
|
+
digest = Digest::MD5.new
|
96
|
+
digest << IO.read(file, 61440)
|
97
|
+
digest.hexdigest
|
98
|
+
end
|
99
|
+
|
100
|
+
def string_md5(string)
|
101
|
+
Digest::MD5.hexdigest(string)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
data/lib/photostat.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
require "benchmark"
|
2
|
+
require "pathname"
|
3
|
+
require "yaml"
|
4
|
+
require "logger"
|
5
|
+
require "exifr"
|
6
|
+
require 'fileutils'
|
7
|
+
require "escape"
|
8
|
+
require 'logger'
|
9
|
+
require 'trollop'
|
10
|
+
|
11
|
+
require "photostat/utils/os"
|
12
|
+
require "photostat/db/base"
|
13
|
+
require "photostat/plugins/00_base"
|
14
|
+
|
15
|
+
module Photostat
|
16
|
+
VERSION = "0.0.1"
|
17
|
+
|
18
|
+
def self.root
|
19
|
+
Pathname.new File.join(File.dirname(__FILE__), 'photostat')
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.config
|
23
|
+
unless @config
|
24
|
+
@config = YAML.load(File.read(File.expand_path "~/.photostat"))
|
25
|
+
end
|
26
|
+
@config
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.activate!
|
30
|
+
Photostat::Plugins.all_in_order.each do |plugin|
|
31
|
+
plugin.new.activate!
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.configure_plugin!(name)
|
36
|
+
Photostat::Plugins.all[name.to_s].new.config
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.activate_plugin!(name)
|
40
|
+
Photostat::Plugin.all[name.to_s].new.activate!
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.execute
|
44
|
+
cmd, subc = nil, :help
|
45
|
+
cmd = ARGV.shift if ARGV and ARGV.length > 0
|
46
|
+
if cmd =~ /^(\w+):(\w+)$/
|
47
|
+
cmd, subc = $1, $2.to_sym
|
48
|
+
end
|
49
|
+
|
50
|
+
all_plugins = Photostat::Plugins.all
|
51
|
+
if cmd and all_plugins[cmd]
|
52
|
+
all_plugins[cmd].new.send subc
|
53
|
+
else
|
54
|
+
show_help
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.show_help
|
59
|
+
puts "Photostat version #{Photostat::VERSION}"
|
60
|
+
puts
|
61
|
+
puts "Getting help for a particular plugin: photostat <plugin_name>"
|
62
|
+
puts "Getting help for a particular command:: photostat <command> --help"
|
63
|
+
puts
|
64
|
+
puts "Available plugins:"
|
65
|
+
|
66
|
+
Photostat::Plugins.all.each_key do |cmd_name|
|
67
|
+
cmd_obj = Photostat::Plugins.all[cmd_name]
|
68
|
+
help_text = "#{cmd_name}"
|
69
|
+
help_text += "\t- #{cmd_obj.help_text}" if cmd_obj.help_text
|
70
|
+
puts " #{help_text}"
|
71
|
+
end
|
72
|
+
|
73
|
+
puts
|
74
|
+
puts "All available commands:"
|
75
|
+
Photostat::Plugins.all.each_key do |cmd_name|
|
76
|
+
cmd_obj = Photostat::Plugins.all[cmd_name]
|
77
|
+
cmd_obj.exposes.each do |name|
|
78
|
+
msg = " #{cmd_obj.plugin_name}:#{name}"
|
79
|
+
if cmd_obj.exposes_help[name]
|
80
|
+
msg += "\t- " + cmd_obj.exposes_help[name]
|
81
|
+
end
|
82
|
+
puts msg
|
83
|
+
end
|
84
|
+
end
|
85
|
+
puts
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
Photostat::Plugins.load_all!
|
data/photostat.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "photostat/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "photostat"
|
7
|
+
s.version = Photostat::VERSION
|
8
|
+
s.authors = ["Alexandru Nedelcu"]
|
9
|
+
s.email = ["me@alexn.org"]
|
10
|
+
s.homepage = "http://github.com/alexandru/photostat"
|
11
|
+
s.summary = %q{Managing Photos For Hackers}
|
12
|
+
s.description = %q{Photostat is a collection of command-line utilities for managing photos / syncronizing with Flickr - first version doesnt do much, it just helps me organize my files and upload them to Flickr}
|
13
|
+
|
14
|
+
s.rubyforge_project = "photostat"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency "bundler"
|
22
|
+
s.add_development_dependency "rake"
|
23
|
+
|
24
|
+
s.add_runtime_dependency 'sqlite3'
|
25
|
+
s.add_runtime_dependency "trollop"
|
26
|
+
s.add_runtime_dependency "escape"
|
27
|
+
s.add_runtime_dependency "flickraw"
|
28
|
+
s.add_runtime_dependency "exifr"
|
29
|
+
s.add_runtime_dependency "json"
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: photostat
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Alexandru Nedelcu
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-10-29 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: &74575380 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *74575380
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: rake
|
27
|
+
requirement: &74575170 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *74575170
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: sqlite3
|
38
|
+
requirement: &74574890 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :runtime
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *74574890
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: trollop
|
49
|
+
requirement: &74574610 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :runtime
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *74574610
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: escape
|
60
|
+
requirement: &74574400 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :runtime
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *74574400
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: flickraw
|
71
|
+
requirement: &74574180 !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :runtime
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: *74574180
|
80
|
+
- !ruby/object:Gem::Dependency
|
81
|
+
name: exifr
|
82
|
+
requirement: &74573940 !ruby/object:Gem::Requirement
|
83
|
+
none: false
|
84
|
+
requirements:
|
85
|
+
- - ! '>='
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0'
|
88
|
+
type: :runtime
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: *74573940
|
91
|
+
- !ruby/object:Gem::Dependency
|
92
|
+
name: json
|
93
|
+
requirement: &74573730 !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ! '>='
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
type: :runtime
|
100
|
+
prerelease: false
|
101
|
+
version_requirements: *74573730
|
102
|
+
description: Photostat is a collection of command-line utilities for managing photos
|
103
|
+
/ syncronizing with Flickr - first version doesnt do much, it just helps me organize
|
104
|
+
my files and upload them to Flickr
|
105
|
+
email:
|
106
|
+
- me@alexn.org
|
107
|
+
executables:
|
108
|
+
- photostat
|
109
|
+
extensions: []
|
110
|
+
extra_rdoc_files: []
|
111
|
+
files:
|
112
|
+
- .gitignore
|
113
|
+
- Gemfile
|
114
|
+
- README.md
|
115
|
+
- Rakefile
|
116
|
+
- bin/photostat
|
117
|
+
- junk/filehash.rb
|
118
|
+
- junk/flickr-sync-old.rb
|
119
|
+
- junk/rack-sample.rb
|
120
|
+
- junk/trash.rb
|
121
|
+
- lib/photostat.rb
|
122
|
+
- lib/photostat/db/001_create_photos_with_tags.rb
|
123
|
+
- lib/photostat/db/002_photos_index.rb
|
124
|
+
- lib/photostat/db/003_photos_add_flickr_info.rb
|
125
|
+
- lib/photostat/db/base.rb
|
126
|
+
- lib/photostat/plugins/00_base.rb
|
127
|
+
- lib/photostat/plugins/01_local.rb
|
128
|
+
- lib/photostat/plugins/02_flickr.rb
|
129
|
+
- lib/photostat/utils/os.rb
|
130
|
+
- photostat.gemspec
|
131
|
+
homepage: http://github.com/alexandru/photostat
|
132
|
+
licenses: []
|
133
|
+
post_install_message:
|
134
|
+
rdoc_options: []
|
135
|
+
require_paths:
|
136
|
+
- lib
|
137
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
138
|
+
none: false
|
139
|
+
requirements:
|
140
|
+
- - ! '>='
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '0'
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
144
|
+
none: false
|
145
|
+
requirements:
|
146
|
+
- - ! '>='
|
147
|
+
- !ruby/object:Gem::Version
|
148
|
+
version: '0'
|
149
|
+
requirements: []
|
150
|
+
rubyforge_project: photostat
|
151
|
+
rubygems_version: 1.8.6
|
152
|
+
signing_key:
|
153
|
+
specification_version: 3
|
154
|
+
summary: Managing Photos For Hackers
|
155
|
+
test_files: []
|