tuomas-tweetwine 0.1.11 → 0.2.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/CHANGELOG.rdoc +11 -0
- data/README.rdoc +36 -2
- data/Rakefile +2 -3
- data/bin/tweetwine +13 -4
- data/lib/tweetwine/client.rb +74 -39
- data/lib/tweetwine/io.rb +48 -28
- data/lib/tweetwine/meta.rb +10 -0
- data/lib/tweetwine/options.rb +17 -0
- data/lib/tweetwine/rest_client_wrapper.rb +17 -0
- data/lib/tweetwine/startup_config.rb +1 -4
- data/lib/tweetwine/url_shortener.rb +37 -0
- data/lib/tweetwine/util.rb +22 -0
- data/lib/tweetwine.rb +10 -1
- data/test/client_test.rb +252 -154
- data/test/io_test.rb +40 -16
- data/test/options_test.rb +19 -0
- data/test/rest_client_wrapper_test.rb +24 -0
- data/test/test_helper.rb +21 -18
- data/test/url_shortener_test.rb +68 -0
- data/test/util_test.rb +29 -0
- metadata +10 -3
data/CHANGELOG.rdoc
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
=== 0.2.1 released 2009-08-17
|
|
2
|
+
|
|
3
|
+
* Command line option "-v" show version information.
|
|
4
|
+
* Slight implementation code cleaning.
|
|
5
|
+
|
|
6
|
+
=== 0.2.0 released 2009-08-17
|
|
7
|
+
|
|
8
|
+
* URL shortening by using an external web service.
|
|
9
|
+
* Show a preview before sending status update.
|
|
10
|
+
* Avoid stack trace upon erroneous connection.
|
|
11
|
+
|
|
1
12
|
=== 0.1.11 released 2009-08-10
|
|
2
13
|
|
|
3
14
|
* Fixed highlighting multiple nicks in statuses.
|
data/README.rdoc
CHANGED
|
@@ -8,7 +8,8 @@ Install Tweetwine as a RubyGem from GitHub:
|
|
|
8
8
|
|
|
9
9
|
$ sudo gem install tuomas-tweetwine --source http://gems.github.com
|
|
10
10
|
|
|
11
|
-
The program is compatible with Ruby 1.9.1
|
|
11
|
+
The program is compatible with Ruby 1.9.1, and it requires <b>rest_client</b>
|
|
12
|
+
gem to be installed. In addition, <b>json</b> gem is needed for Ruby 1.8.6.
|
|
12
13
|
|
|
13
14
|
== Usage
|
|
14
15
|
|
|
@@ -22,7 +23,7 @@ information can be supplied either via a configuration file or as an option
|
|
|
22
23
|
former method over the latter.
|
|
23
24
|
|
|
24
25
|
The configuration file, in <tt>~/.tweetwine</tt>, is in YAML syntax. The
|
|
25
|
-
program recognizes the following settings:
|
|
26
|
+
program recognizes the following basic settings:
|
|
26
27
|
|
|
27
28
|
username: <your_username>
|
|
28
29
|
password: <your_password>
|
|
@@ -43,6 +44,39 @@ For all the options, see:
|
|
|
43
44
|
|
|
44
45
|
$ tweetwine -h
|
|
45
46
|
|
|
47
|
+
=== URL shortening for status update
|
|
48
|
+
|
|
49
|
+
Before actually sending a status update, it is possible for the software to
|
|
50
|
+
shorten the URLs in the update by using an external web service. This can be
|
|
51
|
+
enabled via the <tt>shorten_urls</tt> key in configuration file; for
|
|
52
|
+
example:
|
|
53
|
+
|
|
54
|
+
username: spoonman
|
|
55
|
+
password: withyourhands
|
|
56
|
+
colorize: true
|
|
57
|
+
shorten_urls:
|
|
58
|
+
enable: true
|
|
59
|
+
service_url: http://is.gd/create.php
|
|
60
|
+
method: post
|
|
61
|
+
url_param_name: URL
|
|
62
|
+
xpath_selector: //input[@id='short_url']/@value
|
|
63
|
+
|
|
64
|
+
The supported methods are <tt>get</tt> and <tt>post</tt>. The method chosen
|
|
65
|
+
affects whether parameters are passed as URL query parameters or as payload
|
|
66
|
+
in the HTTP request, respectively. Extra parameters can be given via
|
|
67
|
+
<tt>extra_params</tt> key, as a hash.
|
|
68
|
+
|
|
69
|
+
The <tt>xpath_selector</tt> is needed to extract the shortened URL from the
|
|
70
|
+
result.
|
|
71
|
+
|
|
72
|
+
The feature can be disabled by
|
|
73
|
+
|
|
74
|
+
* not defining <tt>shorten_urls</tt> key in the configuration file,
|
|
75
|
+
* setting key <tt>enable</tt> to <tt>false</tt>, or
|
|
76
|
+
* using the command line option <tt>--no-url-shorten</tt>.
|
|
77
|
+
|
|
78
|
+
The use of the feature requires <b>nokogiri</b> gem to be installed.
|
|
79
|
+
|
|
46
80
|
== Contacting
|
|
47
81
|
|
|
48
82
|
Please send feedback by email to Tuomas Kareinen < tkareine (at) gmail (dot)
|
data/Rakefile
CHANGED
data/bin/tweetwine
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
3
|
EXIT_HELP = 1
|
|
4
|
-
|
|
4
|
+
EXIT_VERSION = 2
|
|
5
|
+
EXIT_SIGINT = 6
|
|
5
6
|
EXIT_ERROR = 64
|
|
6
7
|
|
|
7
8
|
trap("INT") do
|
|
8
|
-
puts "\nAbort
|
|
9
|
+
puts "\nAbort"
|
|
9
10
|
exit(EXIT_SIGINT)
|
|
10
11
|
end
|
|
11
12
|
|
|
@@ -39,10 +40,17 @@ Usage: tweetwine [options...] [command]
|
|
|
39
40
|
opt.on("-n", "--num N", Integer, "The number of statuses to fetch, defaults to #{Client::DEFAULT_NUM_STATUSES}") do |arg|
|
|
40
41
|
options[:num_statuses] = arg
|
|
41
42
|
end
|
|
43
|
+
opt.on("--no-url-shorten", "Do not shorten URLs for status update") do
|
|
44
|
+
options[:shorten_urls] = { :enable => false }
|
|
45
|
+
end
|
|
42
46
|
opt.on("-p", "--page N", Integer, "The page number of the statuses to fetch, defaults to #{Client::DEFAULT_PAGE_NUM}") do |arg|
|
|
43
47
|
options[:page_num] = arg
|
|
44
48
|
end
|
|
45
|
-
opt.
|
|
49
|
+
opt.on("-v", "--version", "Show version information and exit") do
|
|
50
|
+
puts Meta
|
|
51
|
+
exit(EXIT_VERSION)
|
|
52
|
+
end
|
|
53
|
+
opt.on_tail("-h", "--help", "Show this help message and exit") do
|
|
46
54
|
puts opt
|
|
47
55
|
exit(EXIT_HELP)
|
|
48
56
|
end
|
|
@@ -52,7 +60,8 @@ Usage: tweetwine [options...] [command]
|
|
|
52
60
|
end
|
|
53
61
|
options
|
|
54
62
|
end
|
|
55
|
-
|
|
63
|
+
io = Tweetwine::IO.new(config.options)
|
|
64
|
+
client = Client.new(io, config.options)
|
|
56
65
|
client.send(config.command, *config.args)
|
|
57
66
|
rescue ArgumentError, ClientError => e
|
|
58
67
|
puts "Error: #{e.message}"
|
data/lib/tweetwine/client.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
|
-
require "
|
|
2
|
+
require "uri"
|
|
3
3
|
|
|
4
4
|
module Tweetwine
|
|
5
|
-
class ClientError < RuntimeError; end
|
|
6
|
-
|
|
7
5
|
class Client
|
|
8
6
|
attr_reader :num_statuses, :page_num
|
|
9
7
|
|
|
@@ -13,14 +11,19 @@ module Tweetwine
|
|
|
13
11
|
DEFAULT_PAGE_NUM = 1
|
|
14
12
|
MAX_STATUS_LENGTH = 140
|
|
15
13
|
|
|
16
|
-
def initialize(options)
|
|
14
|
+
def initialize(io, options)
|
|
15
|
+
@io = io
|
|
17
16
|
@username = options[:username].to_s
|
|
18
17
|
raise ArgumentError, "No authentication data given" if @username.empty?
|
|
19
18
|
@base_url = "https://#{@username}:#{options[:password]}@twitter.com/"
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
|
|
19
|
+
@num_statuses = Util.parse_int_gt(options[:num_statuses], DEFAULT_NUM_STATUSES, 1, "number of statuses_to_show")
|
|
20
|
+
@page_num = Util.parse_int_gt(options[:page_num], DEFAULT_PAGE_NUM, 1, "page number")
|
|
21
|
+
@url_shortener = if options[:shorten_urls] && options[:shorten_urls][:enable]
|
|
22
|
+
UrlShortener.new(options[:shorten_urls])
|
|
23
|
+
else
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
@status_update_factory = StatusUpdateFactory.new(@io, @url_shortener)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def home
|
|
@@ -36,18 +39,18 @@ module Tweetwine
|
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def update(new_status = nil)
|
|
39
|
-
new_status = @
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@io.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@io.info "Cancelled."
|
|
42
|
+
new_status = @status_update_factory.prepare(new_status)
|
|
43
|
+
completed = false
|
|
44
|
+
unless new_status.empty?
|
|
45
|
+
@io.show_status_preview(new_status)
|
|
46
|
+
if @io.confirm("Really send?")
|
|
47
|
+
status = JSON.parse(post("statuses/update.json", {:status => new_status.to_s}))
|
|
48
|
+
@io.info "Sent status update.\n\n"
|
|
49
|
+
show_statuses([status])
|
|
50
|
+
completed = true
|
|
51
|
+
end
|
|
50
52
|
end
|
|
53
|
+
@io.info "Cancelled." unless completed
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def friends
|
|
@@ -60,19 +63,6 @@ module Tweetwine
|
|
|
60
63
|
|
|
61
64
|
private
|
|
62
65
|
|
|
63
|
-
def parse_int_gt_option(value, default, min, name_for_error)
|
|
64
|
-
if value
|
|
65
|
-
value = value.to_i
|
|
66
|
-
if value >= min
|
|
67
|
-
value
|
|
68
|
-
else
|
|
69
|
-
raise ArgumentError, "Invalid #{name_for_error} -- must be greater than or equal to #{min}"
|
|
70
|
-
end
|
|
71
|
-
else
|
|
72
|
-
default
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
66
|
def get_response_as_json(url_body, *query_opts)
|
|
77
67
|
url = url_body + ".json?#{parse_query_options(query_opts)}"
|
|
78
68
|
JSON.parse(get(url))
|
|
@@ -103,7 +93,7 @@ module Tweetwine
|
|
|
103
93
|
def show_responses(data)
|
|
104
94
|
data.each do |entry|
|
|
105
95
|
user_data, status_data = yield entry
|
|
106
|
-
@io.
|
|
96
|
+
@io.show_record(parse_response(user_data, status_data))
|
|
107
97
|
end
|
|
108
98
|
end
|
|
109
99
|
|
|
@@ -120,17 +110,62 @@ module Tweetwine
|
|
|
120
110
|
end
|
|
121
111
|
|
|
122
112
|
def get(body_url)
|
|
123
|
-
|
|
113
|
+
RestClientWrapper.get @base_url + body_url
|
|
124
114
|
end
|
|
125
115
|
|
|
126
116
|
def post(body_url, body)
|
|
127
|
-
|
|
117
|
+
RestClientWrapper.post @base_url + body_url, body
|
|
128
118
|
end
|
|
129
119
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
class StatusUpdateFactory
|
|
121
|
+
def initialize(io, url_shortener)
|
|
122
|
+
@io = io
|
|
123
|
+
@url_shortener = url_shortener
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def prepare(status)
|
|
127
|
+
StatusUpdate.new(status, @io, @url_shortener).to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class StatusUpdate
|
|
132
|
+
def initialize(status, io, url_shortener)
|
|
133
|
+
@io = io
|
|
134
|
+
@url_shortener = url_shortener
|
|
135
|
+
@text = prepare(status)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_s
|
|
139
|
+
@text.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def prepare(status)
|
|
145
|
+
status = unless status
|
|
146
|
+
@io.prompt("Status update")
|
|
147
|
+
else
|
|
148
|
+
status.dup
|
|
149
|
+
end
|
|
150
|
+
status.strip!
|
|
151
|
+
shorten_urls!(status) if @url_shortener
|
|
152
|
+
truncate!(status) if status.length > MAX_STATUS_LENGTH
|
|
153
|
+
status
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def truncate!(status)
|
|
157
|
+
status.replace status[0...MAX_STATUS_LENGTH]
|
|
158
|
+
@io.warn("Status will be truncated.")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def shorten_urls!(status)
|
|
162
|
+
url_pairs = URI.extract(status, ["http", "https"]).map do |url_to_be_shortened|
|
|
163
|
+
[url_to_be_shortened, @url_shortener.shorten(url_to_be_shortened)]
|
|
164
|
+
end
|
|
165
|
+
url_pairs.reject { |pair| pair.last.nil? || pair.last.empty? }.each do |url_pair|
|
|
166
|
+
status.sub!(url_pair.first, url_pair.last)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
134
169
|
end
|
|
135
170
|
end
|
|
136
171
|
end
|
data/lib/tweetwine/io.rb
CHANGED
|
@@ -36,49 +36,69 @@ module Tweetwine
|
|
|
36
36
|
confirmation.downcase[0,1] == "y"
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
39
|
+
def show_status_preview(status)
|
|
40
|
+
@output.puts <<-END
|
|
41
|
+
|
|
42
|
+
#{format_status(status)}
|
|
43
|
+
|
|
44
|
+
END
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def show_record(record)
|
|
40
48
|
if record[:status]
|
|
41
|
-
|
|
49
|
+
show_record_as_user_with_status(record)
|
|
42
50
|
else
|
|
43
|
-
|
|
51
|
+
show_record_as_user(record)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
colorize!(:green, from_user) if @colorize
|
|
51
|
-
in_reply_to = record[:status][:in_reply_to]
|
|
52
|
-
in_reply_to = if in_reply_to && !in_reply_to.empty?
|
|
53
|
-
colorize!(:green, in_reply_to) if @colorize
|
|
54
|
-
"in reply to #{in_reply_to}, "
|
|
55
|
-
else
|
|
56
|
-
""
|
|
57
|
-
end
|
|
58
|
-
status = record[:status][:text]
|
|
59
|
-
if @colorize
|
|
60
|
-
colorize_all!(:yellow, status, NICK_REGEX)
|
|
61
|
-
URI.extract(status, ["http", "https"]).each do |url|
|
|
62
|
-
colorize_first!(:cyan, status, url)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def show_record_as_user(record)
|
|
65
58
|
@output.puts <<-END
|
|
66
|
-
#{
|
|
67
|
-
#{status}
|
|
59
|
+
#{format_user(record[:user])}
|
|
68
60
|
|
|
69
61
|
END
|
|
70
62
|
end
|
|
71
63
|
|
|
72
|
-
def
|
|
73
|
-
user = record[:user]
|
|
74
|
-
colorize!(:green, user) if @colorize
|
|
64
|
+
def show_record_as_user_with_status(record)
|
|
75
65
|
@output.puts <<-END
|
|
76
|
-
#{
|
|
66
|
+
#{format_record_header(record)}
|
|
67
|
+
#{format_status(record[:status][:text])}
|
|
77
68
|
|
|
78
69
|
END
|
|
79
70
|
end
|
|
80
71
|
|
|
81
|
-
|
|
72
|
+
def format_user(user)
|
|
73
|
+
user = user.dup
|
|
74
|
+
colorize!(:green, user) if @colorize
|
|
75
|
+
user
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format_status(status)
|
|
79
|
+
status = status.dup
|
|
80
|
+
if @colorize
|
|
81
|
+
colorize_all!(:yellow, status, NICK_REGEX)
|
|
82
|
+
URI.extract(status, ["http", "https"]).each do |url|
|
|
83
|
+
colorize_first!(:cyan, status, url)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
status
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_record_header(record)
|
|
90
|
+
time_diff_value, time_diff_unit = Util.humanize_time_diff(record[:status][:created_at], Time.now)
|
|
91
|
+
from_user = record[:user].dup
|
|
92
|
+
colorize!(:green, from_user) if @colorize
|
|
93
|
+
in_reply_to = record[:status][:in_reply_to]
|
|
94
|
+
in_reply_to = if in_reply_to && !in_reply_to.empty?
|
|
95
|
+
in_reply_to = colorize!(:green, in_reply_to.dup) if @colorize
|
|
96
|
+
"in reply to #{in_reply_to}, "
|
|
97
|
+
else
|
|
98
|
+
""
|
|
99
|
+
end
|
|
100
|
+
"#{from_user}, #{in_reply_to}#{time_diff_value} #{time_diff_unit} ago:"
|
|
101
|
+
end
|
|
82
102
|
|
|
83
103
|
def colorize_all!(color, str, pattern)
|
|
84
104
|
str.gsub!(pattern) { |s| colorize_str(COLOR_CODES[color.to_sym], s) }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Tweetwine
|
|
2
|
+
class Options
|
|
3
|
+
def initialize(options)
|
|
4
|
+
@hash = options.to_hash
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def [](key)
|
|
8
|
+
@hash[key]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def require(key)
|
|
12
|
+
value = @hash[key]
|
|
13
|
+
raise RuntimeError, "Option #{key} is required" if value.nil?
|
|
14
|
+
value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "rest_client"
|
|
2
|
+
|
|
3
|
+
module Tweetwine
|
|
4
|
+
class ClientError < RuntimeError; end
|
|
5
|
+
|
|
6
|
+
class RestClientWrapper
|
|
7
|
+
instance_methods.each { |m| undef_method m unless m =~ /(^__|^send$|^object_id$)/ }
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def self.method_missing(name, *args, &block)
|
|
12
|
+
RestClient.send(name, *args, &block)
|
|
13
|
+
rescue RestClient::Exception, SystemCallError => e
|
|
14
|
+
raise ClientError, e.message
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -34,10 +34,7 @@ module Tweetwine
|
|
|
34
34
|
|
|
35
35
|
def parse_config_file(config_file)
|
|
36
36
|
options = YAML.load(File.read(config_file))
|
|
37
|
-
|
|
38
|
-
result[pair.first.to_sym] = pair.last
|
|
39
|
-
result
|
|
40
|
-
end
|
|
37
|
+
Util.symbolize_hash_keys(options)
|
|
41
38
|
end
|
|
42
39
|
end
|
|
43
40
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Tweetwine
|
|
2
|
+
class UrlShortener
|
|
3
|
+
def initialize(options)
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
options = Options.new(options)
|
|
6
|
+
@method = options[:method].to_sym || :get
|
|
7
|
+
@service_url = options.require :service_url
|
|
8
|
+
@url_param_name = options.require :url_param_name
|
|
9
|
+
@extra_params = options[:extra_params] || {}
|
|
10
|
+
if @method == :get
|
|
11
|
+
tmp = []
|
|
12
|
+
@extra_params.each_pair { |k, v| tmp << "#{k}=#{v}" }
|
|
13
|
+
@extra_params = tmp
|
|
14
|
+
end
|
|
15
|
+
@xpath_selector = options.require :xpath_selector
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def shorten(url)
|
|
19
|
+
rest = case @method
|
|
20
|
+
when :get
|
|
21
|
+
tmp = @extra_params.dup
|
|
22
|
+
tmp << "#{@url_param_name}=#{url}"
|
|
23
|
+
service_url = "#{@service_url}?#{tmp.join('&')}"
|
|
24
|
+
[service_url]
|
|
25
|
+
when :post
|
|
26
|
+
service_url = @service_url
|
|
27
|
+
params = @extra_params.merge({ @url_param_name.to_sym => url })
|
|
28
|
+
[service_url, params]
|
|
29
|
+
else
|
|
30
|
+
raise "Unrecognized HTTP request method"
|
|
31
|
+
end
|
|
32
|
+
response = RestClientWrapper.send(@method, *rest)
|
|
33
|
+
doc = Nokogiri::HTML(response)
|
|
34
|
+
doc.xpath(@xpath_selector).first.to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/tweetwine/util.rb
CHANGED
|
@@ -18,6 +18,28 @@ module Tweetwine
|
|
|
18
18
|
[value, pluralize_unit(value, unit)]
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def self.symbolize_hash_keys(hash)
|
|
22
|
+
hash.inject({}) do |result, pair|
|
|
23
|
+
value = pair.last
|
|
24
|
+
value = symbolize_hash_keys(value) if value.is_a? Hash
|
|
25
|
+
result[pair.first.to_sym] = value
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse_int_gt(value, default, min, name_for_error)
|
|
31
|
+
if value
|
|
32
|
+
value = value.to_i
|
|
33
|
+
if value >= min
|
|
34
|
+
value
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "Invalid #{name_for_error} -- must be greater than or equal to #{min}"
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
default
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
21
43
|
private
|
|
22
44
|
|
|
23
45
|
def self.pluralize_unit(value, unit)
|