imp3 0.1.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.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,23 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+
21
+ ## PROJECT::SPECIFIC
22
+ .idea
23
+ *.gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Víctor Martínez
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,129 @@
1
+ == DESCRIPTION
2
+
3
+ iMp3 is an application for batch processing and fixing common issues when dealing with a large iTunes library
4
+
5
+ == REQUIREMENTS
6
+
7
+ * Mac OS X 10.6
8
+ * iTunes 9
9
+
10
+ == INSTALL
11
+
12
+ sudo gem install imp3
13
+
14
+ == SYNOPSIS
15
+
16
+ Application for batch processing and fixing common issues when dealing with a large iTunes library
17
+
18
+ Commands:
19
+ fix genres Tags all tracks genres using last.fm
20
+ fix misspelled-artists Fixes misspelled artist names
21
+ help Display global or [command] help documentation.
22
+ stats genres Lists all genres tagged in iTunes
23
+
24
+ Global Options:
25
+ -h, --help Display help documentation
26
+ -v, --version Display version information
27
+ -t, --trace Display backtrace when an error occurs
28
+
29
+ == FEATURES
30
+
31
+ $ imp3 genres fetch
32
+ 53% |=============............| Tagging track 911DA9F96A9D7003 with genre 'sludge'
33
+
34
+ $ imp3 artists misspelled
35
+ Misspelled artist name scan complete.
36
+
37
+ What is the correct artist name for L'Antietam
38
+ 1. L'Antietam
39
+ 2. L'antietam
40
+ 3. (Skip)
41
+ ? 1
42
+ Tagging track 851744AFF27C75D1 with artist name 'L'Antietam'
43
+
44
+ 177 artists.
45
+ 1479 tracks.
46
+ 1 tracks tagged.
47
+ 0 requests.
48
+ 0 errors.
49
+
50
+ $ imp3 genres ignore-add singer-songwriter
51
+ Genre 'singer-songwriter' added to ignore list
52
+
53
+ $ imp3 genres ignore-list
54
+ +-------------------+
55
+ | Genre |
56
+ +-------------------+
57
+ | singer-songwriter |
58
+ | polish |
59
+ | swedish |
60
+ +-------------------+
61
+
62
+ $ imp3 genres list
63
+ +-------------------+--------+
64
+ | Genre | Tracks |
65
+ +-------------------+--------+
66
+ | screamo | 398 |
67
+ | post-rock | 252 |
68
+ | hardcore | 116 |
69
+ | post-hardcore | 81 |
70
+ | sludge | 72 |
71
+ | indie | 67 |
72
+ | rock | 58 |
73
+ | math-rock | 44 |
74
+ | thrashcore | 38 |
75
+ | emo | 36 |
76
+ | electronic | 33 |
77
+ | crust | 32 |
78
+ | mathcore | 29 |
79
+ | post-metal | 26 |
80
+ | metalcore | 23 |
81
+ | punk | 21 |
82
+ | grindcore | 21 |
83
+ | crustcore | 15 |
84
+ | ska | 11 |
85
+ | indie-rock | 11 |
86
+ | deathcore | 10 |
87
+ | black-metal | 9 |
88
+ | experimental | 8 |
89
+ | ambient | 7 |
90
+ | emo-violence | 6 |
91
+ | death-metal | 5 |
92
+ | pop-punk | 5 |
93
+ | swedish | 3 |
94
+ | psychedelic | 3 |
95
+ | pop | 2 |
96
+ | polish | 2 |
97
+ | punk-rock | 1 |
98
+ | acoustic | 1 |
99
+ | post-punk | 1 |
100
+ +-------------------+--------+
101
+
102
+ == TODO
103
+
104
+ * Issue solver: Misspelled album names
105
+ * Issue solver: Lower-cased track names
106
+ * Issue solver: Same artist, different genre
107
+ * Issue solver: Fetch missing album art using images.google.com
108
+ * Issue solver: Duplicate tracks
109
+ * Issue solver: Remove sort artist (I personally hate this feature)
110
+ * Switch to only process current iTunes selection
111
+ * Switch to skip/force already processed tracks
112
+ * Cache last.fm requests
113
+ * Windows support through Win32OLE
114
+ * (your feature request here)
115
+ * ...
116
+
117
+ == Note on Patches/Pull Requests
118
+
119
+ * Fork the project.
120
+ * Make your feature addition or bug fix.
121
+ * Add tests for it. This is important so I don't break it in a
122
+ future version unintentionally.
123
+ * Commit, do not mess with rakefile, version, or history.
124
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
125
+ * Send me a pull request. Bonus points for topic branches.
126
+
127
+ == LICENSE
128
+
129
+ Copyright (c) 2010 Víctor Martínez. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,55 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'lib/imp3'
4
+
5
+ begin
6
+ require 'jeweler'
7
+ Jeweler::Tasks.new do |gem|
8
+ gem.name = "imp3"
9
+ gem.summary = %Q{Application for batch processing and fixing common issues when dealing with a large iTunes library}
10
+ gem.description = %Q{An application for batch processing and fixing common issues when dealing with a large iTunes library}
11
+ gem.email = "knoopx@gmail.com"
12
+ gem.homepage = "http://github.com/knoopx/imp3"
13
+ gem.authors = ["Víctor Martínez"]
14
+ gem.add_dependency('commander', '~> 4.0.2')
15
+ gem.add_dependency('terminal-table', '~> 1.4.2')
16
+ gem.add_dependency('nokogiri', '~> 1.4.1')
17
+ gem.add_dependency('pbosetti-rubyosa', '~> 0.5.3')
18
+ gem.add_dependency('friendly_id', '~> 2.3.3')
19
+ end
20
+ Jeweler::GemcutterTasks.new
21
+ rescue LoadError
22
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
23
+ end
24
+
25
+ require 'rake/testtask'
26
+ Rake::TestTask.new(:test) do |test|
27
+ test.libs << 'lib' << 'test'
28
+ test.pattern = 'test/**/test_*.rb'
29
+ test.verbose = false
30
+ end
31
+
32
+ begin
33
+ require 'rcov/rcovtask'
34
+ Rcov::RcovTask.new do |test|
35
+ test.libs << 'test'
36
+ test.pattern = 'test/**/test_*.rb'
37
+ test.verbose = true
38
+ end
39
+ rescue LoadError
40
+ task :rcov do
41
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
42
+ end
43
+ end
44
+
45
+ task :test => :check_dependencies
46
+
47
+ task :default => :test
48
+
49
+ require 'rake/rdoctask'
50
+ Rake::RDocTask.new do |rdoc|
51
+ rdoc.rdoc_dir = 'rdoc'
52
+ rdoc.title = "imp3 #{IMP3::VERSION}"
53
+ rdoc.rdoc_files.include('README.rdoc')
54
+ rdoc.rdoc_files.include('lib/**/*.rb')
55
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.1
data/bin/imp3 ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
4
+ $LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
5
+
6
+ require 'rubygems'
7
+ require "lib/imp3"
8
+ require 'commander/import'
9
+
10
+ program :name, 'imp3'
11
+ program :version, IMP3::VERSION
12
+ program :description, 'Application for batch processing and fixing common issues when dealing with a large iTunes library'
13
+ program :help_formatter, :compact
14
+
15
+ default_command :help
16
+
17
+ command "genres list" do |c|
18
+ c.syntax = 'genres list'
19
+ c.description = 'Lists all genres tagged in iTunes'
20
+ c.when_called do |args, options|
21
+ IMP3::CLI.new.genres_list
22
+ end
23
+ end
24
+
25
+ command "genres fetch" do |c|
26
+ c.syntax = 'genres fetch'
27
+ c.description = 'Fetch and tag track genres from last.fm top tag'
28
+ c.when_called do |args, options|
29
+ IMP3::CLI.new.genres_tag
30
+ end
31
+ end
32
+
33
+ command "genres ignore-add" do |c|
34
+ c.syntax = 'genres ignore-add GENRE'
35
+ c.description = 'Ignore specified genre so is no longer used and the next one is taken for tagging tracks'
36
+ c.when_called do |args, options|
37
+ IMP3::CLI.new.genres_ignore_add(args.first)
38
+ end
39
+ end
40
+
41
+ command "genres ignore-del" do |c|
42
+ c.syntax = 'genres ignore-del GENRE'
43
+ c.description = 'Ignore specified genre so is no longer used and the next one is taken for tagging tracks'
44
+ c.when_called do |args, options|
45
+ IMP3::CLI.new.genres_ignore_del(args.first)
46
+ end
47
+ end
48
+
49
+ command "genres ignore-list" do |c|
50
+ c.syntax = 'genres ignore-list'
51
+ c.description = 'Lists all ignored genres'
52
+ c.when_called do |args, options|
53
+ IMP3::CLI.new.genres_ignore_list
54
+ end
55
+ end
56
+
57
+ command "artists misspelled" do |c|
58
+ c.syntax = 'artists misspelled'
59
+ c.description = 'Fix misspelled artist names'
60
+ c.when_called do |args, options|
61
+ IMP3::CLI.new.artists_misspelled
62
+ end
63
+ end
data/lib/imp3/cli.rb ADDED
@@ -0,0 +1,208 @@
1
+ require 'cgi'
2
+ require 'open-uri'
3
+ require 'nokogiri'
4
+ require 'terminal-table/import'
5
+ require 'active_support'
6
+ require 'friendly_id/slug_string'
7
+ require 'rbosa'
8
+ require 'pp'
9
+ require 'commander/user_interaction'
10
+
11
+ OSA.utf8_strings = true
12
+
13
+ class IMP3::CLI
14
+ CONFIG_FILE = File.expand_path(File.join("~", ".imp3"))
15
+
16
+ def genres_tag
17
+ artist_genres = {}
18
+ tagged, requests, errors = 0, 0, 0
19
+
20
+ progress_bar tracks do |track, bar|
21
+ normalized_artist_name = track.artist.normalize
22
+
23
+ begin
24
+ unless artist_genres.has_key?(normalized_artist_name)
25
+ requests += 1
26
+ bar.refresh(:title => "Quering last.fm for '#{track.artist}'")
27
+ tags = Nokogiri(open("http://ws.audioscrobbler.com/1.0/artist/#{URI.escape(CGI.escape(track.artist))}/toptags.xml")).search("//toptags/tag/name").map{|t| t.text.normalize }.uniq
28
+ tags = tags - config[:ignore_genres] if config[:ignore_genres]
29
+ artist_genres[normalized_artist_name] = tags.first
30
+ end
31
+ rescue => e
32
+ errors += 1
33
+ bar.refresh(:title => e)
34
+ artist_genres[normalized_artist_name] = nil
35
+ end
36
+
37
+ if (artist_genres.has_key?(normalized_artist_name) and not artist_genres[normalized_artist_name].nil?)
38
+ tagged += 1
39
+ bar.increment(:title => "Tagging track #{track.persistent_id} with genre '#{artist_genres[normalized_artist_name]}'")
40
+ track.genre = ""
41
+ track.genre = artist_genres[normalized_artist_name]
42
+ end
43
+
44
+ end
45
+ summary(:tracks_tagged => tagged, :requests => requests, :errors => errors)
46
+ end
47
+
48
+ def genres_list
49
+ genres = {}
50
+ tracks.each do |track|
51
+ genres[track.genre] ||= 0
52
+ genres[track.genre] += 1
53
+ end
54
+
55
+ genre_table = table do |t|
56
+ t.headings = 'Genre', 'Tracks'
57
+ genres.sort{|a, b| b[1] <=> a[1]}.each do |g, c|
58
+ t << [g, c]
59
+ end
60
+ end
61
+
62
+ puts genre_table
63
+ summary
64
+ end
65
+
66
+ def genres_ignore_add(genre)
67
+ raise "Please specify a genre" unless genre
68
+ genre.strip!
69
+ config[:ignore_genres] ||= []
70
+ config[:ignore_genres] << genre unless config[:ignore_genres].include?(genre)
71
+ save_config
72
+ puts "Genre '#{genre}' added to ignore list"
73
+ end
74
+
75
+ def genres_ignore_del(genre)
76
+ raise "Please specify a genre" unless genre
77
+ genre.strip!
78
+ config[:ignore_genres].delete(genre) if config[:ignore_genres].include?(genre)
79
+ save_config
80
+ puts "Genre '#{genre}' deleted from ignore list"
81
+ end
82
+
83
+ def genres_ignore_list
84
+ if config[:ignore_genres].any?
85
+ genre_table = table do |t|
86
+ t.headings = 'Genre'
87
+ config[:ignore_genres].each do |g|
88
+ t << [g]
89
+ end
90
+ end
91
+ puts genre_table
92
+ else
93
+ puts "No ignored genres."
94
+ end
95
+ end
96
+
97
+ def artists_misspelled
98
+ artist_choices = {}
99
+ tagged = 0
100
+
101
+ progress_bar artists, :complete_message => "Misspelled artist name scan complete." do |artist, bar|
102
+ bar.increment :title => "Scanning artist '#{artist}'"
103
+ normalized_artist_name = artist.normalized_without_words
104
+ artist_choices[normalized_artist_name] ||= []
105
+ artist_choices[normalized_artist_name] << artist.strip unless artist_choices[normalized_artist_name].include?(artist)
106
+ end
107
+
108
+ artist_names = {}
109
+ tracks.each do |track|
110
+ normalized_artist_name = track.artist.normalized_without_words
111
+ unless artist_names.has_key?(normalized_artist_name)
112
+ if artist_choices[normalized_artist_name] && artist_choices[normalized_artist_name].size > 1
113
+ puts
114
+ artist_name = choose("What is the correct artist name for #{track.artist}", *(artist_choices[normalized_artist_name] << :"(Skip)"))
115
+ artist_names[normalized_artist_name] = artist_name
116
+ else
117
+ artist_names[normalized_artist_name] = track.artist
118
+ end
119
+ end
120
+
121
+ unless artist_names[normalized_artist_name].eql?(:"(Skip)") or track.artist.eql?(artist_names[normalized_artist_name])
122
+ tagged += 1
123
+ puts "Tagging track #{track.persistent_id} with artist name '#{artist_names[normalized_artist_name]}'"
124
+ track.artist = ""
125
+ track.artist = artist_names[normalized_artist_name]
126
+ end
127
+ end
128
+
129
+ summary :tracks_tagged => tagged
130
+ end
131
+
132
+ protected
133
+
134
+ def itunes
135
+ @itunes ||= OSA.app('iTunes')
136
+ end
137
+
138
+ def library
139
+ @library ||= itunes.sources.find {|s| s.kind == OSA::ITunes::ESRC::LIBRARY }.playlists[0]
140
+ end
141
+
142
+ def tracks
143
+ @tracks ||= library.tracks
144
+ end
145
+
146
+ def artists
147
+ @artists ||= tracks.map{|t| t.artist}.uniq.sort
148
+ end
149
+
150
+ def config
151
+ return @config if @config
152
+ if File.exist?(CONFIG_FILE)
153
+ begin
154
+ @config = YAML.load_file(CONFIG_FILE)
155
+ rescue
156
+ raise "Unable to read config file #{CONFIG_FILE}"
157
+ end
158
+ else
159
+ @config = { :ignore_genres => [] }
160
+ end
161
+ end
162
+
163
+ def save_config
164
+ File.new(CONFIG_FILE, "w+").write(config.to_yaml)
165
+ end
166
+
167
+ def summary(opts = {})
168
+ puts
169
+ puts "#{opts[:artist] || tracks.map{|t| t.artist}.uniq.size} artists."
170
+ puts "#{opts[:tracks] || tracks.size} tracks."
171
+ puts "#{opts[:tracks_tagged] || 0} tracks tagged."
172
+ puts "#{opts[:requests] || 0} requests."
173
+ puts "#{opts[:errors] || 0} errors."
174
+ end
175
+ end
176
+
177
+ class String
178
+ STRIP_WORDS = %w(the a of in)
179
+
180
+ def strip_meaningless_words
181
+ string = self
182
+ STRIP_WORDS.each do |w|
183
+ string = string.gsub(/\b#{w}\b/i, '')
184
+ end
185
+ string.strip
186
+ end
187
+
188
+ def normalized_without_words
189
+ self.strip_meaningless_words.normalize
190
+ end
191
+
192
+ def normalize
193
+ FriendlyId::SlugString.new(self).normalize!.approximate_ascii!.to_s
194
+ end
195
+ end
196
+
197
+ def progress_bar arr, options = {:format => ":percent_complete% |:progress_bar| :title"}, &block
198
+ bar = ProgressBar.new arr.size, options
199
+ bar.show
200
+ arr.each { |v| yield(v, bar) }
201
+ end
202
+
203
+ class Commander::UI::ProgressBar
204
+ def refresh(tokens)
205
+ @tokens.merge! tokens if tokens.is_a? Hash
206
+ show
207
+ end
208
+ end
data/lib/imp3.rb ADDED
@@ -0,0 +1,5 @@
1
+ module IMP3
2
+ VERSION = "0.1.1"
3
+ end
4
+
5
+ require 'lib/imp3/cli'
data/test/helper.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'lib/imp3'
4
+
5
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+
8
+ require 'imp3'
9
+
10
+ class Test::Unit::TestCase
11
+ end
@@ -0,0 +1,16 @@
1
+ require 'helper'
2
+
3
+ class TestNormalization < Test::Unit::TestCase
4
+ def test_normalization
5
+ fixtures = {
6
+ "the fall of troy" => "fall-troy",
7
+ "The Fall of Troy" => "fall-troy",
8
+ "Daïtro" => "daitro",
9
+ "té" => "te"
10
+ }
11
+
12
+ fixtures.each do |string, expected|
13
+ assert_equal expected, string.normalized_without_words
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imp3
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - "V\xC3\xADctor Mart\xC3\xADnez"
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-03-14 00:00:00 +01:00
13
+ default_executable: imp3
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: commander
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ~>
22
+ - !ruby/object:Gem::Version
23
+ version: 4.0.2
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: terminal-table
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: 1.4.2
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: nokogiri
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 1.4.1
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: pbosetti-rubyosa
47
+ type: :runtime
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: 0.5.3
54
+ version:
55
+ - !ruby/object:Gem::Dependency
56
+ name: friendly_id
57
+ type: :runtime
58
+ version_requirement:
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ~>
62
+ - !ruby/object:Gem::Version
63
+ version: 2.3.3
64
+ version:
65
+ description: An application for batch processing and fixing common issues when dealing with a large iTunes library
66
+ email: knoopx@gmail.com
67
+ executables:
68
+ - imp3
69
+ extensions: []
70
+
71
+ extra_rdoc_files:
72
+ - LICENSE
73
+ - README.rdoc
74
+ files:
75
+ - .document
76
+ - .gitignore
77
+ - LICENSE
78
+ - README.rdoc
79
+ - Rakefile
80
+ - VERSION
81
+ - bin/imp3
82
+ - lib/imp3.rb
83
+ - lib/imp3/cli.rb
84
+ - test/helper.rb
85
+ - test/test_normalization.rb
86
+ has_rdoc: true
87
+ homepage: http://github.com/knoopx/imp3
88
+ licenses: []
89
+
90
+ post_install_message:
91
+ rdoc_options:
92
+ - --charset=UTF-8
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: "0"
100
+ version:
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: "0"
106
+ version:
107
+ requirements: []
108
+
109
+ rubyforge_project:
110
+ rubygems_version: 1.3.5
111
+ signing_key:
112
+ specification_version: 3
113
+ summary: Application for batch processing and fixing common issues when dealing with a large iTunes library
114
+ test_files:
115
+ - test/helper.rb
116
+ - test/test_normalization.rb