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
@@ -1,5 +1,16 @@
1
- require_relative "spans_validator"
2
-
1
+ require_relative 'spans_validator'
2
+
3
+ # TODO Refactor! This entire file has become 🤢🤮 with the accumulation of new
4
+ # features in the History column.
5
+ #
6
+ # Goals of the refactor:
7
+ # - if possible, avoid daily_spans; build spans with date ranges directly.
8
+ # - validate spans at every step; that way the origin of bugs will be easier
9
+ # to find, e.g. for the bug fixed in 6310639, spans became invalid in
10
+ # #fix_open_ranges! and led to an error elsewhere that didn't give a trace
11
+ # back to the origin.
12
+ # - to facilitate the points above, create a class ExperienceBuilder to
13
+ # contain much of the logic that is currently in this file.
3
14
  module Reading
4
15
  module Parsing
5
16
  module Attributes
@@ -16,13 +27,14 @@ module Reading
16
27
  # many days, for example.
17
28
  AVERAGE_DAYS_IN_A_MONTH = 30.437r
18
29
 
19
- private attr_reader :parsed_row, :config
30
+ private attr_reader :parsed_row, :head_index, :next_open_range_id
20
31
 
21
32
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
22
- # @param config [Hash] an entire config
23
- def initialize(parsed_row, config)
33
+ # @param head_index [Integer] current item's position in the Head column.
34
+ def initialize(parsed_row, head_index)
24
35
  @parsed_row = parsed_row
25
- @config = config
36
+ @head_index = head_index
37
+ @next_open_range_id = 0
26
38
  end
27
39
 
28
40
  # Extracts experiences from the parsed row.
@@ -37,8 +49,7 @@ module Reading
37
49
  }
38
50
  }
39
51
 
40
- # Raises an error if experiences overlap or are out of order.
41
- Experiences::SpansValidator.validate(experiences, config, history_column: true)
52
+ Experiences::SpansValidator.validate(experiences, history_column: true)
42
53
 
43
54
  experiences
44
55
  end
@@ -48,7 +59,7 @@ module Reading
48
59
  # A shortcut to the span template.
49
60
  # @return [Hash]
50
61
  def span_template
51
- @span_template ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
62
+ @span_template ||= Config.hash.deep_fetch(:item, :template, :experiences, 0, :spans).first
52
63
  end
53
64
 
54
65
  # The :spans sub-attribute for the given History column entries.
@@ -62,16 +73,21 @@ module Reading
62
73
  month: nil,
63
74
  day: nil,
64
75
  after_single_date: false,
65
- open_range: false,
76
+ open_range_id: nil,
66
77
  planned: false,
67
78
  amount: nil,
79
+ repetitions: nil,
80
+ frequency: nil,
68
81
  last_start_year: nil,
69
82
  last_start_month: nil,
70
83
  }
71
84
 
85
+ # Dates after "not" entries.
86
+ except_dates = []
87
+
72
88
  entries.each do |entry|
73
89
  if entry[:except_dates]
74
- reject_exception_dates!(entry, daily_spans, active)
90
+ except_dates += reject_exception_dates!(entry, daily_spans, active)
75
91
  next
76
92
  end
77
93
 
@@ -80,10 +96,14 @@ module Reading
80
96
 
81
97
  spans = merge_daily_spans(daily_spans)
82
98
 
83
- fix_open_ranges!(spans)
99
+ spans = fix_open_ranges(spans, except_dates)
84
100
 
85
101
  relativize_amounts_from_progress!(spans)
86
102
 
103
+ remove_last_end_date_of_today_if_open_range!(spans)
104
+
105
+ remove_temporary_keys!(spans)
106
+
87
107
  spans
88
108
  end
89
109
 
@@ -93,6 +113,7 @@ module Reading
93
113
  # date-and-name combination.
94
114
  # @param active [Hash] variables that persist across entries, such as
95
115
  # amount and implied date.
116
+ # @return [Array<Date>] the rejected dates.
96
117
  def reject_exception_dates!(entry, daily_spans, active)
97
118
  except_active = {
98
119
  year: active[:last_start_year],
@@ -123,6 +144,8 @@ module Reading
123
144
  daily_spans.reject! do |(date, _name), _span|
124
145
  except_dates.include?(date)
125
146
  end
147
+
148
+ except_dates
126
149
  end
127
150
 
128
151
  # Expands the given entry into one span per day, then adds them to daily_spans.
@@ -146,7 +169,7 @@ module Reading
146
169
  active[:month] = start_month if start_month
147
170
  active[:last_start_month] = active[:month]
148
171
  if start_day
149
- active[:open_range] = false
172
+ active[:open_range_id] = nil
150
173
  active[:day] = start_day
151
174
  end
152
175
 
@@ -154,8 +177,14 @@ module Reading
154
177
  raise InvalidHistoryError, "Missing or incomplete first date"
155
178
  end
156
179
 
157
- duplicate_open_range = !start_day && active[:open_range]
158
- date_range = date_range(entry, active, duplicate_open_range:)
180
+ if entry[:planned] || (active[:planned] && !start_day)
181
+ active[:planned] = true
182
+ elsif active[:planned] && start_day
183
+ active[:planned] = false
184
+ end
185
+
186
+ duplicate_open_range_id = active[:open_range_id] if !start_day
187
+ date_range = date_range(entry, active, duplicate_open_range: !!duplicate_open_range_id)
159
188
 
160
189
  # A startless date range (i.e. with an implied start date) appearing
161
190
  # immediately after a single date has its start date bumped forward
@@ -165,20 +194,26 @@ module Reading
165
194
  end
166
195
  active[:after_single_date] = !date_range
167
196
 
168
- amount =
169
- Attributes::Shared.length(entry, key_name: :amount, ignore_repetitions: true) ||
170
- Attributes::Shared.length(parsed_row[:length], episodic: true)
171
- active[:amount] = amount if amount
197
+ variant_index = (entry[:variant_index] || 1).to_i - 1
198
+ format = parsed_row[:sources]&.dig(variant_index)&.dig(:format) ||
199
+ parsed_row[:head][head_index][:format]
172
200
 
173
201
  progress = Attributes::Shared.progress(entry)
174
202
 
203
+ amount_from_entry =
204
+ Attributes::Shared.length(entry, format:, key_name: :amount, ignore_repetitions: true)
205
+ amount_from_length =
206
+ Attributes::Shared.length(parsed_row[:length], format:, episodic: progress.nil? || parsed_row.dig(:length, :repetitions).nil?)
207
+ amount = amount_from_entry || amount_from_length
208
+ active[:amount] = amount if amount
209
+
175
210
  # If the entry has no amount and the item has no episodic length,
176
211
  # then use progress as amount instead. The typical scenario for this
177
212
  # is when tracking fixed-length items such as books. See
178
213
  # https://github.com/fpsvogel/reading/blob/main/doc/csv-format.md#history-pages-and-stopping-points-books
179
214
  if !amount && progress
180
215
  if progress.is_a? Float
181
- total_length = Attributes::Shared.length(parsed_row[:length])
216
+ total_length = Attributes::Shared.length(parsed_row[:length], format:)
182
217
  amount = total_length * progress
183
218
  else
184
219
  amount = progress
@@ -186,44 +221,56 @@ module Reading
186
221
  amount_from_progress = true
187
222
  end
188
223
 
189
- repetitions = entry[:repetitions]&.to_i || 1
224
+ repetitions = entry[:repetitions]&.to_i
190
225
  frequency = entry[:frequency]
191
226
 
227
+ # If the entry has no amount or progress, default to the previous
228
+ # repetitions and frequency.
229
+ unless amount_from_entry || progress || repetitions
230
+ repetitions = active[:repetitions]
231
+ frequency = active[:frequency]
232
+ end
233
+
234
+ active[:repetitions] = repetitions if repetitions
235
+ active[:frequency] = frequency if frequency
236
+
192
237
  amounts_by_date = distribute_amount_across_date_range(
193
238
  date_range || Date.new(active[:year], active[:month], active[:day]),
194
239
  amount || active[:amount],
195
- repetitions,
240
+ repetitions || 1,
196
241
  frequency,
197
242
  )
198
243
 
199
- in_open_range = active[:open_range] || duplicate_open_range
244
+ open_range_id = active[:open_range_id] || duplicate_open_range_id
200
245
 
201
246
  daily_spans_from_entry = amounts_by_date.map { |date, daily_amount|
202
247
  span_without_dates = {
203
248
  dates: nil,
204
249
  amount: daily_amount || span_template[:amount],
205
- progress: (progress unless amount_from_progress) || span_template[:progress],
250
+ progress: (progress unless amount_from_progress) ||
251
+ (0.0 if entry[:planned] || active[:planned]) ||
252
+ span_template[:progress],
206
253
  name: entry[:name] || span_template[:name],
207
254
  favorite?: !!entry[:favorite] || span_template[:favorite?],
208
255
  # Temporary keys (not in the final item data) for marking
209
256
  # spans to ...
210
257
  # ... be distributed evenly across an open date range.
211
- in_open_range: in_open_range,
258
+ open_range_id:,
212
259
  # ... have their amounts adjusted to be relative to previous progress.
213
- amount_from_progress: amount_from_progress,
260
+ amount_from_progress?: amount_from_progress,
261
+ amount_from_frequency?: !!frequency,
262
+ implied_date_range?: !date_range && !!frequency,
214
263
  }
215
264
 
216
- if entry[:planned] || (active[:planned] && !start_day)
265
+ if entry[:planned] || active[:planned]
217
266
  date = nil
218
- active[:planned] = true
219
267
  end
220
268
 
221
269
  key = [date, span_without_dates[:name]]
222
270
 
223
- # When any entry in an open range lacks a name, add a random
224
- # number to the key so that it does not overwrite a different
225
- # entry in the open range that also lacks a name.
226
- if in_open_range && !entry[:name]
271
+ # For entries in an open range, add a random number to the key to
272
+ # avoid overwriting entries with the same name, or lacking a name.
273
+ if open_range_id
227
274
  key << rand
228
275
  end
229
276
 
@@ -276,7 +323,7 @@ module Reading
276
323
  return nil unless entry[:range] || duplicate_open_range
277
324
 
278
325
  if entry[:end_day]
279
- active[:open_range] = false
326
+ active[:open_range_id] = nil
280
327
 
281
328
  end_year = entry[:end_year]&.to_i
282
329
  end_month = entry[:end_month]&.to_i
@@ -290,11 +337,17 @@ module Reading
290
337
  date_range = Date.new(active[:year], active[:month], active[:day])..
291
338
  Date.new(end_year || active[:year], end_month || active[:month], end_day)
292
339
 
293
- active[:day] = end_day + 1
294
- active[:month] = end_month if end_month
295
- active[:year] = end_year if end_year
340
+ date_after_end = date_range.end.next_day
341
+
342
+ active[:day] = date_after_end.day
343
+ active[:month] = date_after_end.month if end_month
344
+ active[:year] = date_after_end.year if end_year
296
345
  else # either starting or continuing (duplicating) an open range
297
- active[:open_range] ||= true
346
+ unless active[:open_range_id]
347
+ active[:open_range_id] = next_open_range_id
348
+ @next_open_range_id += 1
349
+ end
350
+
298
351
  date_range = Date.new(active[:year], active[:month], active[:day])..Date.today
299
352
  end
300
353
 
@@ -307,7 +360,7 @@ module Reading
307
360
  # @param amount [Float, Integer, Item::TimeLength] amount in
308
361
  # pages or time.
309
362
  # @param repetitions [Integer] e.g. "x4" in a History entry.
310
- # @param frequency [Integer] e.g. "/week" in a History entry.
363
+ # @param frequency [String] e.g. "/week" in a History entry.
311
364
  # @return [Hash{Date => Float, Integer, Item::TimeLength}]
312
365
  def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
313
366
  unless amount
@@ -357,33 +410,64 @@ module Reading
357
410
  # Set each open date range's last end date (wherever it's today, i.e.
358
411
  # it wasn't defined) to the day before the next entry's start date.
359
412
  # At the same time, distribute each open range's spans evenly.
360
- # Lastly, remove the :in_open_range key from spans.
413
+ # Lastly, remove the :open_range_id key from spans.
361
414
  # @param spans [Array<Hash>] spans after being merged from daily_spans.
415
+ # @param except_dates [Date] dates after "not" entries which were
416
+ # rejected from spans.
362
417
  # @return [Array<Hash>]
363
- def fix_open_ranges!(spans)
418
+ def fix_open_ranges(spans, except_dates)
364
419
  chunked_by_open_range = spans.chunk_while { |a, b|
365
420
  a[:dates] && b[:dates] && # in case of planned entry
366
- a[:dates].begin == b[:dates].begin &&
367
- a[:in_open_range] == b[:in_open_range]
421
+ a[:open_range_id] == b[:open_range_id]
368
422
  }
369
423
 
370
- next_chunk_start_date = nil
424
+ next_start_date = nil
371
425
  chunked_by_open_range
372
- .reverse_each { |chunk|
373
- unless chunk.first[:in_open_range] && chunk.any? { _1[:dates].end == Date.today }
426
+ .to_a.reverse.map { |chunk|
427
+ unless chunk.first[:open_range_id]
374
428
  # safe nav. in case of planned entry
375
- next_chunk_start_date = chunk.first[:dates]&.begin
376
- next
429
+ next_start_date = chunk.first[:dates]&.begin
430
+ next chunk
431
+ end
432
+
433
+ # Filter out spans that begin after the next chunk's start date.
434
+ if next_start_date
435
+ chunk.reject! do |span|
436
+ span[:dates].begin >= next_start_date
437
+ end
438
+ end
439
+
440
+ # For the remaining spans (which begin before the next chunk's
441
+ # start date), bound each end date to that date.
442
+ chunk.reverse_each do |span|
443
+ if !next_start_date
444
+ next_start_date = span[:dates].begin
445
+ next
446
+ end
447
+
448
+ if span[:dates].end >= next_start_date
449
+ new_dates = span[:dates].begin..next_start_date.prev_day
450
+
451
+ if span[:amount_from_frequency?]
452
+ new_to_old_dates_ratio = new_dates.count / span[:dates].count.to_f
453
+ span[:amount] = (span[:amount] * new_to_old_dates_ratio).to_i_if_whole
454
+ end
455
+
456
+ span[:dates] = new_dates
457
+ end
458
+
459
+ next_start_date = span[:dates].begin if span[:dates].begin < next_start_date
377
460
  end
378
461
 
379
- # Set last end date.
380
- if chunk.last[:dates].end == Date.today && next_chunk_start_date
381
- chunk.last[:dates] = chunk.last[:dates].begin..next_chunk_start_date.prev_day
462
+ if !next_start_date || chunk.first[:dates].begin < next_start_date
463
+ next_start_date = chunk.first[:dates].begin
382
464
  end
383
- next_chunk_start_date = chunk.first[:dates].begin
465
+
466
+ next chunk if chunk.map { |span| span[:open_range_id] }.uniq.count == 1 &&
467
+ chunk.map { |span| span[:dates].begin }.uniq.count > 1
384
468
 
385
469
  # Distribute spans across the open date range.
386
- total_amount = chunk.sum { |c| c[:amount] }
470
+ total_amount = chunk.sum { |span| span[:amount] }
387
471
  dates = chunk.last[:dates]
388
472
  amount_per_day = total_amount / dates.count.to_f
389
473
 
@@ -391,6 +475,7 @@ module Reading
391
475
  last_end_date = chunk.last[:dates].end
392
476
 
393
477
  span = nil
478
+ chunk_dup = chunk.dup
394
479
  amount_acc = 0
395
480
  span_needing_end = nil
396
481
  dates.each do |date|
@@ -400,8 +485,8 @@ module Reading
400
485
  end
401
486
 
402
487
  while amount_acc < amount_per_day
403
- break if chunk.empty?
404
- span = chunk.shift
488
+ break if chunk_dup.empty?
489
+ span = chunk_dup.shift
405
490
  amount_acc += span[:amount]
406
491
 
407
492
  if amount_acc < amount_per_day
@@ -418,11 +503,11 @@ module Reading
418
503
  end
419
504
 
420
505
  span[:dates] = span[:dates].begin..last_end_date
421
- }
422
506
 
423
- spans.each do |span|
424
- span.delete(:in_open_range)
425
- end
507
+ chunk
508
+ }
509
+ .reverse
510
+ .flatten
426
511
  end
427
512
 
428
513
  # Changes amounts taken from progress, from absolute to relative,
@@ -434,15 +519,42 @@ module Reading
434
519
  def relativize_amounts_from_progress!(spans)
435
520
  amount_acc = 0
436
521
  spans.each do |span|
437
- if span[:amount_from_progress]
522
+ if span[:amount_from_progress?]
438
523
  span[:amount] -= amount_acc
439
524
  end
440
525
 
441
526
  amount_acc += span[:amount]
442
527
  end
528
+ end
529
+
530
+ # Removes the end date from the last span if it's today, and if it was
531
+ # written as an open range.
532
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
533
+ # @return [Array<Hash>]
534
+ def remove_last_end_date_of_today_if_open_range!(spans)
535
+ if spans.last[:dates] &&
536
+ spans.last[:dates].end == Date.today &&
537
+ (spans.last[:open_range_id] || spans.last[:implied_date_range?])
538
+
539
+ spans.last[:dates] = spans.last[:dates].begin..
540
+ end
541
+ end
542
+
543
+ # Removes all keys that shouldn't be in the final item data.
544
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
545
+ # @return [Array<Hash>]
546
+ def remove_temporary_keys!(spans)
547
+ temporary_keys = %i[
548
+ open_range_id
549
+ amount_from_progress?
550
+ amount_from_frequency?
551
+ implied_date_range?
552
+ ]
443
553
 
444
554
  spans.each do |span|
445
- span.delete(:amount_from_progress)
555
+ temporary_keys.each do |key|
556
+ span.delete(key)
557
+ end
446
558
  end
447
559
  end
448
560
  end
@@ -11,24 +11,23 @@ module Reading
11
11
  # Checks the dates in the given experiences hash, and raises an error
12
12
  # at the first invalid date found.
13
13
  # @param experiences [Array<Hash>] experience hashes.
14
- # @param config [Hash] an entire config.
15
14
  # @param history_column [Boolean] whether this validation is for
16
15
  # experiences from the History column.
17
16
  # @raise [InvalidDateError] if any date is invalid.
18
- def validate(experiences, config, history_column: false)
19
- if both_date_columns?(config)
17
+ def validate(experiences, history_column: false)
18
+ if both_date_columns? && !history_column
20
19
  validate_number_of_start_dates_and_end_dates(experiences)
21
20
  end
22
21
 
23
- if start_dates_column?(config) || history_column
22
+ if start_dates_column? || history_column
24
23
  validate_start_dates_are_in_order(experiences)
25
24
  end
26
25
 
27
- if end_dates_column?(config) || history_column
26
+ if end_dates_column? || history_column
28
27
  validate_end_dates_are_in_order(experiences)
29
28
  end
30
29
 
31
- if both_date_columns?(config) || history_column
30
+ if both_date_columns? || history_column
32
31
  validate_experiences_of_same_variant_do_not_overlap(experiences)
33
32
  end
34
33
 
@@ -39,20 +38,20 @@ module Reading
39
38
 
40
39
  # Whether the Start Dates column is enabled.
41
40
  # @return [Boolean]
42
- def start_dates_column?(config)
43
- config.fetch(:enabled_columns).include?(:start_dates)
41
+ def start_dates_column?
42
+ Config.hash.fetch(:enabled_columns).include?(:start_dates)
44
43
  end
45
44
 
46
45
  # Whether the End Dates column is enabled.
47
46
  # @return [Boolean]
48
- def end_dates_column?(config)
49
- config.fetch(:enabled_columns).include?(:end_dates)
47
+ def end_dates_column?
48
+ Config.hash.fetch(:enabled_columns).include?(:end_dates)
50
49
  end
51
50
 
52
51
  # Whether both the Start Dates and End Dates columns are enabled.
53
52
  # @return [Boolean]
54
- def both_date_columns?(config)
55
- start_dates_column?(config) && end_dates_column?(config)
53
+ def both_date_columns?
54
+ start_dates_column? && end_dates_column?
56
55
  end
57
56
 
58
57
  # Raises an error if there are more end dates than start dates, or
@@ -60,7 +59,7 @@ module Reading
60
59
  # @raise [InvalidDateError]
61
60
  def validate_number_of_start_dates_and_end_dates(experiences)
62
61
  _both_dates, not_both_dates = experiences
63
- .filter { |exp| exp[:spans].first&.dig(:dates) }
62
+ .select { |exp| exp[:spans].first&.dig(:dates) }
64
63
  .map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
65
64
  .partition { |start_date, end_date| start_date && end_date }
66
65
 
@@ -76,7 +75,7 @@ module Reading
76
75
  # @raise [InvalidDateError]
77
76
  def validate_start_dates_are_in_order(experiences)
78
77
  experiences
79
- .filter { |exp| exp[:spans].first&.dig(:dates) }
78
+ .select { |exp| exp[:spans].first&.dig(:dates) }
80
79
  .map { |exp| exp[:spans].first[:dates].begin }
81
80
  .each_cons(2) do |a, b|
82
81
  if (a.nil? && b.nil?) || (a && b && a > b )
@@ -89,8 +88,8 @@ module Reading
89
88
  # @raise [InvalidDateError]
90
89
  def validate_end_dates_are_in_order(experiences)
91
90
  experiences
92
- .filter { |exp| exp[:spans].first&.dig(:dates) }
93
- .map { |exp| exp[:spans].last[:dates].end }
91
+ .select { |exp| exp[:spans].first&.dig(:dates) }
92
+ .map { |exp| exp[:spans].last[:dates]&.end }
94
93
  .each_cons(2) do |a, b|
95
94
  if (a.nil? && b.nil?) || (a && b && a > b )
96
95
  raise InvalidDateError, "End dates are not in order"
@@ -104,7 +103,7 @@ module Reading
104
103
  experiences
105
104
  .group_by { |exp| exp[:variant_index] }
106
105
  .each do |_variant_index, exps|
107
- exps.filter { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
106
+ exps.select { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
108
107
  a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
109
108
  b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
110
109
  if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
@@ -116,11 +115,11 @@ module Reading
116
115
  end
117
116
 
118
117
  # Raises an error if the spans within an experience are out of order
119
- # or if the spans overlap.
118
+ # or if the spans overlap. Spans with nil dates are not considered.
120
119
  # @raise [InvalidDateError]
121
120
  def validate_spans_are_in_order_and_not_overlapping(experiences)
122
121
  experiences
123
- .filter { |exp| exp[:spans].first&.dig(:dates) }
122
+ .select { |exp| exp[:spans].first&.dig(:dates) }
124
123
  .each do |exp|
125
124
  exp[:spans]
126
125
  .map { |span| span[:dates] }
@@ -132,7 +131,7 @@ module Reading
132
131
  end
133
132
  end
134
133
  .each_cons(2) do |a, b|
135
- if a.begin > b.begin || a.end > b.end
134
+ if a.begin > b.begin || (a.end || Date.today) > (b.end || Date.today)
136
135
  raise InvalidDateError, "Dates are not in order"
137
136
  end
138
137
  if a.cover?(b.begin + 1)
@@ -1,6 +1,6 @@
1
- require "date"
2
- require_relative "experiences/history_transformer"
3
- require_relative "experiences/dates_and_head_transformer"
1
+ require 'date'
2
+ require_relative 'experiences/history_transformer'
3
+ require_relative 'experiences/dates_and_head_transformer'
4
4
 
5
5
  module Reading
6
6
  module Parsing
@@ -16,10 +16,10 @@ module Reading
16
16
  # Config#default_config[:item][:template][:experiences]
17
17
  def transform_from_parsed(parsed_row, head_index)
18
18
  if !parsed_row[:history].blank?
19
- return HistoryTransformer.new(parsed_row, config).transform
19
+ return HistoryTransformer.new(parsed_row, head_index).transform
20
20
  end
21
21
 
22
- DatesAndHeadTransformer.new(parsed_row, head_index, config).transform
22
+ DatesAndHeadTransformer.new(parsed_row, head_index).transform
23
23
  end
24
24
  end
25
25
  end
@@ -1,23 +1,31 @@
1
1
  module Reading
2
2
  module Parsing
3
3
  module Attributes
4
- # Shared
4
+ # Sub-attributes that are shared across multiple attributes.
5
5
  module Shared
6
+ using Util::HashArrayDeepFetch
7
+ using Util::NumericToIIfWhole
8
+
6
9
  # Extracts the :progress sub-attribute (percent, pages, or time) from
7
10
  # the given hash.
8
11
  # @param hash [Hash] any parsed hash that contains progress.
12
+ # @param no_end_date [Boolean] for start and end dates (as opposed to
13
+ # the History column), whether an end date is present.
9
14
  # @return [Float, Integer, Item::TimeLength]
10
- def self.progress(hash)
15
+ def self.progress(hash, no_end_date: nil)
11
16
  hash[:progress_percent]&.to_f&./(100) ||
12
17
  hash[:progress_pages]&.to_i ||
13
- hash[:progress_time]&.then { Item::TimeLength.parse _1 } ||
18
+ hash[:progress_time]&.then { Item::TimeLength.parse(_1) } ||
14
19
  (0 if hash[:progress_dnf]) ||
15
20
  (1.0 if hash[:progress_done]) ||
21
+ (0.0 if no_end_date) ||
16
22
  nil
17
23
  end
18
24
 
19
25
  # Extracts the :length sub-attribute (pages or time) from the given hash.
20
26
  # @param hash [Hash] any parsed hash that contains length.
27
+ # @param format [Symbol] the item format, which affects length in cases
28
+ # where Config.hash[:speed][:format] is customized.
21
29
  # @param key_name [Symbol] the first part of the keys to be checked.
22
30
  # @param episodic [Boolean] whether to look for episodic (not total) length.
23
31
  # If false, returns nil if hash contains :each. If true, returns a
@@ -30,15 +38,15 @@ module Reading
30
38
  # This is useful for the History column, where that 1 hour can be used
31
39
  # as the default amount.
32
40
  # @return [Float, Integer, Item::TimeLength]
33
- def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
41
+ def self.length(hash, format:, key_name: :length, episodic: false, ignore_repetitions: false)
34
42
  return nil unless hash
35
43
 
36
44
  length = hash[:"#{key_name}_pages"]&.to_i ||
37
- hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse _1 }
45
+ hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse(_1) }
38
46
 
39
47
  return nil unless length
40
48
 
41
- if hash[:each]
49
+ if hash[:each] && !hash[:repetitions]
42
50
  # Length is calculated based on History column in this case.
43
51
  if episodic
44
52
  return length
@@ -54,7 +62,9 @@ module Reading
54
62
  return nil if episodic && !hash[:each]
55
63
  end
56
64
 
57
- length
65
+ speed = Config.hash.deep_fetch(:speed, :format)[format] || 1.0
66
+
67
+ (length / speed).to_i_if_whole
58
68
  end
59
69
  end
60
70
  end