reading 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +5 -5
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +96 -108
  5. data/lib/reading/errors.rb +10 -66
  6. data/lib/reading/filter.rb +95 -0
  7. data/lib/reading/item/time_length.rb +140 -0
  8. data/lib/reading/item/view.rb +121 -0
  9. data/lib/reading/item.rb +117 -0
  10. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  11. data/lib/reading/parsing/attributes/author.rb +15 -0
  12. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  13. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  14. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  15. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  16. data/lib/reading/parsing/attributes/genres.rb +16 -0
  17. data/lib/reading/parsing/attributes/notes.rb +22 -0
  18. data/lib/reading/parsing/attributes/rating.rb +17 -0
  19. data/lib/reading/parsing/attributes/shared.rb +62 -0
  20. data/lib/reading/parsing/attributes/title.rb +21 -0
  21. data/lib/reading/parsing/attributes/variants.rb +77 -0
  22. data/lib/reading/parsing/csv.rb +112 -0
  23. data/lib/reading/parsing/parser.rb +292 -0
  24. data/lib/reading/parsing/rows/column.rb +131 -0
  25. data/lib/reading/parsing/rows/comment.rb +26 -0
  26. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  27. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  28. data/lib/reading/parsing/rows/regular.rb +33 -0
  29. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  30. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  31. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  32. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  33. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  34. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  35. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  36. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  37. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  38. data/lib/reading/parsing/transformer.rb +70 -0
  39. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  40. data/lib/reading/util/hash_deep_merge.rb +1 -1
  41. data/lib/reading/util/hash_to_data.rb +30 -0
  42. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  43. data/lib/reading/util/string_truncate.rb +13 -4
  44. data/lib/reading/version.rb +1 -1
  45. data/lib/reading.rb +49 -0
  46. metadata +76 -42
  47. data/lib/reading/attribute/all_attributes.rb +0 -83
  48. data/lib/reading/attribute/attribute.rb +0 -25
  49. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  50. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  51. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  52. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  53. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  54. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  55. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  56. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  57. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  58. data/lib/reading/csv.rb +0 -76
  59. data/lib/reading/line.rb +0 -23
  60. data/lib/reading/row/blank_row.rb +0 -23
  61. data/lib/reading/row/compact_planned_row.rb +0 -130
  62. data/lib/reading/row/regular_row.rb +0 -99
  63. data/lib/reading/row/row.rb +0 -88
  64. data/lib/reading/util/hash_to_struct.rb +0 -29
@@ -0,0 +1,452 @@
1
+ require_relative "spans_validator"
2
+
3
+ module Reading
4
+ module Parsing
5
+ module Attributes
6
+ class Experiences < Attribute
7
+ # Experiences#transform_from_parsed delegates to this class when the
8
+ # History column is not blank (i.e. when experiences should be extracted
9
+ # from History and not the Start Dates, End Dates, and Head columns).
10
+ class HistoryTransformer
11
+ using Util::HashArrayDeepFetch
12
+ using Util::NumericToIIfWhole
13
+
14
+ # Rational numbers are used here and in #distribute_amount_across_date_range
15
+ # below so as not to lose precision when dividing a small amount by
16
+ # many days, for example.
17
+ AVERAGE_DAYS_IN_A_MONTH = 30.437r
18
+
19
+ private attr_reader :parsed_row, :config
20
+
21
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
22
+ # @param config [Hash] an entire config
23
+ def initialize(parsed_row, config)
24
+ @parsed_row = parsed_row
25
+ @config = config
26
+ end
27
+
28
+ # Extracts experiences from the parsed row.
29
+ # @return [Array<Hash>] an array of experiences; see
30
+ # Config#default_config[:item][:template][:experiences]
31
+ def transform
32
+ experiences = parsed_row[:history].map { |entries|
33
+ {
34
+ spans: spans_from_history_entries(entries),
35
+ group: entries.first[:group],
36
+ variant_index: (entries.first[:variant] || 1).to_i - 1,
37
+ }
38
+ }
39
+
40
+ # Raises an error if experiences overlap or are out of order.
41
+ Experiences::SpansValidator.validate(experiences, config, history_column: true)
42
+
43
+ experiences
44
+ end
45
+
46
+ private
47
+
48
+ # A shortcut to the span template.
49
+ # @return [Hash]
50
+ def span_template
51
+ @span_template ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
52
+ end
53
+
54
+ # The :spans sub-attribute for the given History column entries.
55
+ # @param entries [Array<Hash>] History entries for one experience.
56
+ # @return [Array<Hash>] an array of spans; see
57
+ # Config#default_config[:item][:template][:experiences].first[:spans]
58
+ def spans_from_history_entries(entries)
59
+ daily_spans = {}
60
+ active = {
61
+ year: nil,
62
+ month: nil,
63
+ day: nil,
64
+ after_single_date: false,
65
+ open_range: false,
66
+ planned: false,
67
+ amount: nil,
68
+ last_start_year: nil,
69
+ last_start_month: nil,
70
+ }
71
+
72
+ entries.each do |entry|
73
+ if entry[:except_dates]
74
+ reject_exception_dates!(entry, daily_spans, active)
75
+ next
76
+ end
77
+
78
+ add_to_daily_spans!(entry, daily_spans, active)
79
+ end
80
+
81
+ spans = merge_daily_spans(daily_spans)
82
+
83
+ fix_open_ranges!(spans)
84
+
85
+ relativize_amounts_from_progress!(spans)
86
+
87
+ spans
88
+ end
89
+
90
+ # Removes the given entry's exception dates from daily_spans.
91
+ # @param entry [Hash] a History entry of exception dates ("not <list of dates>").
92
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
93
+ # date-and-name combination.
94
+ # @param active [Hash] variables that persist across entries, such as
95
+ # amount and implied date.
96
+ def reject_exception_dates!(entry, daily_spans, active)
97
+ except_active = {
98
+ year: active[:last_start_year],
99
+ month: active[:last_start_month],
100
+ day: nil,
101
+ }
102
+
103
+ except_dates = entry[:except_dates].flat_map { |except_entry|
104
+ start_year = except_entry[:start_year]&.to_i
105
+ start_month = except_entry[:start_month]&.to_i
106
+ # (Start) day is required in an exception date.
107
+ except_active[:day] = except_entry[:start_day].to_i
108
+
109
+ # Increment year if month is earlier than previous regular entry's month.
110
+ if except_active[:month] && start_month && start_month < except_active[:month]
111
+ start_year ||= except_active[:year] + 1
112
+ end
113
+
114
+ except_active[:year] = start_year if start_year
115
+ except_active[:month] = start_month if start_month
116
+
117
+ date_range = date_range(except_entry, except_active)
118
+
119
+ date_range&.to_a ||
120
+ Date.new(except_active[:year], except_active[:month], except_active[:day])
121
+ }
122
+
123
+ daily_spans.reject! do |(date, _name), _span|
124
+ except_dates.include?(date)
125
+ end
126
+ end
127
+
128
+ # Expands the given entry into one span per day, then adds them to daily_spans.
129
+ # @param entry [Hash] a regular History entry (not exception dates).
130
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
131
+ # date-and-name combination.
132
+ # @param active [Hash] variables that persist across entries, such as
133
+ # amount and implied date.
134
+ def add_to_daily_spans!(entry, daily_spans, active)
135
+ start_year = entry[:start_year]&.to_i
136
+ start_month = entry[:start_month]&.to_i
137
+ start_day = entry[:start_day]&.to_i
138
+
139
+ # Increment year if start month is earlier than previous entry's month.
140
+ if active[:month] && start_month && start_month < active[:month]
141
+ start_year ||= active[:year] + 1
142
+ end
143
+
144
+ active[:year] = start_year if start_year
145
+ active[:last_start_year] = active[:year]
146
+ active[:month] = start_month if start_month
147
+ active[:last_start_month] = active[:month]
148
+ if start_day
149
+ active[:open_range] = false
150
+ active[:day] = start_day
151
+ end
152
+
153
+ unless active[:day] && active[:month] && active[:year]
154
+ raise InvalidHistoryError, "Missing or incomplete first date"
155
+ end
156
+
157
+ duplicate_open_range = !start_day && active[:open_range]
158
+ date_range = date_range(entry, active, duplicate_open_range:)
159
+
160
+ # A startless date range (i.e. with an implied start date) appearing
161
+ # immediately after a single date has its start date bumped forward
162
+ # by one, so that its start date is not the same as the single date.
163
+ if date_range && !start_day && active[:after_single_date]
164
+ date_range = (date_range.begin + 1)..date_range.end
165
+ end
166
+ active[:after_single_date] = !date_range
167
+
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
172
+
173
+ progress = Attributes::Shared.progress(entry)
174
+
175
+ # If the entry has no amount and the item has no episodic length,
176
+ # then use progress as amount instead. The typical scenario for this
177
+ # is when tracking fixed-length items such as books. See
178
+ # https://github.com/fpsvogel/reading/blob/main/doc/csv-format.md#history-pages-and-stopping-points-books
179
+ if !amount && progress
180
+ if progress.is_a? Float
181
+ total_length = Attributes::Shared.length(parsed_row[:length])
182
+ amount = total_length * progress
183
+ else
184
+ amount = progress
185
+ end
186
+ amount_from_progress = true
187
+ end
188
+
189
+ repetitions = entry[:repetitions]&.to_i || 1
190
+ frequency = entry[:frequency]
191
+
192
+ amounts_by_date = distribute_amount_across_date_range(
193
+ date_range || Date.new(active[:year], active[:month], active[:day]),
194
+ amount || active[:amount],
195
+ repetitions,
196
+ frequency,
197
+ )
198
+
199
+ in_open_range = active[:open_range] || duplicate_open_range
200
+
201
+ daily_spans_from_entry = amounts_by_date.map { |date, daily_amount|
202
+ span_without_dates = {
203
+ dates: nil,
204
+ amount: daily_amount || span_template[:amount],
205
+ progress: (progress unless amount_from_progress) || span_template[:progress],
206
+ name: entry[:name] || span_template[:name],
207
+ favorite?: !!entry[:favorite] || span_template[:favorite?],
208
+ # Temporary keys (not in the final item data) for marking
209
+ # spans to ...
210
+ # ... be distributed evenly across an open date range.
211
+ in_open_range: in_open_range,
212
+ # ... have their amounts adjusted to be relative to previous progress.
213
+ amount_from_progress: amount_from_progress,
214
+ }
215
+
216
+ if entry[:planned] || (active[:planned] && !start_day)
217
+ date = nil
218
+ active[:planned] = true
219
+ end
220
+
221
+ key = [date, span_without_dates[:name]]
222
+
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]
227
+ key << rand
228
+ end
229
+
230
+ [key, span_without_dates]
231
+ }.to_h
232
+
233
+ daily_spans.merge!(daily_spans_from_entry)
234
+ end
235
+
236
+ # Changes daily spans into normal spans, with chunks of daily spans
237
+ # with contiguous dates compressed into single spans with date ranges.
238
+ # @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
239
+ # date-and-name combination.
240
+ # @return [Array<Hash>]
241
+ def merge_daily_spans(daily_spans)
242
+ daily_spans
243
+ .chunk_while { |((date_a, _name_a), span_a), ((date_b, _name_b), span_b)|
244
+ date_b && date_a && # in case of planned entry
245
+ date_b == date_a.next_day &&
246
+ span_b == span_a
247
+ }.map { |chunked_spans|
248
+ # or chunked_spans.to_h.values.first.first
249
+ span = chunked_spans.first[1]
250
+ first_daily_date = chunked_spans.first[0][0]
251
+ last_daily_date = chunked_spans.last[0][0]
252
+
253
+ unless chunked_spans.first[0][0].nil? # in case of planned entry
254
+ # or chunked_spans.to_h.keys.first[0]..chunked_spans.to_h.keys.last[0]
255
+ span[:dates] = first_daily_date..last_daily_date
256
+ end
257
+
258
+ span[:amount] *= chunked_spans.count
259
+
260
+ span
261
+ }.reject { |span|
262
+ span[:amount].zero?
263
+ }
264
+ end
265
+
266
+ # Builds the date range for the given entry, if any. Also may update
267
+ # the dates in `active` according to the entry's end date.
268
+ # @param entry [Hash] a History entry.
269
+ # @param active [Hash]variables that persist across entries, such as
270
+ # amount and implied date.
271
+ # @param duplicate_open_range [Boolean] whether this entry is a
272
+ # continuation of an open range, in which case it doesn't need to
273
+ # be a range explicitly.
274
+ # @return [Range<Date>]
275
+ def date_range(entry, active, duplicate_open_range: false)
276
+ return nil unless entry[:range] || duplicate_open_range
277
+
278
+ if entry[:end_day]
279
+ active[:open_range] = false
280
+
281
+ end_year = entry[:end_year]&.to_i
282
+ end_month = entry[:end_month]&.to_i
283
+ end_day = entry[:end_day].to_i
284
+
285
+ # Increment year if end month is earlier than start month.
286
+ if active[:month] && end_month && end_month < active[:month]
287
+ end_year ||= active[:year] + 1
288
+ end
289
+
290
+ date_range = Date.new(active[:year], active[:month], active[:day])..
291
+ Date.new(end_year || active[:year], end_month || active[:month], end_day)
292
+
293
+ active[:day] = end_day + 1
294
+ active[:month] = end_month if end_month
295
+ active[:year] = end_year if end_year
296
+ else # either starting or continuing (duplicating) an open range
297
+ active[:open_range] ||= true
298
+ date_range = Date.new(active[:year], active[:month], active[:day])..Date.today
299
+ end
300
+
301
+ date_range
302
+ end
303
+
304
+ # Distributes an amount across the given date(s).
305
+ # @param date_or_range [Date, Range<Date>] the date or range across
306
+ # which the amount will be split up.
307
+ # @param amount [Float, Integer, Item::TimeLength] amount in
308
+ # pages or time.
309
+ # @param repetitions [Integer] e.g. "x4" in a History entry.
310
+ # @param frequency [Integer] e.g. "/week" in a History entry.
311
+ # @return [Hash{Date => Float, Integer, Item::TimeLength}]
312
+ def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
313
+ unless amount
314
+ raise InvalidHistoryError, "Missing length or amount"
315
+ end
316
+
317
+ if date_or_range.is_a? Date
318
+ if frequency
319
+ # e.g. " -- x1/week"
320
+ date_range = date_or_range..Date.today
321
+ else
322
+ date_range = date_or_range..date_or_range
323
+ end
324
+ else
325
+ date_range = date_or_range
326
+ end
327
+
328
+ total_amount = amount * repetitions
329
+
330
+ case frequency
331
+ when "month"
332
+ months = date_range.count / AVERAGE_DAYS_IN_A_MONTH
333
+ total_amount *= months
334
+ when "week"
335
+ weeks = date_range.count / 7r
336
+ total_amount *= weeks
337
+ when "day"
338
+ days = date_range.count
339
+ total_amount *= days
340
+ end
341
+
342
+ days ||= date_range.count
343
+ if days.zero?
344
+ raise InvalidHistoryError,
345
+ "Backward date range in the History column: #{date_range}"
346
+ end
347
+
348
+ amount_per_date = (total_amount / days.to_r).to_i_if_whole if total_amount
349
+
350
+ amounts_by_date = date_range.to_a.map { |date|
351
+ [date, amount_per_date]
352
+ }.to_h
353
+
354
+ amounts_by_date
355
+ end
356
+
357
+ # Set each open date range's last end date (wherever it's today, i.e.
358
+ # it wasn't defined) to the day before the next entry's start date.
359
+ # At the same time, distribute each open range's spans evenly.
360
+ # Lastly, remove the :in_open_range key from spans.
361
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
362
+ # @return [Array<Hash>]
363
+ def fix_open_ranges!(spans)
364
+ chunked_by_open_range = spans.chunk_while { |a, b|
365
+ 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]
368
+ }
369
+
370
+ next_chunk_start_date = nil
371
+ chunked_by_open_range
372
+ .reverse_each { |chunk|
373
+ unless chunk.first[:in_open_range] && chunk.any? { _1[:dates].end == Date.today }
374
+ # safe nav. in case of planned entry
375
+ next_chunk_start_date = chunk.first[:dates]&.begin
376
+ next
377
+ end
378
+
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
382
+ end
383
+ next_chunk_start_date = chunk.first[:dates].begin
384
+
385
+ # Distribute spans across the open date range.
386
+ total_amount = chunk.sum { |c| c[:amount] }
387
+ dates = chunk.last[:dates]
388
+ amount_per_day = total_amount / dates.count.to_f
389
+
390
+ # Save the last end date to restore it after it becomes nil below.
391
+ last_end_date = chunk.last[:dates].end
392
+
393
+ span = nil
394
+ amount_acc = 0
395
+ span_needing_end = nil
396
+ dates.each do |date|
397
+ if span_needing_end && amount_acc < amount_per_day
398
+ span_needing_end[:dates] = span_needing_end[:dates].begin..date
399
+ span_needing_end = nil
400
+ end
401
+
402
+ while amount_acc < amount_per_day
403
+ break if chunk.empty?
404
+ span = chunk.shift
405
+ amount_acc += span[:amount]
406
+
407
+ if amount_acc < amount_per_day
408
+ end_date = date
409
+ else
410
+ end_date = nil
411
+ span_needing_end = span
412
+ end
413
+
414
+ span[:dates] = date..end_date
415
+ end
416
+
417
+ amount_acc -= amount_per_day
418
+ end
419
+
420
+ span[:dates] = span[:dates].begin..last_end_date
421
+ }
422
+
423
+ spans.each do |span|
424
+ span.delete(:in_open_range)
425
+ end
426
+ end
427
+
428
+ # Changes amounts taken from progress, from absolute to relative,
429
+ # e.g. at p20 on 2/11 then at p30 on 2/12 (absolute) to
430
+ # 20 pages on 2/11 then 10 pages on 2/12 (relative). Also, remove the
431
+ # :amount_from_progress key key from spans.
432
+ # @param spans [Array<Hash>] spans after being merged from daily_spans.
433
+ # @return [Array<Hash>]
434
+ def relativize_amounts_from_progress!(spans)
435
+ amount_acc = 0
436
+ spans.each do |span|
437
+ if span[:amount_from_progress]
438
+ span[:amount] -= amount_acc
439
+ end
440
+
441
+ amount_acc += span[:amount]
442
+ end
443
+
444
+ spans.each do |span|
445
+ span.delete(:amount_from_progress)
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,149 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ class Experiences < Attribute
5
+ # Methods to validate dates in spans. This does not cover all the ways
6
+ # dates can be invalid, just the ones not caught during parsing.
7
+ module SpansValidator
8
+ using Util::HashArrayDeepFetch
9
+
10
+ class << self
11
+ # Checks the dates in the given experiences hash, and raises an error
12
+ # at the first invalid date found.
13
+ # @param experiences [Array<Hash>] experience hashes.
14
+ # @param config [Hash] an entire config.
15
+ # @param history_column [Boolean] whether this validation is for
16
+ # experiences from the History column.
17
+ # @raise [InvalidDateError] if any date is invalid.
18
+ def validate(experiences, config, history_column: false)
19
+ if both_date_columns?(config)
20
+ validate_number_of_start_dates_and_end_dates(experiences)
21
+ end
22
+
23
+ if start_dates_column?(config) || history_column
24
+ validate_start_dates_are_in_order(experiences)
25
+ end
26
+
27
+ if end_dates_column?(config) || history_column
28
+ validate_end_dates_are_in_order(experiences)
29
+ end
30
+
31
+ if both_date_columns?(config) || history_column
32
+ validate_experiences_of_same_variant_do_not_overlap(experiences)
33
+ end
34
+
35
+ validate_spans_are_in_order_and_not_overlapping(experiences)
36
+ end
37
+
38
+ private
39
+
40
+ # Whether the Start Dates column is enabled.
41
+ # @return [Boolean]
42
+ def start_dates_column?(config)
43
+ config.fetch(:enabled_columns).include?(:start_dates)
44
+ end
45
+
46
+ # Whether the End Dates column is enabled.
47
+ # @return [Boolean]
48
+ def end_dates_column?(config)
49
+ config.fetch(:enabled_columns).include?(:end_dates)
50
+ end
51
+
52
+ # Whether both the Start Dates and End Dates columns are enabled.
53
+ # @return [Boolean]
54
+ def both_date_columns?(config)
55
+ start_dates_column?(config) && end_dates_column?(config)
56
+ end
57
+
58
+ # Raises an error if there are more end dates than start dates, or
59
+ # if there is more than one more start date than end dates.
60
+ # @raise [InvalidDateError]
61
+ def validate_number_of_start_dates_and_end_dates(experiences)
62
+ _both_dates, not_both_dates = experiences
63
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
64
+ .map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
65
+ .partition { |start_date, end_date| start_date && end_date }
66
+
67
+ all_dates_paired = not_both_dates.empty?
68
+ last_date_started_present = not_both_dates.count == 1 && not_both_dates.first
69
+
70
+ unless all_dates_paired || last_date_started_present
71
+ raise InvalidDateError, "Start dates or end dates are missing"
72
+ end
73
+ end
74
+
75
+ # Raises an error if the spans' first start dates are not in order.
76
+ # @raise [InvalidDateError]
77
+ def validate_start_dates_are_in_order(experiences)
78
+ experiences
79
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
80
+ .map { |exp| exp[:spans].first[:dates].begin }
81
+ .each_cons(2) do |a, b|
82
+ if (a.nil? && b.nil?) || (a && b && a > b )
83
+ raise InvalidDateError, "Start dates are not in order"
84
+ end
85
+ end
86
+ end
87
+
88
+ # Raises an error if the spans' last end dates are not in order.
89
+ # @raise [InvalidDateError]
90
+ def validate_end_dates_are_in_order(experiences)
91
+ experiences
92
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
93
+ .map { |exp| exp[:spans].last[:dates].end }
94
+ .each_cons(2) do |a, b|
95
+ if (a.nil? && b.nil?) || (a && b && a > b )
96
+ raise InvalidDateError, "End dates are not in order"
97
+ end
98
+ end
99
+ end
100
+
101
+ # Raises an error if two experiences of the same variant overlap.
102
+ # @raise [InvalidDateError]
103
+ def validate_experiences_of_same_variant_do_not_overlap(experiences)
104
+ experiences
105
+ .group_by { |exp| exp[:variant_index] }
106
+ .each do |_variant_index, exps|
107
+ exps.filter { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
108
+ a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
109
+ b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
110
+ if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
111
+ b_metaspan.cover?(a_metaspan.begin || b_metaspan.begin || b_metaspan.end)
112
+ raise InvalidDateError, "Experiences are overlapping"
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Raises an error if the spans within an experience are out of order
119
+ # or if the spans overlap.
120
+ # @raise [InvalidDateError]
121
+ def validate_spans_are_in_order_and_not_overlapping(experiences)
122
+ experiences
123
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
124
+ .each do |exp|
125
+ exp[:spans]
126
+ .map { |span| span[:dates] }
127
+ # Exclude nil dates (planned entries in History).
128
+ .reject { |dates| dates.nil? }
129
+ .each do |dates|
130
+ if dates.begin && dates.end && dates.begin > dates.end
131
+ raise InvalidDateError, "A date range is backward"
132
+ end
133
+ end
134
+ .each_cons(2) do |a, b|
135
+ if a.begin > b.begin || a.end > b.end
136
+ raise InvalidDateError, "Dates are not in order"
137
+ end
138
+ if a.cover?(b.begin + 1)
139
+ raise InvalidDateError, "Dates are overlapping"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,27 @@
1
+ require "date"
2
+ require_relative "experiences/history_transformer"
3
+ require_relative "experiences/dates_and_head_transformer"
4
+
5
+ module Reading
6
+ module Parsing
7
+ module Attributes
8
+ # Transformer for the :experiences item attribute.
9
+ class Experiences < Attribute
10
+ using Util::HashArrayDeepFetch
11
+ using Util::HashDeepMerge
12
+
13
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
14
+ # @param head_index [Integer] current item's position in the Head column.
15
+ # @return [Array<Hash>] an array of experiences; see
16
+ # Config#default_config[:item][:template][:experiences]
17
+ def transform_from_parsed(parsed_row, head_index)
18
+ if !parsed_row[:history].blank?
19
+ return HistoryTransformer.new(parsed_row, config).transform
20
+ end
21
+
22
+ DatesAndHeadTransformer.new(parsed_row, head_index, config).transform
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :genres item attribute.
5
+ class Genres < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param head_index [Integer] current item's position in the Head column.
8
+ # @return [Array<String>]
9
+ def transform_from_parsed(parsed_row, head_index)
10
+ (parsed_row[:genres] || parsed_row[:head][head_index][:genres])
11
+ &.map { _1.is_a?(Hash) ? _1[:genre] : _1 }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :notes item attribute.
5
+ class Notes < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param _head_index [Integer] current item's position in the Head column.
8
+ # @return [Array<Hash>] an array of notes; see
9
+ # Config#default_config[:item][:template][:notes]
10
+ def transform_from_parsed(parsed_row, _head_index)
11
+ parsed_row[:notes]&.map { |note|
12
+ {
13
+ blurb?: note.has_key?(:note_blurb),
14
+ private?: note.has_key?(:note_private),
15
+ content: note[:note_regular] || note[:note_blurb] || note[:note_private],
16
+ }
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :rating item attribute.
5
+ class Rating < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param _head_index [Integer] current item's position in the Head column.
8
+ # @return [Integer, Float]
9
+ def transform_from_parsed(parsed_row, _head_index)
10
+ rating = parsed_row[:rating]&.dig(:number)
11
+
12
+ Integer(rating, exception: false) || Float(rating, exception: false)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end