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
data/lib/reading/item.rb CHANGED
@@ -1,9 +1,6 @@
1
- require "forwardable"
1
+ require 'forwardable'
2
2
 
3
- require_relative "item/view"
4
- require_relative "config"
5
- require_relative "util/hash_to_data"
6
- require_relative "util/hash_array_deep_fetch"
3
+ require_relative 'item/view'
7
4
 
8
5
  module Reading
9
6
  # A wrapper for an item parsed from a CSV reading log, providing convenience
@@ -15,28 +12,36 @@ module Reading
15
12
 
16
13
  ATTRIBUTES = %i[rating author title genres variants experiences notes]
17
14
 
18
- private attr_reader :attributes, :config
19
- attr_reader :view, :status, :last_end_date
15
+ private attr_reader :data
16
+ attr_reader :view
20
17
 
21
- def_delegators :attributes, *ATTRIBUTES
18
+ def_delegators :data, *ATTRIBUTES
22
19
 
23
- # @param item_hash [Hash] a parsed item like the template in
24
- # Config#default_config[:item][:template].
25
- # @param config [Hash] an entire config.
20
+ # @param item_hash_or_data [Hash, Data] a parsed item Hash like the template
21
+ # in Config#default_config[:item][:template]; or a Data from another Item.
26
22
  # @param view [Class, nil, Boolean] the class that will be used to build the
27
23
  # view object, or nil/false if no view object should be built. If you use
28
24
  # a custom view class, the only requirement is that its #initialize take
29
- # an Item and a full config as arguments.
30
- def initialize(item_hash, config: Config.new.hash, view: Item::View)
31
- item_hash = item_hash.dup
25
+ # an Item.
26
+ def initialize(item_hash_or_data, view: Item::View)
27
+ if item_hash_or_data.is_a? Data
28
+ @data = item_hash_or_data
29
+ elsif item_hash_or_data.is_a? Hash
30
+ item_hash = item_hash_or_data.dup
32
31
 
33
- add_missing_attributes_with_filler_values(item_hash, config)
32
+ add_missing_attributes_with_filler_values!(item_hash)
33
+ add_statuses_and_last_end_dates!(item_hash)
34
34
 
35
- @attributes = item_hash.to_data
36
- @config = config
35
+ @data = item_hash.to_data
36
+ end
37
+
38
+ @view = view.new(self) if view
39
+ end
37
40
 
38
- @status, @last_end_date = get_status_and_last_end_date
39
- @view = view.new(self, config) if view
41
+ # This item's status.
42
+ # @return [Symbol] :planned, :in_progress, or :done
43
+ def status
44
+ data.experiences.last&.status || :planned
40
45
  end
41
46
 
42
47
  # Whether this item is done.
@@ -45,11 +50,263 @@ module Reading
45
50
  status == :done
46
51
  end
47
52
 
48
- # Whether this item has a fixed length, such as a book or audiobook (as
49
- # opposed to an ongoing podcast).
50
- # @return [Boolean]
51
- def definite_length?
52
- attributes.variants.any? { |variant| !!variant.length }
53
+ # This item's last end date.
54
+ # @return [Date, nil]
55
+ def last_end_date
56
+ data.experiences.last&.last_end_date
57
+ end
58
+
59
+ # Returns a new Item containing shallow copy of @data, with its experiences
60
+ # replaced with new_experiences.
61
+ # @param new_experiences [Array<Data>]
62
+ # @param view [Class, nil, Boolean]
63
+ # @return [Item]
64
+ def with_experiences(new_experiences, view: false)
65
+ new_variants = variants.filter.with_index { |variant, old_index|
66
+ new_experiences.any? { _1.variant_index == old_index }
67
+ }
68
+
69
+ with_variants(
70
+ new_variants,
71
+ new_experiences:,
72
+ view:,
73
+ )
74
+ end
75
+
76
+ # Returns a new Item containing shallow copy of @data, with its variants
77
+ # replaced with new_variants.
78
+ # @param new_variants [Array<Data>]
79
+ # @param new_experiences [Array<Data>]
80
+ # @param view [Class, nil, Boolean]
81
+ # @return [Item]
82
+ def with_variants(new_variants, new_experiences: nil, view: false)
83
+ updated_variant_indices = []
84
+
85
+ # Map old to new indices, omitting those of variants that are not in new_variants.
86
+ variants.each.with_index { |variant, old_index|
87
+ new_index = new_variants.index(variant)
88
+ updated_variant_indices[old_index] = new_index if new_index
89
+ }
90
+
91
+ # Remove experiences associated with the removed variants.
92
+ kept_experiences = (new_experiences || experiences).select { |experience|
93
+ # Conditional in case Item was created with fragmentary experience hashes,
94
+ # as in stats_test.rb
95
+ variant_index = experience.variant_index if experience.members.include?(:variant_index)
96
+
97
+ !!updated_variant_indices[variant_index || 0]
98
+ }
99
+
100
+ # Then update the kept experiences' variant indices.
101
+ updated_kept_experiences = kept_experiences.map { |experience|
102
+ updated_variant_index = updated_variant_indices[experience.variant_index]
103
+ experience.with(variant_index: updated_variant_index)
104
+ }
105
+
106
+ self.class.new(
107
+ data.with(
108
+ variants: new_variants,
109
+ experiences: updated_kept_experiences,
110
+ ),
111
+ view:,
112
+ )
113
+ end
114
+
115
+ # Splits this Item into two Items: one < the given date, and the other >= it.
116
+ # @date [Date] must be the first day of a month.
117
+ # @return [Array(Item, Item)]
118
+ def split(date)
119
+ before_index = nil
120
+ middle_indices = experiences.map.with_index { |experience, i|
121
+ if experience.spans.first.dates &&
122
+ experience.spans.first.dates.begin < date
123
+
124
+ before_index = i
125
+
126
+ if (experience.last_end_date || Date.today) >= date
127
+ i
128
+ else
129
+ nil
130
+ end
131
+ end
132
+ }
133
+ .compact
134
+
135
+ # There are no experiences with spans that overlap the date.
136
+ if middle_indices.none?
137
+ # The Item is planned.
138
+ return [] if experiences.none? { _1.spans.first.dates }
139
+ # date is after all spans.
140
+ return [self, nil] if experiences.all? { _1.last_end_date && date > _1.last_end_date }
141
+ # date is before all spans.
142
+ return [nil, self] if experiences.all? { _1.spans.first.dates.begin >= date }
143
+
144
+ # Date is in between experiences.
145
+ if before_index
146
+ item_before = with_experiences(experiences[..before_index])
147
+ item_after = with_experiences(experiences[(before_index + 1)..])
148
+
149
+ return [item_before, item_after]
150
+ end
151
+ end
152
+
153
+ if middle_indices.first == 0
154
+ experiences_before = []
155
+ else
156
+ experiences_before = experiences[..(middle_indices.first - 1)]
157
+ end
158
+ experiences_after = experiences[(middle_indices.first + middle_indices.count)..]
159
+ experiences_middle = experiences.values_at(*middle_indices)
160
+
161
+ # TODO remove this check?
162
+ unless middle_indices == (middle_indices.min..middle_indices.max).to_a
163
+ raise Reading::Error, "Non-consecutive experiences found during Item#split."
164
+ end
165
+
166
+ experiences_middle.each do |experience_middle|
167
+ before_index = nil
168
+ span_middle_index = experience_middle
169
+ .spans
170
+ .index.with_index { |span, i|
171
+ if span.dates && span.dates.begin < date
172
+ before_index = i
173
+
174
+ (span.dates.end || Date.today) >= date
175
+ end
176
+ }
177
+
178
+ if span_middle_index.nil? # date is between spans.
179
+ spans_before = experience_middle.spans[..before_index]
180
+ spans_after = experience_middle.spans[(before_index + 1)..]
181
+ else
182
+ span_middle = experience_middle.spans[span_middle_index]
183
+
184
+ unless span_middle.dates.end
185
+ end_today_instead_of_endless = { dates: span_middle.dates.begin..Date.today }
186
+ span_middle = span_middle.to_h.merge(end_today_instead_of_endless).to_data
187
+ end
188
+
189
+ dates_before = span_middle.dates.begin..date.prev_day
190
+ amount_before = (span_middle.amount || 0) * (dates_before.count / span_middle.dates.count.to_f)
191
+ span_middle_before = span_middle.with(
192
+ dates: dates_before,
193
+ amount: amount_before,
194
+ )
195
+
196
+ dates_after = date..span_middle.dates.end
197
+ amount_after = (span_middle.amount || 0) * (dates_after.count / span_middle.dates.count.to_f)
198
+ span_middle_after = span_middle.with(
199
+ dates: dates_after,
200
+ amount: amount_after,
201
+ )
202
+
203
+ if span_middle_index.zero?
204
+ spans_before = [span_middle_before]
205
+ else
206
+ spans_before = [
207
+ *experience_middle.spans[..(span_middle_index - 1)],
208
+ span_middle_before,
209
+ ]
210
+ end
211
+
212
+ spans_after = [
213
+ span_middle_after,
214
+ *experience_middle.spans[(span_middle_index + 1)..],
215
+ ]
216
+ end
217
+
218
+ experience_middle_before = experience_middle.with(
219
+ spans: spans_before,
220
+ last_end_date: spans_before.map { _1.dates&.end }.compact.last,
221
+ )
222
+ experience_middle_after = experience_middle.with(
223
+ spans: spans_after,
224
+ )
225
+
226
+ experiences_before << experience_middle_before
227
+ experiences_after.unshift(*experience_middle_after)
228
+ end
229
+
230
+ # RM (alternate implementation)
231
+ # experiences_before = experiences
232
+ # .select(&:last_end_date)
233
+ # .select { _1.last_end_date < date }
234
+ # experiences_after = experiences
235
+ # .select { _1.spans.first.dates.nil? || _1.spans.first.dates.begin >= date }
236
+
237
+ # experiences_middle = experiences.select {
238
+ # _1.spans.first.dates.begin < date && _1.last_end_date >= date
239
+ # }
240
+ # experiences_middle.each do |experience_middle|
241
+ # spans_before = experience_middle
242
+ # .spans
243
+ # .select { _1.dates&.end }
244
+ # .select { _1.dates.end < date }
245
+ # spans_after = experience_middle
246
+ # .spans
247
+ # .select(&:dates)
248
+ # .select { _1.dates.begin >= date }
249
+
250
+ # span_middle = experience_middle
251
+ # .spans
252
+ # .find { _1.dates && _1.dates.begin < date && _1.dates.end >= date }
253
+
254
+ # middle_index = experience_middle.spans.index(span_middle)
255
+ # planned_spans_before = experience_middle
256
+ # .spans
257
+ # .map.with_index { |span, i|
258
+ # [i, span] if span.dates.nil? && i < middle_index
259
+ # }
260
+ # .compact
261
+ # planned_spans_after = experience_middle
262
+ # .spans
263
+ # .map.with_index { |span, i|
264
+ # [i, span] if span.dates.nil? && i > middle_index
265
+ # }
266
+ # .compact
267
+
268
+ # if span_middle
269
+ # dates_before = span_middle.dates.begin..date.prev_day
270
+ # amount_before = span_middle.amount * (dates_before.count / span_middle.dates.count.to_f)
271
+ # span_middle_before = span_middle.with(
272
+ # dates: dates_before,
273
+ # amount: amount_before,
274
+ # )
275
+
276
+ # dates_after = date..span_middle.dates.end
277
+ # amount_after = span_middle.amount * (dates_after.count / span_middle.dates.count.to_f)
278
+ # span_middle_after = span_middle.with(
279
+ # dates: dates_after,
280
+ # amount: amount_after,
281
+ # )
282
+
283
+ # spans_before = [*spans_before, span_middle_before]
284
+ # spans_after = [span_middle_after, *spans_after]
285
+
286
+ # planned_spans_before.each do |i, planned_span|
287
+ # spans_before.insert(i, planned_span)
288
+ # end
289
+ # planned_spans_after.each do |i, planned_span|
290
+ # spans_after.insert(i - middle_index, planned_span)
291
+ # end
292
+ # end
293
+
294
+ # experience_middle_before = experience_middle.with(
295
+ # spans: spans_before,
296
+ # last_end_date: spans_before.last.dates.end,
297
+ # )
298
+ # experience_middle_after = experience_middle.with(
299
+ # spans: spans_after,
300
+ # )
301
+
302
+ # experiences_before << experience_middle_before
303
+ # experiences_after = [experience_middle_after, *experiences_after]
304
+ # end
305
+
306
+ item_before = with_experiences(experiences_before)
307
+ item_after = with_experiences(experiences_after)
308
+
309
+ [item_before, item_after]
53
310
  end
54
311
 
55
312
  # Equality to another Item.
@@ -60,17 +317,16 @@ module Reading
60
317
  raise ArgumentError, "An Item can be compared only with another Item."
61
318
  end
62
319
 
63
- attributes == other.send(:attributes)
320
+ data == other.send(:data)
64
321
  end
65
322
 
66
323
  private
67
324
 
68
- # For each missing item attribute (key in config[:item][:template]) in
325
+ # For each missing item attribute (key in Config.hash[:item][:template]) in
69
326
  # item_hash, adds the key and a filler value.
70
327
  # @param item_hash [Hash]
71
- # @param config [Hash] an entire config.
72
- def add_missing_attributes_with_filler_values(item_hash, config)
73
- config.deep_fetch(:item, :template).each do |k, v|
328
+ def add_missing_attributes_with_filler_values!(item_hash)
329
+ Config.hash.deep_fetch(:item, :template).each do |k, v|
74
330
  next if item_hash.has_key?(k)
75
331
 
76
332
  filler = v.is_a?(Array) ? [] : nil
@@ -78,39 +334,53 @@ module Reading
78
334
  end
79
335
  end
80
336
 
81
- # Determines the status and the last end date. Note: for an item of indefinite
82
- # length (e.g. podcast) there is a grace period during which the status
83
- # remains :in_progress after the last activity. If that grace period is over,
84
- # the status is :done. It's :planned if there are no spans with dates.
337
+ # Determines the status and the last end date of each experience, and adds
338
+ # it into the experience hash. Note: for an item of indefinite length (e.g.
339
+ # podcast) there is a grace period during which the status remains
340
+ # :in_progress after the last activity. If that grace period is over, the
341
+ # status is :done. It's :planned if there are no spans with dates.
342
+ # @param item_hash [Hash]
85
343
  # @return [Array(Symbol, Date)]
86
- def get_status_and_last_end_date
87
- return [:planned, nil] if experiences.none? || experiences.flat_map(&:spans).none?
344
+ def add_statuses_and_last_end_dates!(item_hash)
345
+ item_hash[:experiences] = item_hash[:experiences].dup
88
346
 
89
- experiences_with_spans_with_dates = experiences
90
- .select { |experience| experience.spans.any? { |span| span.dates } }
347
+ item_hash[:experiences].each do |experience|
348
+ experience[:status] = :planned
349
+ experience[:last_end_date] = nil
91
350
 
92
- return [:planned, nil] unless experiences_with_spans_with_dates.any?
351
+ next unless experience[:spans]&.any? { |span|
352
+ span[:dates]
353
+ }
93
354
 
94
- last_end_date = experiences_with_spans_with_dates
95
- .last
96
- .spans
97
- .select { |span| span.dates }
98
- .last
99
- .dates
100
- .end
355
+ experience[:status] = :in_progress
101
356
 
102
- return [:in_progress, nil] unless last_end_date
357
+ experience[:last_end_date] = experience[:spans]
358
+ .select { |span| span[:dates] }
359
+ .last[:dates]
360
+ .end
103
361
 
104
- if definite_length?
105
- [:done, last_end_date]
106
- else
107
- grace_period = config.deep_fetch(:item, :indefinite_in_progress_grace_period_days)
108
- indefinite_in_progress_grace_period_is_over =
109
- (Date.today - grace_period) > last_end_date
362
+ next unless experience[:last_end_date]
363
+
364
+ # Whether this item has a fixed length, such as a book or audiobook (as
365
+ # opposed to e.g. an ongoing podcast).
366
+ has_definite_length =
367
+ !!item_hash[:variants][experience[:variant_index] || 0]&.dig(:length)
368
+
369
+ if has_definite_length
370
+ experience[:status] = :done
371
+ else
372
+ grace_period = Config.hash.deep_fetch(
373
+ :item,
374
+ :indefinite_in_progress_grace_period_days,
375
+ )
110
376
 
111
- return [:done, last_end_date] if indefinite_in_progress_grace_period_is_over
377
+ indefinite_in_progress_grace_period_is_over =
378
+ (Date.today - grace_period) > experience[:last_end_date]
112
379
 
113
- [:in_progress, last_end_date]
380
+ if indefinite_in_progress_grace_period_is_over
381
+ experience[:status] = :done
382
+ end
383
+ end
114
384
  end
115
385
  end
116
386
  end
@@ -6,13 +6,6 @@ module Reading
6
6
  # parsed row (an intermediate hash) into item attributes, as in
7
7
  # Config#default_config[:item][:template].
8
8
  class Attribute
9
- private attr_reader :config
10
-
11
- # @param config [Hash] an entire config.
12
- def initialize(config)
13
- @config = config
14
- end
15
-
16
9
  # Extracts this attribute's value from a parsed row.
17
10
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
18
11
  # @param head_index [Integer] current item's position in the Head column.
@@ -1,4 +1,4 @@
1
- require_relative "spans_validator"
1
+ require_relative 'spans_validator'
2
2
 
3
3
  module Reading
4
4
  module Parsing
@@ -10,13 +10,11 @@ module Reading
10
10
  class DatesAndHeadTransformer
11
11
  using Util::HashArrayDeepFetch
12
12
 
13
- private attr_reader :config, :parsed_row, :head_index
13
+ private attr_reader :parsed_row, :head_index
14
14
 
15
15
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
16
16
  # @param head_index [Integer] current item's position in the Head column.
17
- # @param config [Hash] an entire config.
18
- def initialize(parsed_row, head_index, config)
19
- @config = config
17
+ def initialize(parsed_row, head_index)
20
18
  @parsed_row = parsed_row
21
19
  @head_index = head_index
22
20
  end
@@ -31,7 +29,10 @@ module Reading
31
29
  start_dates = Array.new(size) { |i| parsed_row[:start_dates]&.dig(i) || {} }
32
30
  end_dates = Array.new(size) { |i| parsed_row[:end_dates]&.dig(i) || nil }
33
31
 
34
- start_end_dates = start_dates.zip(end_dates).presence || [[{}, nil]]
32
+ start_end_dates = start_dates
33
+ .reject { _1[:planned] }
34
+ .zip(end_dates)
35
+ .presence || [[{}, nil]]
35
36
 
36
37
  experiences_with_dates = start_end_dates.map { |start_entry, end_entry|
37
38
  {
@@ -42,8 +43,7 @@ module Reading
42
43
  }.presence
43
44
 
44
45
  if experiences_with_dates
45
- # Raises an error if any sequence of dates does not make sense.
46
- Experiences::SpansValidator.validate(experiences_with_dates, config)
46
+ Experiences::SpansValidator.validate(experiences_with_dates)
47
47
  end
48
48
 
49
49
  experiences_with_dates
@@ -54,13 +54,13 @@ module Reading
54
54
  # A shortcut to the experience template.
55
55
  # @return [Hash]
56
56
  def template
57
- config.deep_fetch(:item, :template, :experiences).first
57
+ Config.hash.deep_fetch(:item, :template, :experiences).first
58
58
  end
59
59
 
60
60
  # A shortcut to the span template.
61
61
  # @return [Hash]
62
62
  def span_template
63
- config.deep_fetch(:item, :template, :experiences, 0, :spans).first
63
+ Config.hash.deep_fetch(:item, :template, :experiences, 0, :spans).first
64
64
  end
65
65
 
66
66
  # The :spans sub-attribute for the given pair of date entries.
@@ -84,14 +84,18 @@ module Reading
84
84
  end
85
85
 
86
86
  variant_index = (start_entry[:variant] || 1).to_i - 1
87
- length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index)) ||
88
- Attributes::Shared.length(parsed_row[:length])
87
+ format = parsed_row[:sources]&.dig(variant_index)&.dig(:format) ||
88
+ parsed_row[:head][head_index][:format]
89
+ length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index), format:) ||
90
+ Attributes::Shared.length(parsed_row[:length], format:)
91
+ no_end_date = !dates.end if dates &&
92
+ Config.hash.fetch(:enabled_columns).include?(:end_dates)
89
93
 
90
94
  [
91
95
  {
92
96
  dates: dates,
93
97
  amount: (length if dates),
94
- progress: Attributes::Shared.progress(start_entry) ||
98
+ progress: Attributes::Shared.progress(start_entry, no_end_date:) ||
95
99
  Attributes::Shared.progress(parsed_row[:head][head_index]) ||
96
100
  (1.0 if end_entry),
97
101
  name: span_template.fetch(:name),