reading 0.8.0 → 0.9.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +95 -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 +324 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +17 -7
  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 +16 -10
  25. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
  26. data/lib/reading/parsing/transformer.rb +13 -17
  27. data/lib/reading/stats/filter.rb +738 -0
  28. data/lib/reading/stats/grouping.rb +257 -0
  29. data/lib/reading/stats/operation.rb +345 -0
  30. data/lib/reading/stats/query.rb +37 -0
  31. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  32. data/lib/reading/util/exclude.rb +12 -0
  33. data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
  34. data/lib/reading/util/hash_to_data.rb +2 -2
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +36 -21
  37. metadata +28 -24
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/string_remove.rb +0 -28
  40. data/lib/reading/util/string_truncate.rb +0 -22
@@ -0,0 +1,257 @@
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
+ end_year = [Date.today.year, end_date.year].min
123
+
124
+ year_ranges = (begin_date.year..end_year).flat_map { |year|
125
+ beginning_of_year = Date.new(year, 1, 1)
126
+ end_of_year = Date.new(year + 1, 1, 1).prev_day
127
+
128
+ beginning_of_year..end_of_year
129
+ }
130
+
131
+ groups = year_ranges.map { [_1, []] }.to_h
132
+
133
+ groups.each do |year_range, year_items|
134
+ items.each do |item|
135
+ without_before = item.split(year_range.end.next_day).first
136
+ without_before_or_after = without_before&.split(year_range.begin)&.last
137
+
138
+ year_items << without_before_or_after if without_before_or_after
139
+ end
140
+ end
141
+
142
+ groups.transform_keys! { |year_range|
143
+ year_range.begin.year
144
+ }
145
+
146
+ groups
147
+ end
148
+ },
149
+ month: proc { |items|
150
+ begin_date = items
151
+ .map { _1.experiences.first&.spans&.first&.dates&.begin }
152
+ .compact
153
+ .min
154
+
155
+ if begin_date.nil?
156
+ {}
157
+ else
158
+ end_date = items
159
+ .flat_map { _1.experiences.map(&:last_end_date) }
160
+ .compact
161
+ .max
162
+
163
+ month_ranges = (begin_date.year..end_date.year).flat_map { |year|
164
+ (1..12).map { |month|
165
+ beginning_of_month = Date.new(year, month, 1)
166
+
167
+ end_of_month =
168
+ if month == 12
169
+ Date.new(year + 1, 1, 1).prev_day
170
+ else
171
+ Date.new(year, month + 1, 1).prev_day
172
+ end
173
+
174
+ beginning_of_month..end_of_month
175
+ }
176
+ }
177
+
178
+ groups = month_ranges.map { [_1, []] }.to_h
179
+
180
+ groups.each do |month_range, month_items|
181
+ items.each do |item|
182
+ without_before = item.split(month_range.end.next_day).first
183
+ without_before_or_after = without_before&.split(month_range.begin)&.last
184
+
185
+ month_items << without_before_or_after if without_before_or_after
186
+ end
187
+ end
188
+
189
+ groups.transform_keys! { |month_range|
190
+ [month_range.begin.year, month_range.begin.month]
191
+ }
192
+
193
+ groups
194
+ end
195
+ },
196
+ eachgenre: proc { |items|
197
+ groups = Hash.new { |h, k| h[k] = [] }
198
+
199
+ items.each do |item|
200
+ item.genres.each { |genre| groups[genre] << item }
201
+ end
202
+
203
+ groups.sort.to_h
204
+ },
205
+ genre: proc { |items|
206
+ groups = Hash.new { |h, k| h[k] = [] }
207
+
208
+ items.each do |item|
209
+ if item.genres.any?
210
+ genre_combination = item.genres.sort.join(", ")
211
+ groups[genre_combination] << item
212
+ end
213
+ end
214
+
215
+ groups.sort.to_h
216
+ },
217
+ length: proc { |items|
218
+ boundaries = Config.hash.fetch(:length_group_boundaries)
219
+
220
+ groups = boundaries.each_cons(2).map { |a, b|
221
+ [a..b, []]
222
+ }
223
+
224
+ groups.unshift([0..boundaries.first, []])
225
+ groups << [boundaries.last.., []]
226
+
227
+ groups = groups.to_h
228
+
229
+ items.each do |item|
230
+ item
231
+ .variants
232
+ .map { |variant| [variant, variant.length] }
233
+ .group_by { |_variant, length|
234
+ groups.keys.find { |length_range| length_range.include?(length) }
235
+ }
236
+ .transform_values { |variants_and_lengths|
237
+ variants_and_lengths.map(&:first)
238
+ }
239
+ .reject { |length_range, _variants| length_range.nil? }
240
+ .each do |length_range, variants|
241
+ groups[length_range] << item.with_variants(variants)
242
+ end
243
+ end
244
+
245
+ groups
246
+ },
247
+ }
248
+
249
+ REGEX = %r{\A
250
+ [^=]+ # the operation
251
+ by
252
+ \s*
253
+ (?<groups>[\w,\s]+)
254
+ }x
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,345 @@
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, &block)
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, &block)
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
+ list_item: proc { |items|
109
+ items.map { |item| author_and_title(item) }
110
+ },
111
+ total_item: proc { |items|
112
+ items.count
113
+ },
114
+ total_amount: proc { |items|
115
+ items.sum { |item|
116
+ item.experiences.sum { |experience|
117
+ experience.spans.sum { |span|
118
+ (span.amount * span.progress).to_i_if_whole
119
+ }
120
+ }
121
+ }
122
+ },
123
+ top_rating: proc { |items, number_arg|
124
+ items
125
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { _1.rating || 0}
126
+ .map { |item| [author_and_title(item), item.rating] }
127
+ },
128
+ top_length: proc { |items, number_arg|
129
+ items
130
+ .map { |item|
131
+ [
132
+ author_and_title(item),
133
+ # Longest length, or if undefined length then longest experience
134
+ # (code adapted from top_amount below).
135
+ item.variants.map(&:length).max ||
136
+ item.experiences.map { |experience|
137
+ experience.spans.sum { |span|
138
+ (span.amount * span.progress).to_i_if_whole
139
+ }
140
+ }.max,
141
+ ]
142
+ }
143
+ .reject { |_title, length| length.nil? }
144
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
145
+ },
146
+ top_amount: proc { |items, number_arg|
147
+ items
148
+ .map { |item|
149
+ amount = item.experiences.sum { |experience|
150
+ experience.spans.sum { |span|
151
+ (span.amount * span.progress).to_i_if_whole
152
+ }
153
+ }
154
+
155
+ [author_and_title(item), amount]
156
+ }
157
+ .reject { |_title, amount| amount.zero? }
158
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
159
+ },
160
+ top_speed: proc { |items, number_arg|
161
+ items
162
+ .map { |item| calculate_speed(item) }
163
+ .compact
164
+ .max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
165
+ speed_hash[:amount] / speed_hash[:days].to_f
166
+ }
167
+ },
168
+ bottom_rating: proc { |items, number_arg|
169
+ items
170
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { _1.rating || 0}
171
+ .map { |item| [author_and_title(item), item.rating] }
172
+ },
173
+ bottom_length: proc { |items, number_arg|
174
+ items
175
+ .map { |item|
176
+ [
177
+ author_and_title(item),
178
+ # Longest length, or if undefined length then longest experience
179
+ # (code adapted from bottom_amount below).
180
+ item.variants.map(&:length).max ||
181
+ item.experiences.map { |experience|
182
+ experience.spans.sum { |span|
183
+ (span.amount * span.progress).to_i_if_whole
184
+ }
185
+ }.max,
186
+ ]
187
+ }
188
+ .reject { |_title, length| length.nil? }
189
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
190
+ },
191
+ bottom_amount: proc { |items, number_arg|
192
+ items
193
+ .map { |item|
194
+ amount = item.experiences.sum { |experience|
195
+ experience.spans.sum { |span|
196
+ (span.amount * span.progress).to_i_if_whole
197
+ }
198
+ }
199
+
200
+ [author_and_title(item), amount]
201
+ }
202
+ .reject { |_title, amount| amount.zero? }
203
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
204
+ },
205
+ bottom_speed: proc { |items, number_arg|
206
+ items
207
+ .map { |item| calculate_speed(item) }
208
+ .compact
209
+ .min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
210
+ speed_hash[:amount] / speed_hash[:days].to_f
211
+ }
212
+ },
213
+ debug: proc { |items|
214
+ items
215
+ },
216
+ }
217
+
218
+ ALIASES = {
219
+ average_rating: %w[ar],
220
+ average_length: %w[al],
221
+ average_amount: %w[aia ai],
222
+ :"average_daily-amount" => %w[ada ad],
223
+ list_item: %w[li list],
224
+ total_item: %w[item count],
225
+ total_amount: %w[amount],
226
+ top_rating: %w[tr],
227
+ top_length: %w[tl],
228
+ top_amount: %w[ta],
229
+ top_speed: %w[ts],
230
+ bottom_rating: %w[br],
231
+ bottom_length: %w[bl],
232
+ bottom_amount: %w[ba],
233
+ bottom_speed: %w[bs],
234
+ debug: %w[d],
235
+ }
236
+
237
+ REGEXES = ACTIONS.map { |key, _action|
238
+ first_word, second_word = key.to_s.split('_')
239
+ aliases = ALIASES.fetch(key)
240
+
241
+ regex =
242
+ %r{
243
+ (
244
+ \A
245
+ \s*
246
+ #{first_word}
247
+ s?
248
+ \s*
249
+ (?<number_arg>
250
+ \d+
251
+ )?
252
+ \s*
253
+ (
254
+ #{second_word}
255
+ s?
256
+ )
257
+ \s*
258
+ )
259
+ |
260
+ (
261
+ \A
262
+ \s*
263
+ (#{aliases.join('|')})
264
+ s?
265
+ \s*
266
+ (?<number_arg>
267
+ \d+
268
+ )?
269
+ \s*
270
+ )
271
+ }x
272
+
273
+ [key, regex]
274
+ }.to_h
275
+
276
+ # Sums the given Items' amounts per date.
277
+ # @param items [Array<Item>]
278
+ # @return [Hash{Date => Numeric, Reading::Item::TimeLength}]
279
+ private_class_method def self.calculate_amounts_by_date(items)
280
+ amounts_by_date = {}
281
+
282
+ items.each do |item|
283
+ item.experiences.each do |experience|
284
+ experience.spans.each do |span|
285
+ next unless span.dates
286
+
287
+ dates = span.dates.begin..(span.dates.end || Date.today)
288
+
289
+ amount = span.amount / dates.count.to_f
290
+ progress = span.members.include?(:progress) ? span.progress : 1.0
291
+
292
+ dates.each do |date|
293
+ amounts_by_date[date] ||= 0
294
+ amounts_by_date[date] += amount * progress
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ amounts_by_date
301
+ end
302
+
303
+ # Calculates an Item's speed (total amount over how many days). Returns
304
+ # nil if a speed is not able to be calculated (e.g. in a planned Item).
305
+ # @param item [Item]
306
+ # @return [Array(String, Hash), nil]
307
+ private_class_method def self.calculate_speed(item)
308
+ speeds = item.experiences.map { |experience|
309
+ spans_with_finite_dates = experience.spans.reject { |span|
310
+ span.dates.nil? || span.dates.end.nil?
311
+ }
312
+ next unless spans_with_finite_dates.any?
313
+
314
+ amount = spans_with_finite_dates.sum { |span|
315
+ # Conditional in case Item was created with fragmentary experience hashes,
316
+ # as in stats_test.rb
317
+ progress = span.members.include?(:progress) ? span.progress : 1.0
318
+
319
+ span.amount * progress
320
+ }
321
+ .to_i_if_whole
322
+
323
+ days = spans_with_finite_dates.sum { |span| span.dates.count }.to_i
324
+
325
+ { amount:, days: }
326
+ }
327
+ .compact
328
+
329
+ return nil unless speeds.any?
330
+
331
+ speed = speeds
332
+ .max_by { |hash| hash[:amount] / hash[:days].to_f }
333
+
334
+ [author_and_title(item), speed]
335
+ end
336
+
337
+ # A shorter version of Item::View#name.
338
+ # @param item [Item]
339
+ # @return [String]
340
+ private_class_method def self.author_and_title(item)
341
+ "#{item.author + " – " if item.author}#{item.title}"
342
+ end
343
+ end
344
+ end
345
+ 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