adri 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +20 -0
  3. data/README.md +166 -0
  4. data/bin/adri +306 -0
  5. metadata +127 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2fc54b8d1ef7e4486a3d5926fc8c28633ceab6709d0a9160d026ae8497e99e65
4
+ data.tar.gz: ea3895da8d463f866fe4b702f0204a31596dd0047d401fc6cdcf2ff4321d676d
5
+ SHA512:
6
+ metadata.gz: 5433f1bafa362f9d04e5c1c6401195941c82ba08aa86ce8f597b23459b58889311ca65e827ad5ebce9c018dea393d1a89274e840adac4926abe41c99ad7756bd
7
+ data.tar.gz: e9b46b82b46126ba281fd895154093bb339f9c3fb0bcc928acd888251e9ceb87323bd01f483432a8177e78c0dbd11beaa6c245dc828c512b37a96fb5f212d884
@@ -0,0 +1,20 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2018 Angelos Orfanakos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,166 @@
1
+ # adri
2
+
3
+ adri organizes JPEG/TIFF photographs according to their EXIF date and location
4
+ data into a custom directory structure.
5
+
6
+ In other words, it turns this:
7
+
8
+ ```sh
9
+ $ ls -1 photos/*.jpg
10
+ IMG100001.jpg
11
+ IMG100002.jpg
12
+ IMG100003.jpg
13
+ ```
14
+
15
+ To this:
16
+
17
+ ```sh
18
+ $ tree photos/2018/
19
+ photos/2018/
20
+ └── 10/
21
+ └── 14/
22
+ └── London
23
+ ├── IMG100001.jpg
24
+ ├── IMG100002.jpg
25
+ └── IMG100003.jpg
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ Install the necessary packages. For Debian/Ubuntu, issue:
31
+
32
+ ```sh
33
+ sudo apt install ruby-full git build-essential libexif-dev
34
+ ```
35
+
36
+ Install adri:
37
+
38
+ ```sh
39
+ sudo gem install adri
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ adri converts the GPS coordinates (latitude and longitude) recorded in a
45
+ photograph's EXIF headers to a location name using the [Google Maps API][].
46
+
47
+ To use it, you need a free [API key][] with the Geocoding API enabled.
48
+
49
+ You can then set the API key in a `GOOGLE_API_KEY` environment variable in your
50
+ shell's configuration file. For Bash, issue:
51
+
52
+ ```sh
53
+ $ cat >>.~/.bashrc
54
+ export GOOGLE_API_KEY=yourapikeyhere
55
+ ^D
56
+ ```
57
+
58
+ Note: `^D` stands for `Ctrl-D`
59
+
60
+ You can also pass the API key as a command line option with `--api-key`. This
61
+ overrides the environment variable.
62
+
63
+ ## Use
64
+
65
+ To get the help text, issue:
66
+
67
+ ```sh
68
+ $ adri -h
69
+ usage: adri [options] <path>...
70
+ -p, --prefix Place everything under this path (default: photo parent directory)
71
+ -f, --path-format Format path with strftime and %{location} (default: %Y/%m/%d/%{location})
72
+ --api-key Google Maps API key (default: $GOOGLE_API_KEY)
73
+ --run Perform changes instead of a dry run
74
+ -q, --quiet Do not print operations
75
+ --version Print program version
76
+ -h, --help Print help text
77
+ ```
78
+
79
+ **By default, adri runs in dry run mode.** This means it simply prints out what
80
+ it would do, without actually doing it:
81
+
82
+ ```sh
83
+ $ pwd
84
+ /home/agorf/work/adri/
85
+ $ ls -1 photos/*.jpg
86
+ IMG100001.jpg
87
+ IMG100002.jpg
88
+ IMG100003.jpg
89
+ $ adri photos/*.jpg
90
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100001.jpg (DRY RUN)
91
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100002.jpg (DRY RUN)
92
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100003.jpg (DRY RUN)
93
+ $ ls -1 photos/
94
+ IMG100001.jpg
95
+ IMG100002.jpg
96
+ IMG100003.jpg
97
+ ```
98
+
99
+ To apply the changes, use the `--run` option:
100
+
101
+ ```sh
102
+ $ adri --run photos/*.jpg
103
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100001.jpg
104
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100002.jpg
105
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100003.jpg
106
+ $ tree photos/
107
+ photos/
108
+ └── 2018/
109
+ └── 10/
110
+ └── 14/
111
+ └── London/
112
+ ├── IMG100001.jpg
113
+ ├── IMG100002.jpg
114
+ └── IMG100003.jpg
115
+ ```
116
+
117
+ To place everything under a path other than the parent directory of each
118
+ photograph, use the `--prefix` option:
119
+
120
+ ```sh
121
+ $ adri --prefix . photos/*.jpg
122
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/2018/10/14/London/IMG100001.jpg (DRY RUN)
123
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/2018/10/14/London/IMG100002.jpg (DRY RUN)
124
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/2018/10/14/London/IMG100003.jpg (DRY RUN)
125
+ ```
126
+
127
+ The default path format is year/month/day/location. It is possible to specify a
128
+ custom one with the `--path-format` option:
129
+
130
+ ```sh
131
+ $ adri --path-format '%{location}/%b %Y/%d' photos/*.jpg
132
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/photos/London/Oct 2018/14/IMG100001.jpg (DRY RUN)
133
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/photos/London/Oct 2018/14/IMG100002.jpg (DRY RUN)
134
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/photos/London/Oct 2018/14/IMG100003.jpg (DRY RUN)
135
+ ```
136
+
137
+ The date is formatted according to [strftime(3)][strftime].
138
+
139
+ It's also possible to process many photos at once by passing space-separated
140
+ file names and directories (in which case adri will [recurse][]):
141
+
142
+ ```sh
143
+ $ adri photos/IMG100001.jpg photos/IMG100002.jpg photos/IMG100003.jpg
144
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100001.jpg (DRY RUN)
145
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100002.jpg (DRY RUN)
146
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100003.jpg (DRY RUN)
147
+ $ adri photos/
148
+ /home/agorf/work/adri/photos/IMG100001.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100001.jpg (DRY RUN)
149
+ /home/agorf/work/adri/photos/IMG100002.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100002.jpg (DRY RUN)
150
+ /home/agorf/work/adri/photos/IMG100003.jpg -> /home/agorf/work/adri/photos/2018/10/14/London/IMG100003.jpg (DRY RUN)
151
+ ```
152
+
153
+ ## License
154
+
155
+ [MIT][]
156
+
157
+ ## Author
158
+
159
+ [Angelos Orfanakos](https://agorf.gr/contact/)
160
+
161
+ [Bundler]: https://bundler.io/
162
+ [Google Maps API]: https://developers.google.com/maps/documentation/javascript/examples/geocoding-reverse
163
+ [API key]: https://cloud.google.com/maps-platform/#get-started
164
+ [MIT]: https://github.com/agorf/adri/blob/master/LICENSE.txt
165
+ [recurse]: https://softwareengineering.stackexchange.com/a/184600/316578
166
+ [strftime]: http://man7.org/linux/man-pages/man3/strftime.3.html
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ begin
7
+ require 'dotenv/load'
8
+ rescue LoadError
9
+ end
10
+
11
+ require 'exif'
12
+ require 'geocoder'
13
+ require 'slop'
14
+
15
+ module Adri
16
+ DEFAULT_PATH_FORMAT = '%Y/%m/%d/%{location}'.freeze
17
+ EXTENSIONS = %w[jpg jpeg JPG JPEG tif tiff TIF TIFF].freeze
18
+ GEOCODE_MAX_DELAY = 60 # Seconds
19
+ LOCATION_CACHE_SCALE = 2
20
+ VERSION = '0.0.1'.freeze
21
+
22
+ class Photo
23
+ class << self
24
+ attr_accessor :location_cache
25
+ end
26
+
27
+ self.location_cache = Hash.new(false)
28
+
29
+ attr_reader(
30
+ :source_path,
31
+ :prefix,
32
+ :path_format,
33
+ :verbose,
34
+ :dry_run
35
+ )
36
+
37
+ def initialize(path, options)
38
+ @source_path = File.absolute_path(path)
39
+ @path_format = options[:path_format].gsub('/', File::SEPARATOR)
40
+ @verbose = !options[:quiet]
41
+ @dry_run = !options[:run]
42
+
43
+ @prefix =
44
+ if options[:prefix].to_s.strip.empty?
45
+ File.dirname(@source_path)
46
+ else
47
+ File.absolute_path(options[:prefix])
48
+ end
49
+ end
50
+
51
+ def date_time
52
+ return @date_time if @date_time
53
+
54
+ if exif&.date_time && exif.date_time != '0000:00:00 00:00:00'
55
+ @date_time =
56
+ Time.strptime(exif.date_time.sub(' 24:', ' 00:'), '%Y:%m:%d %H:%M:%S')
57
+ end
58
+ end
59
+
60
+ def latitude
61
+ return @latitude if @latitude
62
+
63
+ if exif&.gps_latitude
64
+ @latitude = geo_float(exif.gps_latitude)
65
+ end
66
+ end
67
+
68
+ def longitude
69
+ return @longitude if @longitude
70
+
71
+ if exif&.gps_longitude
72
+ @longitude = geo_float(exif.gps_longitude)
73
+ end
74
+ end
75
+
76
+ def location
77
+ return @location if defined?(@location)
78
+
79
+ @location = read_location_from_cache
80
+
81
+ return @location if @location != false
82
+
83
+ @location = location_from_latlng
84
+
85
+ write_location_to_cache
86
+
87
+ @location
88
+ end
89
+
90
+ def destination_path
91
+ return @destination_path if @destination_path
92
+
93
+ path = date_time.strftime(path_format)
94
+
95
+ if location_in_path_format?
96
+ path = sprintf(path, location: location)
97
+ end
98
+
99
+ @destination_path ||= File.join(
100
+ prefix,
101
+ path,
102
+ File.basename(source_path)
103
+ )
104
+ end
105
+
106
+ def move
107
+ return if skip_move?
108
+
109
+ if verbose
110
+ puts sprintf(
111
+ '%s -> %s%s',
112
+ source_path,
113
+ destination_path,
114
+ dry_run ? ' (DRY RUN)' : ''
115
+ )
116
+ end
117
+
118
+ return if dry_run
119
+
120
+ FileUtils.mkdir_p(File.dirname(destination_path))
121
+ FileUtils.mv(source_path, destination_path)
122
+ end
123
+
124
+ private def exif
125
+ begin
126
+ @exif ||= Exif::Data.new(File.open(source_path))
127
+ rescue Exif::NotReadable
128
+ end
129
+ end
130
+
131
+ private def location_in_path_format?
132
+ path_format['%{location}']
133
+ end
134
+
135
+ private def latlng
136
+ @latlng ||= [latitude, longitude].compact
137
+ end
138
+
139
+ private def skip_move?
140
+ if !File.exist?(source_path)
141
+ puts "Missing file #{source_path}" if verbose
142
+ return true
143
+ end
144
+
145
+ if date_time.nil?
146
+ puts "No datetime data #{source_path}" if verbose
147
+ return true
148
+ end
149
+
150
+ if location_in_path_format?
151
+ if latlng.empty?
152
+ puts "No location data #{source_path}" if verbose
153
+ return true
154
+ end
155
+
156
+ if location.nil? # Geocoding failed
157
+ puts "Unknown location #{source_path}" if verbose
158
+ return true
159
+ end
160
+ end
161
+
162
+ if File.exist?(destination_path)
163
+ puts "Existing file #{destination_path}" if verbose
164
+ return true
165
+ end
166
+
167
+ false
168
+ end
169
+
170
+ private def location_from_latlng
171
+ current_delay = 0.1 # 100 ms
172
+
173
+ begin
174
+ geocode_results = Geocoder.search(latlng)
175
+ rescue Geocoder::OverQueryLimitError
176
+ puts 'Got OverQueryLimitError' if verbose
177
+
178
+ if current_delay > GEOCODE_MAX_DELAY
179
+ puts "Exceeded max delay of #{GEOCODE_MAX_DELAY} seconds" if verbose
180
+ return
181
+ end
182
+
183
+ puts "Waiting #{current_delay} seconds before retrying..." if verbose
184
+ sleep(current_delay)
185
+ current_delay *= 2 # Exponential backoff
186
+
187
+ retry
188
+ end
189
+
190
+ geocode_results.map(&:city).compact.uniq.first
191
+ end
192
+
193
+ private def location_cache_key
194
+ @location_cache_key ||= [
195
+ latitude.truncate(LOCATION_CACHE_SCALE).to_s,
196
+ longitude.truncate(LOCATION_CACHE_SCALE).to_s
197
+ ]
198
+ end
199
+
200
+ private def write_location_to_cache
201
+ self.class.location_cache[location_cache_key] = location
202
+ end
203
+
204
+ private def read_location_from_cache
205
+ self.class.location_cache[location_cache_key]
206
+ end
207
+
208
+ private def geo_float(value)
209
+ degrees, minutes, seconds = value
210
+ degrees + minutes / 60.0 + seconds / 3600.0
211
+ end
212
+ end
213
+
214
+ def self.parse_args
215
+ Slop.parse do |o|
216
+ o.banner = "usage: #{$PROGRAM_NAME} [options] <path>..."
217
+
218
+ o.string(
219
+ '-p',
220
+ '--prefix',
221
+ 'Place everything under this path (default: photo parent directory)'
222
+ )
223
+
224
+ o.string(
225
+ '-f',
226
+ '--path-format',
227
+ 'Format path with strftime and %{location} (default: ' \
228
+ "#{DEFAULT_PATH_FORMAT})",
229
+ default: DEFAULT_PATH_FORMAT
230
+ )
231
+
232
+ o.string(
233
+ '--api-key',
234
+ 'Google Maps API key (default: $GOOGLE_API_KEY)',
235
+ default: ENV['GOOGLE_API_KEY']
236
+ )
237
+
238
+ o.bool('--run', 'Perform changes instead of a dry run')
239
+
240
+ o.bool('-q', '--quiet', 'Do not print operations')
241
+
242
+ o.on('--version', 'Print program version') do
243
+ puts [$PROGRAM_NAME, VERSION].join(' ')
244
+ exit
245
+ end
246
+
247
+ o.on('-h', '--help', 'Print help text') do
248
+ puts o
249
+ exit
250
+ end
251
+ end
252
+ end
253
+
254
+ def self.expand_paths(paths, verbose)
255
+ glob_pattern = File.join('**', "*.{#{EXTENSIONS.join(',')}}")
256
+
257
+ Enumerator.new do |y|
258
+ paths.each do |path|
259
+ file_paths =
260
+ if FileTest.directory?(path)
261
+ Dir.glob(File.join(path, glob_pattern)).sort
262
+ elsif !File.exist?(path)
263
+ puts "Missing #{path}" if verbose
264
+ elsif !FileTest.file?(path) || FileTest.symlink?(path)
265
+ puts "Not a file #{path}" if verbose
266
+ elsif !EXTENSIONS.include?(File.extname(path).delete('.'))
267
+ if verbose
268
+ puts "File extension not one of: #{EXTENSIONS.join(', ')}"
269
+ end
270
+ else
271
+ [path]
272
+ end || []
273
+
274
+ file_paths.each do |file_path|
275
+ y << file_path
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ opts = Adri.parse_args
283
+ args = opts.arguments
284
+
285
+ if args.empty?
286
+ puts opts
287
+ exit
288
+ end
289
+
290
+ options = opts.to_h
291
+
292
+ if options[:path_format]['%{location}'] && options[:api_key].to_s.strip.empty?
293
+ puts 'Please specify a Google Maps API key or remove %{location} from path ' \
294
+ 'format'
295
+ exit 1
296
+ end
297
+
298
+ Geocoder.configure(
299
+ always_raise: :all,
300
+ lookup: :google,
301
+ api_key: options[:api_key]
302
+ )
303
+
304
+ Adri.expand_paths(args, !options[:quiet]).each do |path|
305
+ Adri::Photo.new(path, options).move
306
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: adri
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Angelos Orfanakos
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-12-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: exif
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.2'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.2.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '2.2'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.2.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: geocoder
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 1.5.0
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '1.5'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 1.5.0
53
+ - !ruby/object:Gem::Dependency
54
+ name: slop
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '4.6'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 4.6.2
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '4.6'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 4.6.2
73
+ - !ruby/object:Gem::Dependency
74
+ name: dotenv
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.5'
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 2.5.0
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.5'
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 2.5.0
93
+ description:
94
+ email: me@agorf.gr
95
+ executables:
96
+ - adri
97
+ extensions: []
98
+ extra_rdoc_files: []
99
+ files:
100
+ - LICENSE.txt
101
+ - README.md
102
+ - bin/adri
103
+ homepage: https://github.com/agorf/adri
104
+ licenses:
105
+ - MIT
106
+ metadata: {}
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 2.3.0
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubyforge_project:
123
+ rubygems_version: 2.7.6
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: Organize photos by date and location in a directory structure
127
+ test_files: []