imagesorter 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +31 -0
- data/.rspec +2 -0
- data/.rubocop.yml +14 -0
- data/.rubocop_todo.yml +33 -0
- data/.travis.yml +11 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +8 -0
- data/appveyor.yml +18 -0
- data/exe/imagesorter +28 -0
- data/imagesorter.gemspec +34 -0
- data/lib/imagesorter.rb +17 -0
- data/lib/imagesorter/categorizers/chained_categorizer.rb +24 -0
- data/lib/imagesorter/categorizers/file_exif_categorizer.rb +21 -0
- data/lib/imagesorter/categorizers/file_stat_categorizer.rb +17 -0
- data/lib/imagesorter/cmd.rb +106 -0
- data/lib/imagesorter/file_batch_processor.rb +130 -0
- data/lib/imagesorter/file_system_processor.rb +82 -0
- data/lib/imagesorter/gem.rb +24 -0
- data/lib/imagesorter/option_parser.rb +103 -0
- data/lib/imagesorter/sortable_file.rb +14 -0
- data/version +1 -0
- metadata +195 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f5ce17c344a92e399f3654dfef66291a1ab44fe5
|
4
|
+
data.tar.gz: aefc58447eb3ae56149cd7a0190889446680dcd0
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5aab53b23f7146ac247d76ca4dd5a8095655363c1277d968522db98677d0a148a696d090c18573cb678c54e5a996549c00009b78bb50ee5d1415b92f933f0d20
|
7
|
+
data.tar.gz: cdd1a773adc3a435e68455818f5b59241979b4335d4ccc14a64818fe6e6fb82ee2879b49624ccae648541b08216bca371d844c97589de2e182bf50e47c79f8e6
|
data/.gitignore
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
/.config
|
4
|
+
/coverage/
|
5
|
+
/InstalledFiles
|
6
|
+
/pkg/
|
7
|
+
/spec/reports/
|
8
|
+
/spec/examples.txt
|
9
|
+
/test/tmp/
|
10
|
+
/test/version_tmp/
|
11
|
+
/tmp/
|
12
|
+
|
13
|
+
## Documentation cache and generated files:
|
14
|
+
/.yardoc/
|
15
|
+
/_yardoc/
|
16
|
+
/doc/
|
17
|
+
/rdoc/
|
18
|
+
|
19
|
+
## Environment normalization:
|
20
|
+
/.bundle/
|
21
|
+
/vendor/bundle
|
22
|
+
/lib/bundler/man/
|
23
|
+
|
24
|
+
# for a library or gem, you might want to ignore these files since the code is
|
25
|
+
# intended to run in multiple environments; otherwise, check them in:
|
26
|
+
Gemfile.lock
|
27
|
+
.ruby-version
|
28
|
+
.ruby-gemset
|
29
|
+
|
30
|
+
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
|
31
|
+
.rvmrc
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.rubocop_todo.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# This configuration was generated by
|
2
|
+
# `rubocop --auto-gen-config`
|
3
|
+
# on 2017-08-09 21:58:47 +0300 using RuboCop version 0.49.1.
|
4
|
+
# The point is for the user to remove these configuration records
|
5
|
+
# one by one as the offenses are removed from the code base.
|
6
|
+
# Note that changes in the inspected code, or installation of new
|
7
|
+
# versions of RuboCop, may require this file to be generated again.
|
8
|
+
|
9
|
+
# Offense count: 2
|
10
|
+
Metrics/AbcSize:
|
11
|
+
Max: 38
|
12
|
+
|
13
|
+
# Offense count: 7
|
14
|
+
# Configuration parameters: CountComments.
|
15
|
+
Metrics/MethodLength:
|
16
|
+
Max: 54
|
17
|
+
|
18
|
+
# Offense count: 1
|
19
|
+
# Configuration parameters: CountKeywordArgs.
|
20
|
+
Metrics/ParameterLists:
|
21
|
+
Max: 7
|
22
|
+
|
23
|
+
# Offense count: 6
|
24
|
+
Style/Documentation:
|
25
|
+
Exclude:
|
26
|
+
- 'spec/**/*'
|
27
|
+
- 'test/**/*'
|
28
|
+
- 'lib/imagesorter/file_batch_processor.rb'
|
29
|
+
- 'lib/imagesorter/file_exif_categorizer.rb'
|
30
|
+
- 'lib/imagesorter/file_stat_categorizer.rb'
|
31
|
+
- 'lib/imagesorter/file_system_processor.rb'
|
32
|
+
- 'lib/imagesorter/gem.rb'
|
33
|
+
- 'lib/imagesorter/option_parser.rb'
|
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
# Changelog
|
2
|
+
All notable changes to this project will be documented in this file.
|
3
|
+
|
4
|
+
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
5
|
+
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
6
|
+
|
7
|
+
## [1.0.0] - 2017-08-12
|
8
|
+
### Added
|
9
|
+
- Initial release
|
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6
|
+
|
7
|
+
## Our Standards
|
8
|
+
|
9
|
+
Examples of behavior that contributes to creating a positive environment include:
|
10
|
+
|
11
|
+
* Using welcoming and inclusive language
|
12
|
+
* Being respectful of differing viewpoints and experiences
|
13
|
+
* Gracefully accepting constructive criticism
|
14
|
+
* Focusing on what is best for the community
|
15
|
+
* Showing empathy towards other community members
|
16
|
+
|
17
|
+
Examples of unacceptable behavior by participants include:
|
18
|
+
|
19
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
20
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
21
|
+
* Public or private harassment
|
22
|
+
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
23
|
+
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
24
|
+
|
25
|
+
## Our Responsibilities
|
26
|
+
|
27
|
+
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
28
|
+
|
29
|
+
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
30
|
+
|
31
|
+
## Scope
|
32
|
+
|
33
|
+
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
34
|
+
|
35
|
+
## Enforcement
|
36
|
+
|
37
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at antmanj@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
38
|
+
|
39
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
40
|
+
|
41
|
+
## Attribution
|
42
|
+
|
43
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
44
|
+
|
45
|
+
[homepage]: http://contributor-covenant.org
|
46
|
+
[version]: http://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2017 Joakim Antman
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, 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,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# imagesorter
|
2
|
+
|
3
|
+
[![Build Status](https://travis-ci.org/anakinj/imagesorter.svg?branch=master)](https://travis-ci.org/anakinj/imagesorter)
|
4
|
+
[![Code Climate](https://codeclimate.com/github/anakinj/imagesorter/badges/gpa.svg)](https://codeclimate.com/github/anakinj/imagesorter)
|
5
|
+
[![Gem Version](https://badge.fury.io/rb/imagesorter.svg)](https://badge.fury.io/rb/imagesorter)
|
6
|
+
|
7
|
+
A command line tool that sorts your photos and videos (actually any files you tell it to sort) based on date it was created or EXIF information from the picture. The tool can do it's magic in parallel.
|
8
|
+
|
9
|
+
The tool was inspired by [https://github.com/andrewning/sortphotos](https://github.com/andrewning/sortphotos), but I wanted the process to be done in multiple threads to minimize the wait time and my pearl skills are a little rusty.
|
10
|
+
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
```gem install imagesorter``` and you're good to go.
|
15
|
+
|
16
|
+
Currently the tool requires Ruby 2.1 or newer.
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
```imagesorter --help``` gives you all the details
|
21
|
+
|
22
|
+
### Destination format
|
23
|
+
|
24
|
+
The destination format is used to configure the template where into the destination the files will be copied, the template will be populated with metadata extracted from the source file.
|
25
|
+
|
26
|
+
The source file metadata is referred to with the ```%{key}``` notation. Also directives used for [formatting timestamps](https://ruby-doc.org/stdlib-2.1.1/libdoc/date/rdoc/Date.html#method-i-strftime) are available.
|
27
|
+
|
28
|
+
Keys that are not found are replaced with a empty string.
|
29
|
+
|
30
|
+
```imagesorter -s . -d /my/dest --destination-format "%{exif.make} %{exif.model}/%Y/%m/%d/%{full_name}```
|
31
|
+
|
32
|
+
#### File metadata
|
33
|
+
| Key | Description |
|
34
|
+
| --- | --- |
|
35
|
+
| name | Name of the source file |
|
36
|
+
| extension | Extension of the source file |
|
37
|
+
| full_name | Alias for name+extension |
|
38
|
+
| exif.* | Data extracted from the image EXIF data |
|
39
|
+
|
40
|
+
#### Duplicate handling
|
41
|
+
|
42
|
+
The tool checks for duplicates and identical files are ignored. On conflicting filenames the file to be copied gets an additional tag in the filename, no existing files will be touched.
|
data/Rakefile
ADDED
data/appveyor.yml
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
version: '{build}'
|
2
|
+
|
3
|
+
skip_tags: true
|
4
|
+
|
5
|
+
environment:
|
6
|
+
matrix:
|
7
|
+
- ruby_version: "21"
|
8
|
+
- ruby_version: "21-x64"
|
9
|
+
|
10
|
+
install:
|
11
|
+
- SET PATH=C:\Ruby%ruby_version%\bin;%PATH%
|
12
|
+
- gem install bundler --no-document -v 1.10.5
|
13
|
+
- bundle install --retry=3
|
14
|
+
|
15
|
+
test_script:
|
16
|
+
- bundle exec rake
|
17
|
+
|
18
|
+
build: off
|
data/exe/imagesorter
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#--
|
5
|
+
# Copyright (c) 2003, 2004, 2005, 2006, 2007 Jim Weirich
|
6
|
+
#
|
7
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
# of this software and associated documentation files (the "Software"), to
|
9
|
+
# deal in the Software without restriction, including without limitation the
|
10
|
+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
11
|
+
# sell copies of the Software, and to permit persons to whom the Software is
|
12
|
+
# furnished to do so, subject to the following conditions:
|
13
|
+
#
|
14
|
+
# The above copyright notice and this permission notice shall be included in
|
15
|
+
# all copies or substantial portions of the Software.
|
16
|
+
#
|
17
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
22
|
+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
23
|
+
# IN THE SOFTWARE.
|
24
|
+
#++
|
25
|
+
|
26
|
+
require 'imagesorter/cmd'
|
27
|
+
|
28
|
+
Imagesorter::Cmd.new.run
|
data/imagesorter.gemspec
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'imagesorter'
|
7
|
+
s.version = File.read(File.expand_path('../version', __FILE__)).strip
|
8
|
+
s.description = 'Command line tool for sorting photos and videos'
|
9
|
+
s.summary = 'Command line tool for sorting photos and videos'
|
10
|
+
s.authors = ['Joakim Antman']
|
11
|
+
s.email = 'antmanj@gmail.com'
|
12
|
+
|
13
|
+
s.homepage = 'https://github.com/anakinj/imagesorter'
|
14
|
+
s.license = 'MIT'
|
15
|
+
s.require_paths = ['lib']
|
16
|
+
s.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
s.bindir = 'exe'
|
20
|
+
s.executables = s.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
|
+
|
22
|
+
s.required_ruby_version = '>= 2.1'
|
23
|
+
|
24
|
+
s.add_dependency 'exifr'
|
25
|
+
s.add_dependency 'progressbar'
|
26
|
+
s.add_dependency 'r18n-core'
|
27
|
+
|
28
|
+
s.add_development_dependency 'rubocop'
|
29
|
+
s.add_development_dependency 'rspec'
|
30
|
+
s.add_development_dependency 'simplecov'
|
31
|
+
s.add_development_dependency 'bundler'
|
32
|
+
s.add_development_dependency 'rake'
|
33
|
+
s.add_development_dependency 'pry'
|
34
|
+
end
|
data/lib/imagesorter.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'ostruct'
|
6
|
+
require 'r18n-core'
|
7
|
+
require 'exifr/jpeg'
|
8
|
+
|
9
|
+
R18n.set('en')
|
10
|
+
|
11
|
+
require 'imagesorter/gem'
|
12
|
+
require 'imagesorter/sortable_file'
|
13
|
+
require 'imagesorter/categorizers/chained_categorizer'
|
14
|
+
require 'imagesorter/categorizers/file_exif_categorizer'
|
15
|
+
require 'imagesorter/categorizers/file_stat_categorizer'
|
16
|
+
require 'imagesorter/file_system_processor'
|
17
|
+
require 'imagesorter/file_batch_processor'
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
module Categorizers
|
5
|
+
# Chains categorizers together, will try in order until gets a successfull result
|
6
|
+
class ChainedCategorizer
|
7
|
+
def initialize(*categorizers)
|
8
|
+
@categorizers = categorizers
|
9
|
+
end
|
10
|
+
|
11
|
+
def process(file)
|
12
|
+
result = nil
|
13
|
+
@categorizers.each do |categorizer|
|
14
|
+
result = categorizer.process(file)
|
15
|
+
return result unless result.nil?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def step_name
|
20
|
+
'Categorizing'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
module Categorizers
|
5
|
+
class FileExifCategorizer
|
6
|
+
def process(file)
|
7
|
+
exif = EXIFR::JPEG.new(file.file)
|
8
|
+
|
9
|
+
exif.to_hash.each do |key, value|
|
10
|
+
file["exif.#{key}"] = value
|
11
|
+
end
|
12
|
+
|
13
|
+
time = exif.date_time
|
14
|
+
return nil if time.nil?
|
15
|
+
file.time = time
|
16
|
+
rescue
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
module Categorizers
|
5
|
+
class FileStatCategorizer
|
6
|
+
def initialize(stat)
|
7
|
+
@stat = stat
|
8
|
+
end
|
9
|
+
|
10
|
+
def process(file)
|
11
|
+
time = file.file.send(@stat)
|
12
|
+
return nil if time.nil?
|
13
|
+
file.time = time
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'imagesorter/option_parser'
|
4
|
+
require 'imagesorter'
|
5
|
+
require 'progressbar'
|
6
|
+
|
7
|
+
module Imagesorter
|
8
|
+
class Cmd
|
9
|
+
attr_reader :progressbar
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@options = Imagesorter::OptionParser.parse(ARGV)
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
print_options
|
17
|
+
|
18
|
+
setup_logger
|
19
|
+
setup_locale
|
20
|
+
|
21
|
+
Imagesorter::FileBatchProcessor.new(batch_options).execute!
|
22
|
+
|
23
|
+
finalize
|
24
|
+
|
25
|
+
puts 'DONE'
|
26
|
+
exit 0
|
27
|
+
rescue Interrupt
|
28
|
+
puts 'FAIL: INTERRUPTED'
|
29
|
+
exit 1
|
30
|
+
rescue => e
|
31
|
+
puts e
|
32
|
+
exit 2
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def setup_logger
|
38
|
+
|
39
|
+
if @options.logfile
|
40
|
+
Imagesorter.logger = Logger.new(@options.logfile)
|
41
|
+
end
|
42
|
+
|
43
|
+
if @options.verbose == true
|
44
|
+
Imagesorter.logger.level = Logger::DEBUG
|
45
|
+
elsif @options.silent == true && !@options.logfile
|
46
|
+
Imagesorter.logger.level = Logger::FATAL
|
47
|
+
else
|
48
|
+
Imagesorter.logger.level = Logger::INFO
|
49
|
+
end
|
50
|
+
|
51
|
+
Imagesorter.logger.formatter = proc do |severity, datetime, _progname, msg|
|
52
|
+
date_format = datetime.strftime('%Y-%m-%d %H:%M:%S')
|
53
|
+
if @options.test
|
54
|
+
"[#{date_format}] [#{severity}] TEST - #{msg}\n"
|
55
|
+
else
|
56
|
+
"[#{date_format}] [#{severity}] #{msg}\n"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def setup_locale
|
62
|
+
R18n.locale(@options.locale)
|
63
|
+
R18n.set(@options.locale)
|
64
|
+
end
|
65
|
+
|
66
|
+
def batch_options
|
67
|
+
options = {
|
68
|
+
source: @options.source,
|
69
|
+
recursive: @options.recursive,
|
70
|
+
extensions: @options.extensions,
|
71
|
+
processor: Imagesorter::FileSystemProcessor.new(destination: @options.dest,
|
72
|
+
destination_fmt: @options.destination_format,
|
73
|
+
copy_mode: @options.copy_mode,
|
74
|
+
test: @options.test)
|
75
|
+
}
|
76
|
+
|
77
|
+
if progressbar_enabled?
|
78
|
+
options[:progress_proc] = proc { |progress_options| progress(progress_options) }
|
79
|
+
end
|
80
|
+
|
81
|
+
options
|
82
|
+
end
|
83
|
+
|
84
|
+
def print_options
|
85
|
+
puts "Source: #{@options.source}"
|
86
|
+
puts "Destination: #{@options.dest}"
|
87
|
+
puts "Threads: #{@options.threads}"
|
88
|
+
end
|
89
|
+
|
90
|
+
def finalize
|
91
|
+
@progressbar.finish if @progressbar
|
92
|
+
end
|
93
|
+
|
94
|
+
def progressbar_enabled?
|
95
|
+
@options.silent == false && !@options.logfile.nil?
|
96
|
+
end
|
97
|
+
|
98
|
+
def progress(progress_options)
|
99
|
+
@progressbar ||= ProgressBar.create(format: '%a %e %P% %t %c of %C',
|
100
|
+
autofinish: false)
|
101
|
+
@progressbar.title = progress_options[:step]
|
102
|
+
@progressbar.total = progress_options[:total_steps]
|
103
|
+
@progressbar.increment
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
class FileBatchProcessor
|
5
|
+
attr_reader :files
|
6
|
+
|
7
|
+
def initialize(source:,
|
8
|
+
processor:,
|
9
|
+
categorizer: nil,
|
10
|
+
progress_proc: nil,
|
11
|
+
threads: 1,
|
12
|
+
extensions: nil,
|
13
|
+
recursive: false)
|
14
|
+
@dir = source
|
15
|
+
@categorizer = categorizer || Categorizers::ChainedCategorizer.new(Categorizers::FileExifCategorizer.new,
|
16
|
+
Categorizers::FileStatCategorizer.new(:ctime))
|
17
|
+
@processor = processor
|
18
|
+
@progress_proc = progress_proc
|
19
|
+
@queue = Queue.new
|
20
|
+
@files = []
|
21
|
+
@threads = threads
|
22
|
+
@recursive = recursive
|
23
|
+
@extensions = Array(extensions).map(&:upcase)
|
24
|
+
@total_steps = nil
|
25
|
+
|
26
|
+
@skipped_files = 0
|
27
|
+
end
|
28
|
+
|
29
|
+
def execute!
|
30
|
+
collect!
|
31
|
+
|
32
|
+
@total_steps = @skipped_files + @files.size * 3
|
33
|
+
|
34
|
+
process!
|
35
|
+
|
36
|
+
if @threads > 1
|
37
|
+
start_queue_workers
|
38
|
+
else
|
39
|
+
work_on_queue
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def collect!
|
44
|
+
@files = []
|
45
|
+
|
46
|
+
collect_files_from_dir(@dir)
|
47
|
+
end
|
48
|
+
|
49
|
+
def collect_files_from_dir(dir)
|
50
|
+
Dir.foreach(dir) do |file|
|
51
|
+
collect_file_from_dir(dir, file)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def collect_file_from_dir(dir, file)
|
56
|
+
return if file =~ /^\.\.?$/
|
57
|
+
full_path = File.join(dir, file)
|
58
|
+
|
59
|
+
if File.directory?(full_path)
|
60
|
+
collect_files_from_dir(full_path) if @recursive
|
61
|
+
return
|
62
|
+
end
|
63
|
+
|
64
|
+
if include_file?(full_path)
|
65
|
+
@files << SortableFile.new(full_path)
|
66
|
+
else
|
67
|
+
@skipped_files += 1
|
68
|
+
end
|
69
|
+
|
70
|
+
increment('Collecting files')
|
71
|
+
end
|
72
|
+
|
73
|
+
def include_file?(file)
|
74
|
+
File.file?(file) &&
|
75
|
+
(@extensions.empty? || @extensions.include?(File.extname(file).delete('.').upcase))
|
76
|
+
end
|
77
|
+
|
78
|
+
def process!
|
79
|
+
@files.each do |file|
|
80
|
+
queue_categorizing(file)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
require 'json'
|
84
|
+
def queue_categorizing(file)
|
85
|
+
queue_job do
|
86
|
+
file.process!(@categorizer)
|
87
|
+
|
88
|
+
Imagesorter.logger.debug "#{file.file.path} metadata: #{JSON.pretty_generate(file.to_h)}"
|
89
|
+
|
90
|
+
increment(@categorizer.step_name)
|
91
|
+
queue_proceesing(file)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def queue_proceesing(file)
|
96
|
+
return if @processor.nil?
|
97
|
+
queue_job do
|
98
|
+
file.process!(@processor)
|
99
|
+
increment(@processor.step_name)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def increment(step)
|
104
|
+
return if @progress_proc.nil?
|
105
|
+
@progress_proc.call(step: step.ljust(14, ' '),
|
106
|
+
total_steps: @total_steps)
|
107
|
+
end
|
108
|
+
|
109
|
+
def queue_job(&block)
|
110
|
+
@queue.push(block)
|
111
|
+
end
|
112
|
+
|
113
|
+
def work_on_queue
|
114
|
+
until @queue.empty?
|
115
|
+
job = @queue.shift
|
116
|
+
job.call
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def start_queue_workers
|
121
|
+
@threads = Array.new(@threads) do
|
122
|
+
Thread.new do
|
123
|
+
work_on_queue
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
@threads.each(&:join)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
class FileSystemProcessor
|
5
|
+
include R18n::Helpers
|
6
|
+
|
7
|
+
attr_reader :copy_mode
|
8
|
+
|
9
|
+
def initialize(destination:,
|
10
|
+
destination_fmt: '%Y/%m/%d/%<name>s.%<extension>s',
|
11
|
+
copy_mode: :copy,
|
12
|
+
test: false)
|
13
|
+
@destination = destination
|
14
|
+
@test = test
|
15
|
+
@copy_mode = copy_mode
|
16
|
+
@destination_fmt = destination_fmt
|
17
|
+
end
|
18
|
+
|
19
|
+
def step_name
|
20
|
+
@copy_mode == :move ? 'Moving' : 'Copying'
|
21
|
+
end
|
22
|
+
|
23
|
+
def process(file)
|
24
|
+
source = file.file.path
|
25
|
+
|
26
|
+
destination_params = {
|
27
|
+
full_name: File.basename(source),
|
28
|
+
name: File.basename(source, '.*'),
|
29
|
+
extension: File.extname(source).delete('.')
|
30
|
+
}
|
31
|
+
|
32
|
+
dest = begin
|
33
|
+
File.join(@destination, format(l(file.time, @destination_fmt), file.to_h.merge(destination_params)))
|
34
|
+
rescue KeyError => e
|
35
|
+
Imagesorter.logger.warn e.message
|
36
|
+
|
37
|
+
key = /^key<(.*)> not found$/.match(e.message)[1]
|
38
|
+
|
39
|
+
unless key.nil?
|
40
|
+
destination_params[key.to_sym] = ''
|
41
|
+
retry
|
42
|
+
else
|
43
|
+
raise
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
return if dest.nil?
|
48
|
+
|
49
|
+
dest = handle_duplicate(source, dest)
|
50
|
+
|
51
|
+
return if dest.nil?
|
52
|
+
|
53
|
+
dest_dir = File.dirname(dest)
|
54
|
+
|
55
|
+
Imagesorter.logger.info "#{step_name} #{source} to #{dest}"
|
56
|
+
|
57
|
+
return if @test
|
58
|
+
|
59
|
+
FileUtils.mkdir_p(dest_dir) unless File.directory?(dest_dir)
|
60
|
+
|
61
|
+
if @copy_mode == :move
|
62
|
+
FileUtils.mv(source, dest)
|
63
|
+
else
|
64
|
+
FileUtils.cp(source, dest, preserve: true)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def handle_duplicate(source, dest)
|
69
|
+
return dest unless File.exist?(dest)
|
70
|
+
|
71
|
+
return nil if FileUtils.identical?(source, dest) # skip if identical
|
72
|
+
|
73
|
+
basename = File.basename(dest, '.*')
|
74
|
+
extname = File.extname(dest)
|
75
|
+
dirname = File.dirname(dest)
|
76
|
+
|
77
|
+
sequence = 1 # TODO, Figure out current sequence number
|
78
|
+
|
79
|
+
handle_duplicate(source, File.join(dirname, "#{basename}_#{sequence}#{extname}"))
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Imagesorter
|
6
|
+
def self.logger
|
7
|
+
@logger ||= Logger.new(STDOUT)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.logger=(logger)
|
11
|
+
@logger = logger
|
12
|
+
end
|
13
|
+
|
14
|
+
# Gem related helpers
|
15
|
+
module Gem
|
16
|
+
def self.root
|
17
|
+
@root ||= File.expand_path('../../..', __FILE__)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.version
|
21
|
+
@version ||= File.read(File.join(root, 'version')).strip
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
module OptionParser
|
5
|
+
DEFAULT_OPTIONS = {
|
6
|
+
silent: false,
|
7
|
+
test: false,
|
8
|
+
recursive: false,
|
9
|
+
copy_mode: :copy,
|
10
|
+
threads: 1,
|
11
|
+
locale: 'en-us',
|
12
|
+
extensions: %w[JPG JPEG MP4 MOV],
|
13
|
+
destination_format: '%Y/%m/%d/%{full_name}' # rubocop:disable Style/FormatStringToken
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def self.parse(argv)
|
17
|
+
options = OpenStruct.new(DEFAULT_OPTIONS)
|
18
|
+
|
19
|
+
# rubocop:disable Metrics/BlockLength
|
20
|
+
parser = ::OptionParser.new do |opts|
|
21
|
+
opts.banner = 'Usage: imagesorter [options]'
|
22
|
+
|
23
|
+
opts.on('-s', '--source SOURCE_DIR', 'Source directory') do |source|
|
24
|
+
options.source = source
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on('-d', '--dest DESTINATION_DIR', 'Destination directory (will be created if it does not exist)') do |dest|
|
28
|
+
options.dest = dest
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on('-t', '--threads THREADS', "Number of threads to run the processing in (default is #{options.threads})") do |threads|
|
32
|
+
options.threads = threads
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on('-m', '--move', 'Move rather than copy') do
|
36
|
+
options.copy_mode = :move
|
37
|
+
end
|
38
|
+
|
39
|
+
opts.on('-e', '--extensions EXTENSIONS', Array, "What extensions to include (default is #{options.extensions.join(',')})") do |extensions|
|
40
|
+
options.extensions = extensions
|
41
|
+
end
|
42
|
+
|
43
|
+
opts.on('-r', '--recursive', "Iterate source recursively (default is #{options.recursive})") do |recursive|
|
44
|
+
options.recursive = recursive
|
45
|
+
end
|
46
|
+
|
47
|
+
opts.on('-l', '--logfile LOGFILE', 'Write message to given logfile. Default output is STDOUT') do |logfile|
|
48
|
+
options.logfile = logfile
|
49
|
+
end
|
50
|
+
|
51
|
+
opts.on('--locale LOCALE', "Locale to use (default is #{options.locale})") do |locale|
|
52
|
+
options.locale = locale
|
53
|
+
end
|
54
|
+
|
55
|
+
opts.on('--destination-format DESTINATION_FORMAT', "Destination format (default is #{options.destination_format})") do |destination_format|
|
56
|
+
options.destination_format = destination_format
|
57
|
+
end
|
58
|
+
|
59
|
+
opts.on('--[no-]silent', 'Run silently') do |silent|
|
60
|
+
options.silent = silent
|
61
|
+
end
|
62
|
+
|
63
|
+
opts.on('--[no-]test', 'Do a test-run without touching any files') do |test|
|
64
|
+
options.test = test
|
65
|
+
end
|
66
|
+
|
67
|
+
opts.separator ''
|
68
|
+
opts.separator 'Common options:'
|
69
|
+
opts.on_tail('-h', '--help', 'Show this message') do
|
70
|
+
puts opts
|
71
|
+
exit
|
72
|
+
end
|
73
|
+
|
74
|
+
opts.on('--verbose', 'Increase log message verbosity') do |verbose|
|
75
|
+
options.verbose = verbose
|
76
|
+
end
|
77
|
+
|
78
|
+
# Another typical switch to print the version.
|
79
|
+
opts.on_tail('-v', '--version', 'Show version') do
|
80
|
+
puts Imagesorter::Gem.version
|
81
|
+
exit
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
parser.parse!(argv)
|
86
|
+
|
87
|
+
validate_mandatory_options!(options)
|
88
|
+
|
89
|
+
options
|
90
|
+
rescue ::OptionParser::InvalidOption, ::OptionParser::MissingArgument => e
|
91
|
+
puts e.to_s
|
92
|
+
puts parser
|
93
|
+
exit
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.validate_mandatory_options!(options)
|
97
|
+
mandatory = %i[source dest]
|
98
|
+
missing = mandatory.select { |param| options[param].nil? }
|
99
|
+
return if missing.empty?
|
100
|
+
raise ::OptionParser::MissingArgument, missing.join(', ')
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Imagesorter
|
4
|
+
# Base for every sortable entity
|
5
|
+
class SortableFile < OpenStruct
|
6
|
+
def initialize(path)
|
7
|
+
super(file: File.new(path))
|
8
|
+
end
|
9
|
+
|
10
|
+
def process!(processor)
|
11
|
+
processor.process(self)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
metadata
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: imagesorter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Joakim Antman
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-08-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: exifr
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: progressbar
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: r18n-core
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: simplecov
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: bundler
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rake
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: pry
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
description: Command line tool for sorting photos and videos
|
140
|
+
email: antmanj@gmail.com
|
141
|
+
executables:
|
142
|
+
- imagesorter
|
143
|
+
extensions: []
|
144
|
+
extra_rdoc_files: []
|
145
|
+
files:
|
146
|
+
- ".gitignore"
|
147
|
+
- ".rspec"
|
148
|
+
- ".rubocop.yml"
|
149
|
+
- ".rubocop_todo.yml"
|
150
|
+
- ".travis.yml"
|
151
|
+
- CHANGELOG.md
|
152
|
+
- CODE_OF_CONDUCT.md
|
153
|
+
- Gemfile
|
154
|
+
- LICENSE
|
155
|
+
- README.md
|
156
|
+
- Rakefile
|
157
|
+
- appveyor.yml
|
158
|
+
- exe/imagesorter
|
159
|
+
- imagesorter.gemspec
|
160
|
+
- lib/imagesorter.rb
|
161
|
+
- lib/imagesorter/categorizers/chained_categorizer.rb
|
162
|
+
- lib/imagesorter/categorizers/file_exif_categorizer.rb
|
163
|
+
- lib/imagesorter/categorizers/file_stat_categorizer.rb
|
164
|
+
- lib/imagesorter/cmd.rb
|
165
|
+
- lib/imagesorter/file_batch_processor.rb
|
166
|
+
- lib/imagesorter/file_system_processor.rb
|
167
|
+
- lib/imagesorter/gem.rb
|
168
|
+
- lib/imagesorter/option_parser.rb
|
169
|
+
- lib/imagesorter/sortable_file.rb
|
170
|
+
- version
|
171
|
+
homepage: https://github.com/anakinj/imagesorter
|
172
|
+
licenses:
|
173
|
+
- MIT
|
174
|
+
metadata: {}
|
175
|
+
post_install_message:
|
176
|
+
rdoc_options: []
|
177
|
+
require_paths:
|
178
|
+
- lib
|
179
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
180
|
+
requirements:
|
181
|
+
- - ">="
|
182
|
+
- !ruby/object:Gem::Version
|
183
|
+
version: '2.1'
|
184
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
185
|
+
requirements:
|
186
|
+
- - ">="
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
version: '0'
|
189
|
+
requirements: []
|
190
|
+
rubyforge_project:
|
191
|
+
rubygems_version: 2.4.6
|
192
|
+
signing_key:
|
193
|
+
specification_version: 4
|
194
|
+
summary: Command line tool for sorting photos and videos
|
195
|
+
test_files: []
|