adri 0.0.1

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.
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: []