reading 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +27 -5
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/item/time_length.rb +60 -23
  6. data/lib/reading/item/view.rb +14 -19
  7. data/lib/reading/item.rb +321 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +13 -6
  14. data/lib/reading/parsing/attributes/variants.rb +9 -6
  15. data/lib/reading/parsing/csv.rb +38 -35
  16. data/lib/reading/parsing/parser.rb +23 -24
  17. data/lib/reading/parsing/rows/blank.rb +23 -0
  18. data/lib/reading/parsing/rows/comment.rb +6 -7
  19. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  20. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  21. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  22. data/lib/reading/parsing/rows/regular.rb +15 -14
  23. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  24. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  25. data/lib/reading/parsing/transformer.rb +13 -17
  26. data/lib/reading/stats/filter.rb +738 -0
  27. data/lib/reading/stats/grouping.rb +243 -0
  28. data/lib/reading/stats/operation.rb +313 -0
  29. data/lib/reading/stats/query.rb +37 -0
  30. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  31. data/lib/reading/util/exclude.rb +12 -0
  32. data/lib/reading/util/hash_to_data.rb +2 -2
  33. data/lib/reading/version.rb +1 -1
  34. data/lib/reading.rb +36 -21
  35. metadata +10 -6
  36. data/bin/readingfile +0 -31
  37. data/lib/reading/util/string_remove.rb +0 -28
  38. 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
@@ -12,9 +12,9 @@ module Reading
12
12
  if v.is_a?(Hash)
13
13
  v.to_data
14
14
  elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
15
- v.map(&:to_data)
15
+ v.map(&:to_data).freeze
16
16
  else
17
- v
17
+ v.freeze
18
18
  end
19
19
  }.values
20
20