reading 0.7.0 → 0.9.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +96 -52
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/filter.rb +95 -0
  6. data/lib/reading/item/time_length.rb +69 -30
  7. data/lib/reading/item/view.rb +116 -0
  8. data/lib/reading/item.rb +384 -0
  9. data/lib/reading/parsing/attributes/attribute.rb +1 -8
  10. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +11 -12
  11. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +31 -22
  12. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  13. data/lib/reading/parsing/attributes/experiences.rb +6 -6
  14. data/lib/reading/parsing/attributes/notes.rb +1 -1
  15. data/lib/reading/parsing/attributes/shared.rb +15 -8
  16. data/lib/reading/parsing/attributes/variants.rb +10 -7
  17. data/lib/reading/parsing/csv.rb +58 -44
  18. data/lib/reading/parsing/parser.rb +24 -25
  19. data/lib/reading/parsing/rows/blank.rb +23 -0
  20. data/lib/reading/parsing/rows/comment.rb +6 -7
  21. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  22. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  23. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  24. data/lib/reading/parsing/rows/regular.rb +15 -14
  25. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  26. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  27. data/lib/reading/parsing/transformer.rb +15 -19
  28. data/lib/reading/stats/filter.rb +738 -0
  29. data/lib/reading/stats/grouping.rb +243 -0
  30. data/lib/reading/stats/operation.rb +313 -0
  31. data/lib/reading/stats/query.rb +37 -0
  32. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  33. data/lib/reading/util/exclude.rb +12 -0
  34. data/lib/reading/util/hash_to_data.rb +30 -0
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +51 -5
  37. metadata +28 -7
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/hash_to_struct.rb +0 -30
  40. data/lib/reading/util/string_remove.rb +0 -28
  41. data/lib/reading/util/string_truncate.rb +0 -22
@@ -0,0 +1,243 @@
1
+ module Reading
2
+ module Stats
3
+ # The part of the query right after the operation, which groups the results,
4
+ # e.g. "by genre, rating".
5
+ class Grouping
6
+ # Determines which group(s) the input indicates, and then groups the
7
+ # Items accordingly. For the groups and their actions, see the constants
8
+ # below.
9
+ # @param input [String] the query string.
10
+ # @param items [Array<Item>] the Items on which to run the operation.
11
+ # @return [Hash] the return value of the group action(s).
12
+ def self.group(input, items)
13
+ grouped_items = {}
14
+
15
+ match = input.match(REGEX)
16
+
17
+ if match
18
+ group_names = match[:groups]
19
+ .split(',')
20
+ .tap { _1.last.sub!(/(\w)\s+\w+/, '\1') }
21
+ .map(&:strip)
22
+ .map { _1.delete_suffix('s') }
23
+ .map(&:to_sym)
24
+
25
+ if group_names.uniq.count < group_names.count
26
+ raise InputError, "Each grouping can be applied only once in a query."
27
+ end
28
+
29
+ begin
30
+ return group_hash(items, group_names)
31
+ rescue InputError => e
32
+ raise e.class, "#{e.message} in \"#{input}\""
33
+ end
34
+ end
35
+
36
+ { all: items }
37
+ end
38
+
39
+ # Recursively builds a tree of groupings based on group_names.
40
+ # @group_names [Array<Symbol>]
41
+ # @items [Array<Item>]
42
+ # @return [Hash, Array<Item>]
43
+ private_class_method def self.group_hash(items, group_names)
44
+ return items if group_names.empty?
45
+
46
+ key = group_names.first
47
+ action = ACTIONS[key]
48
+
49
+ unless action
50
+ raise InputError, "Invalid grouping \"#{key}\""
51
+ end
52
+
53
+ action.call(items).transform_values do |grouped_items|
54
+ group_hash(grouped_items, group_names[1..])
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ INPUT_SPLIT = /\s+(?=[\w\-]+\s*(?:!=|=|!~|~|>=|>|<=|<))/
61
+
62
+ # Each action groups the given Items.
63
+ # @param items [Array<Item>]
64
+ # @return [Hash{Symbol => Array<Item>}] the Items separated into groups.
65
+ ACTIONS = {
66
+ rating: proc { |items|
67
+ items
68
+ .group_by(&:rating)
69
+ .reject { |rating, _items| rating.nil? }
70
+ .sort_by { |rating, _items| rating }
71
+ .reverse
72
+ .to_h
73
+ },
74
+ format: proc { |items|
75
+ groups = Hash.new { |h, k| h[k] = [] }
76
+
77
+ items.each do |item|
78
+ item.variants.group_by(&:format).each do |format, variants|
79
+ groups[format] << item.with_variants(variants)
80
+ end
81
+ end
82
+
83
+ groups.sort.to_h
84
+ },
85
+ source: proc { |items|
86
+ groups = Hash.new { |h, k| h[k] = [] }
87
+
88
+ items.each do |item|
89
+ item
90
+ .variants
91
+ .map { |variant|
92
+ variant.sources.map { |source|
93
+ [variant, source.name || source.url]
94
+ }
95
+ }
96
+ .flatten(1)
97
+ .group_by { |_variant, source| source }
98
+ .transform_values { |variants_and_sources|
99
+ variants_and_sources.map(&:first)
100
+ }
101
+ .each do |source, variants|
102
+ groups[source] << item.with_variants(variants)
103
+ end
104
+ end
105
+
106
+ groups.sort.to_h
107
+ },
108
+ year: proc { |items|
109
+ begin_date = items
110
+ .map { _1.experiences.first&.spans&.first&.dates&.begin }
111
+ .compact
112
+ .min
113
+
114
+ if begin_date.nil?
115
+ {}
116
+ else
117
+ end_date = items
118
+ .flat_map { _1.experiences.map(&:last_end_date) }
119
+ .compact
120
+ .max
121
+
122
+ year_ranges = (begin_date.year..end_date.year).flat_map { |year|
123
+ beginning_of_year = Date.new(year, 1, 1)
124
+ end_of_year = Date.new(year + 1, 1, 1).prev_day
125
+
126
+ beginning_of_year..end_of_year
127
+ }
128
+
129
+ groups = year_ranges.map { [_1, []] }.to_h
130
+
131
+ groups.each do |year_range, year_items|
132
+ items.each do |item|
133
+ without_before = item.split(year_range.end.next_day).first
134
+ without_before_or_after = without_before&.split(year_range.begin)&.last
135
+
136
+ year_items << without_before_or_after if without_before_or_after
137
+ end
138
+ end
139
+
140
+ groups.transform_keys! { |year_range|
141
+ year_range.begin.year
142
+ }
143
+
144
+ groups
145
+ end
146
+ },
147
+ month: proc { |items|
148
+ begin_date = items
149
+ .map { _1.experiences.first&.spans&.first&.dates&.begin }
150
+ .compact
151
+ .min
152
+
153
+ if begin_date.nil?
154
+ {}
155
+ else
156
+ end_date = items
157
+ .flat_map { _1.experiences.map(&:last_end_date) }
158
+ .compact
159
+ .max
160
+
161
+ month_ranges = (begin_date.year..end_date.year).flat_map { |year|
162
+ (1..12).map { |month|
163
+ beginning_of_month = Date.new(year, month, 1)
164
+
165
+ end_of_month =
166
+ if month == 12
167
+ Date.new(year + 1, 1, 1).prev_day
168
+ else
169
+ Date.new(year, month + 1, 1).prev_day
170
+ end
171
+
172
+ beginning_of_month..end_of_month
173
+ }
174
+ }
175
+
176
+ groups = month_ranges.map { [_1, []] }.to_h
177
+
178
+ groups.each do |month_range, month_items|
179
+ items.each do |item|
180
+ without_before = item.split(month_range.end.next_day).first
181
+ without_before_or_after = without_before&.split(month_range.begin)&.last
182
+
183
+ month_items << without_before_or_after if without_before_or_after
184
+ end
185
+ end
186
+
187
+ groups.transform_keys! { |month_range|
188
+ [month_range.begin.year, month_range.begin.month]
189
+ }
190
+
191
+ groups
192
+ end
193
+ },
194
+ genre: proc { |items|
195
+ groups = Hash.new { |h, k| h[k] = [] }
196
+
197
+ items.each do |item|
198
+ item.genres.each { |genre| groups[genre] << item }
199
+ end
200
+
201
+ groups.sort.to_h
202
+ },
203
+ length: proc { |items|
204
+ boundaries = Config.hash.fetch(:length_group_boundaries)
205
+
206
+ groups = boundaries.each_cons(2).map { |a, b|
207
+ [a..b, []]
208
+ }
209
+
210
+ groups.unshift([0..boundaries.first, []])
211
+ groups << [boundaries.last.., []]
212
+
213
+ groups = groups.to_h
214
+
215
+ items.each do |item|
216
+ item
217
+ .variants
218
+ .map { |variant| [variant, variant.length] }
219
+ .group_by { |_variant, length|
220
+ groups.keys.find { |length_range| length_range.include?(length) }
221
+ }
222
+ .transform_values { |variants_and_lengths|
223
+ variants_and_lengths.map(&:first)
224
+ }
225
+ .reject { |length_range, _variants| length_range.nil? }
226
+ .each do |length_range, variants|
227
+ groups[length_range] << item.with_variants(variants)
228
+ end
229
+ end
230
+
231
+ groups
232
+ },
233
+ }
234
+
235
+ REGEX = %r{\A
236
+ [^=]+ # the operation
237
+ by
238
+ \s*
239
+ (?<groups>[\w,\s]+)
240
+ }x
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,313 @@
1
+ module Reading
2
+ module Stats
3
+ # The beginning of a query which specifies what it does, e.g.
4
+ # "average rating" or "total amount".
5
+ class Operation
6
+ using Util::NumericToIIfWhole
7
+
8
+ # Determines which operation is contained in the given input, and then
9
+ # runs it to get the result. For the operations and their actions, see
10
+ # the constants below.
11
+ # @param input [String] the query string.
12
+ # @param grouped_items [Hash{Symbol => Array<Item>}] if no group was used,
13
+ # the hash is just { all: items }
14
+ # @param result_formatters [Hash{Symbol => Proc}] to alter the appearance
15
+ # of results. Keys should be from among the keys of Operation::ACTIONS.
16
+ # @return [Object] the return value of the action; if items are grouped
17
+ # then a hash is returned with the same keys as grouped_items, otherwise
18
+ # just the array of all results (not grouped) is returned.
19
+ def self.execute(input, grouped_items, result_formatters)
20
+ REGEXES.each do |key, regex|
21
+ match = input.match(regex)
22
+
23
+ if match
24
+ if match[:number_arg]
25
+ number_arg = Integer(match[:number_arg], exception: false) ||
26
+ (raise InputError, "Argument must be an integer. Example: top 5 ratings")
27
+ end
28
+
29
+ results = apply_to_inner_items(grouped_items) do |inner_items|
30
+ result = ACTIONS[key].call(inner_items, number_arg)
31
+
32
+ default_formatter = :itself.to_proc # just the result itself
33
+ result_formatter = result_formatters[key] || default_formatter
34
+
35
+ result_formatter.call(result)
36
+ end
37
+
38
+ if results.keys == [:all] # no groupings
39
+ return results[:all]
40
+ else
41
+ return results
42
+ end
43
+ end
44
+ end
45
+
46
+ raise InputError, "No valid operation in stats query \"#{input}\""
47
+ end
48
+
49
+ # A recursive method that applies the block to the leaf nodes (arrays of
50
+ # Items) of the given hash of grouped items.
51
+ # @param grouped_items [Hash]
52
+ # @yield [Array<Item>]
53
+ def self.apply_to_inner_items(grouped_items, &)
54
+ if grouped_items.values.first.is_a? Array
55
+ grouped_items.transform_values! { |inner_items|
56
+ yield inner_items
57
+ }
58
+ else # It's a Hash, so go one level deeper.
59
+ grouped_items.each do |group_name, grouped|
60
+ apply_to_inner_items(grouped, &)
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ # The default number argument if one is not given, as in "top ratings"
68
+ # rather than "top 5 ratings".
69
+ DEFAULT_NUMBER_ARG = 10
70
+
71
+ # Each action makes some calculation based on the given Items.
72
+ # @param items [Array<Item>]
73
+ # @return [Object] in most cases an Integer.
74
+ ACTIONS = {
75
+ average_rating: proc { |items|
76
+ ratings = items.map(&:rating).compact
77
+
78
+ if ratings.any?
79
+ (ratings.sum.to_f / ratings.count).to_i_if_whole
80
+ end
81
+ },
82
+ average_length: proc { |items|
83
+ lengths = items.flat_map { |item|
84
+ item.variants.map(&:length)
85
+ }
86
+ .compact
87
+
88
+ if lengths.any?
89
+ (lengths.sum / lengths.count.to_f).to_i_if_whole
90
+ end
91
+ },
92
+ :"average_amount" => proc { |items|
93
+ total_amount = items.sum { |item|
94
+ item.experiences.sum { |experience|
95
+ experience.spans.sum(&:amount)
96
+ }
97
+ }
98
+
99
+ (total_amount / items.count.to_f).to_i_if_whole
100
+ },
101
+ :"average_daily-amount" => proc { |items|
102
+ amounts_by_date = calculate_amounts_by_date(items)
103
+
104
+ if amounts_by_date.any?
105
+ amounts_by_date.values.sum / amounts_by_date.count
106
+ end
107
+ },
108
+ total_item: proc { |items|
109
+ items.count
110
+ },
111
+ total_amount: proc { |items|
112
+ items.sum { |item|
113
+ item.experiences.sum { |experience|
114
+ experience.spans.sum { |span|
115
+ (span.amount * span.progress).to_i_if_whole
116
+ }
117
+ }
118
+ }
119
+ },
120
+ top_rating: proc { |items, number_arg|
121
+ items
122
+ .max_by(number_arg || DEFAULT_NUMBER_ARG, &:rating)
123
+ .map { |item| [author_and_title(item), item.rating] }
124
+ },
125
+ top_length: proc { |items, number_arg|
126
+ items
127
+ .map { |item| [author_and_title(item), item.variants.map(&:length).max] }
128
+ .reject { |_title, length| length.nil? }
129
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
130
+ },
131
+ top_amount: proc { |items, number_arg|
132
+ items
133
+ .map { |item|
134
+ amount = item.experiences.sum { |experience|
135
+ experience.spans.sum { |span|
136
+ (span.amount * span.progress).to_i_if_whole
137
+ }
138
+ }
139
+
140
+ [author_and_title(item), amount]
141
+ }
142
+ .reject { |_title, amount| amount.zero? }
143
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
144
+ },
145
+ top_speed: proc { |items, number_arg|
146
+ items
147
+ .map { |item| calculate_speed(item) }
148
+ .compact
149
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
150
+ speed_hash[:amount] / speed_hash[:days].to_f
151
+ }
152
+ },
153
+ bottom_rating: proc { |items, number_arg|
154
+ items
155
+ .min_by(number_arg || DEFAULT_NUMBER_ARG, &:rating)
156
+ .map { |item| [author_and_title(item), item.rating] }
157
+ },
158
+ bottom_length: proc { |items, number_arg|
159
+ items
160
+ .map { |item| [author_and_title(item), item.variants.map(&:length).max] }
161
+ .reject { |_title, length| length.nil? }
162
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
163
+ },
164
+ bottom_amount: proc { |items, number_arg|
165
+ items
166
+ .map { |item|
167
+ amount = item.experiences.sum { |experience|
168
+ experience.spans.sum { |span|
169
+ (span.amount * span.progress).to_i_if_whole
170
+ }
171
+ }
172
+
173
+ [author_and_title(item), amount]
174
+ }
175
+ .reject { |_title, amount| amount.zero? }
176
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
177
+ },
178
+ bottom_speed: proc { |items, number_arg|
179
+ items
180
+ .map { |item| calculate_speed(item) }
181
+ .compact
182
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
183
+ speed_hash[:amount] / speed_hash[:days].to_f
184
+ }
185
+ },
186
+ }
187
+
188
+ ALIASES = {
189
+ average_rating: %w[ar],
190
+ average_length: %w[al],
191
+ average_amount: %w[aia ai],
192
+ :"average_daily-amount" => %w[ada ad],
193
+ total_item: %w[item count],
194
+ total_amount: %w[amount],
195
+ top_rating: %w[tr],
196
+ top_length: %w[tl],
197
+ top_amount: %w[ta],
198
+ top_speed: %w[ts],
199
+ bottom_rating: %w[br],
200
+ bottom_length: %w[bl],
201
+ bottom_amount: %w[ba],
202
+ bottom_speed: %w[bs],
203
+ }
204
+
205
+ REGEXES = ACTIONS.map { |key, _action|
206
+ first_word, second_word = key.to_s.split('_')
207
+ aliases = ALIASES.fetch(key)
208
+
209
+ regex =
210
+ %r{
211
+ (
212
+ \A
213
+ \s*
214
+ #{first_word}
215
+ s?
216
+ \s*
217
+ (?<number_arg>
218
+ \d+
219
+ )?
220
+ \s*
221
+ (
222
+ #{second_word}
223
+ s?
224
+ )
225
+ \s*
226
+ )
227
+ |
228
+ (
229
+ \A
230
+ \s*
231
+ (#{aliases.join('|')})
232
+ s?
233
+ \s*
234
+ (?<number_arg>
235
+ \d+
236
+ )?
237
+ \s*
238
+ )
239
+ }x
240
+
241
+ [key, regex]
242
+ }.to_h
243
+
244
+ # Sums the given Items' amounts per date.
245
+ # @param items [Array<Item>]
246
+ # @return [Hash{Date => Numeric, Reading::Item::TimeLength}]
247
+ private_class_method def self.calculate_amounts_by_date(items)
248
+ amounts_by_date = {}
249
+
250
+ items.each do |item|
251
+ item.experiences.each do |experience|
252
+ experience.spans.each do |span|
253
+ next unless span.dates
254
+
255
+ dates = span.dates.begin..(span.dates.end || Date.today)
256
+
257
+ amount = span.amount / dates.count.to_f
258
+ progress = span.members.include?(:progress) ? span.progress : 1.0
259
+
260
+ dates.each do |date|
261
+ amounts_by_date[date] ||= 0
262
+ amounts_by_date[date] += amount * progress
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ amounts_by_date
269
+ end
270
+
271
+ # Calculates an Item's speed (total amount over how many days). Returns
272
+ # nil if a speed is not able to be calculated (e.g. in a planned Item).
273
+ # @param item [Item]
274
+ # @return [Array(String, Hash), nil]
275
+ private_class_method def self.calculate_speed(item)
276
+ speeds = item.experiences.map { |experience|
277
+ spans_with_finite_dates = experience.spans.reject { |span|
278
+ span.dates.nil? || span.dates.end.nil?
279
+ }
280
+ next unless spans_with_finite_dates.any?
281
+
282
+ amount = spans_with_finite_dates.sum { |span|
283
+ # Conditional in case Item was created with fragmentary experience hashes,
284
+ # as in stats_test.rb
285
+ progress = span.members.include?(:progress) ? span.progress : 1.0
286
+
287
+ span.amount * progress
288
+ }
289
+ .to_i_if_whole
290
+
291
+ days = spans_with_finite_dates.sum { |span| span.dates.count }.to_i
292
+
293
+ { amount:, days: }
294
+ }
295
+ .compact
296
+
297
+ return nil unless speeds.any?
298
+
299
+ speed = speeds
300
+ .max_by { |hash| hash[:amount] / hash[:days].to_f }
301
+
302
+ [author_and_title(item), speed]
303
+ end
304
+
305
+ # A shorter version of Item::View#name.
306
+ # @param item [Item]
307
+ # @return [String]
308
+ private_class_method def self.author_and_title(item)
309
+ "#{item.author + " – " if item.author}#{item.title}"
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,37 @@
1
+ require 'pastel'
2
+ require_relative 'operation'
3
+ require_relative 'filter'
4
+ require_relative 'grouping'
5
+
6
+ module Reading
7
+ module Stats
8
+ # Gives statistics on an array of Items.
9
+ class Query
10
+ private attr_reader :input, :items, :result_formatters, :pastel
11
+
12
+ # @param input [String] the query string.
13
+ # @param items [Array<Item>] the Items to be queried.
14
+ # @param result_formatters [Boolean, Hash{Symbol => Proc}] to alter the
15
+ # appearance of results; keys should be from among the keys of
16
+ # Operation::ACTIONS. Pre-made formatters for terminal output are in
17
+ # terminal_result_formatters.rb.
18
+ def initialize(input:, items:, result_formatters: {})
19
+ @input = input
20
+ @items = items
21
+ @result_formatters = result_formatters
22
+ @pastel = Pastel.new
23
+ end
24
+
25
+ # Parses the query and returns the result.
26
+ # @return [Object]
27
+ def result
28
+ filtered_items = Stats::Filter.filter(input, items)
29
+ grouped_items = Grouping.group(input, filtered_items)
30
+
31
+ Operation.execute(input, grouped_items, result_formatters || {})
32
+ rescue Reading::Error => e
33
+ raise e.class, pastel.bright_red(e.message)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ require 'pastel'
2
+
3
+ module Reading
4
+ module Stats
5
+ module ResultFormatters
6
+ TERMINAL = {
7
+ average_length: ->(result) { length_to_s(result) },
8
+ average_amount: ->(result) { length_to_s(result) },
9
+ :'average_daily-amount' => ->(result) { "#{length_to_s(result)} per day" },
10
+ total_item: ->(result) {
11
+ if result.zero?
12
+ PASTEL.bright_black("none")
13
+ else
14
+ color("#{result} #{result == 1 ? "item" : "items"}")
15
+ end
16
+ },
17
+ total_amount: ->(result) { length_to_s(result) },
18
+ top_length: ->(result) { top_or_bottom_lengths(result) },
19
+ top_amount: ->(result) { top_or_bottom_lengths(result) },
20
+ top_speed: ->(result) { top_or_bottom_speeds(result) },
21
+ bottom_length: ->(result) { top_or_bottom_lengths(result) },
22
+ botom_amount: ->(result) { top_or_bottom_lengths(result) },
23
+ bottom_speed: ->(result) { top_or_bottom_speeds(result) },
24
+ }
25
+
26
+ private
27
+
28
+ PASTEL = Pastel.new
29
+
30
+ # Applies a terminal color.
31
+ # @param string [String]
32
+ # @return [String]
33
+ private_class_method def self.color(string)
34
+ PASTEL.bright_blue(string)
35
+ end
36
+
37
+ # Converts a length/amount (pages or time) into a string.
38
+ # @param length [Numeric, Reading::Item::TimeLength]
39
+ # @param color [Boolean] whether a terminal color should be applied.
40
+ # @return [String]
41
+ private_class_method def self.length_to_s(length, color: true)
42
+ if length.is_a?(Numeric)
43
+ length_string = "#{length.round} pages"
44
+ else
45
+ length_string = length.to_s
46
+ end
47
+
48
+ color ? color(length_string) : length_string
49
+ end
50
+
51
+ # Formats a list of top/bottom length results as a string.
52
+ # @param result [Array]
53
+ # @return [String]
54
+ private_class_method def self.top_or_bottom_lengths(result)
55
+ offset = result.count.digits.count
56
+
57
+ result
58
+ .map.with_index { |(title, length), index|
59
+ pad = ' ' * (offset - (index + 1).digits.count)
60
+
61
+ title_line = "#{index + 1}. #{pad}#{title}"
62
+ indent = " #{' ' * offset}"
63
+
64
+ "#{title_line}\n#{indent}#{length_to_s(length)}"
65
+ }
66
+ .join("\n")
67
+ end
68
+
69
+ # Formats a list of top/bottom speed results as a string.
70
+ # @param result [Array]
71
+ # @return [String]
72
+ private_class_method def self.top_or_bottom_speeds(result)
73
+ offset = result.count.digits.count
74
+
75
+ result
76
+ .map.with_index { |(title, hash), index|
77
+ amount = length_to_s(hash[:amount], color: false)
78
+ days = "#{hash[:days]} #{hash[:days] == 1 ? "day" : "days"}"
79
+ pad = ' ' * (offset - (index + 1).digits.count)
80
+
81
+ title_line = "#{index + 1}. #{pad}#{title}"
82
+ indent = " #{' ' * offset}"
83
+ colored_speed = color("#{amount} in #{days}")
84
+
85
+ "#{title_line}\n#{indent}#{colored_speed}"
86
+ }
87
+ .join("\n")
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,12 @@
1
+ # Copied from activesupport/lib/active_support/core_ext/enumerable.rb
2
+ module Enumerable
3
+ def exclude?(object)
4
+ !include?(object)
5
+ end
6
+ end
7
+
8
+ class String
9
+ def exclude?(object)
10
+ !include?(object)
11
+ end
12
+ end