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.
- checksums.yaml +4 -4
- data/bin/reading +95 -10
- data/lib/reading/config.rb +27 -5
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/item/time_length.rb +60 -23
- data/lib/reading/item/view.rb +14 -19
- data/lib/reading/item.rb +324 -54
- data/lib/reading/parsing/attributes/attribute.rb +0 -7
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
- data/lib/reading/parsing/attributes/experiences.rb +5 -5
- data/lib/reading/parsing/attributes/shared.rb +17 -7
- data/lib/reading/parsing/attributes/variants.rb +9 -6
- data/lib/reading/parsing/csv.rb +38 -35
- data/lib/reading/parsing/parser.rb +23 -24
- data/lib/reading/parsing/rows/blank.rb +23 -0
- data/lib/reading/parsing/rows/comment.rb +6 -7
- data/lib/reading/parsing/rows/compact_planned.rb +9 -9
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
- data/lib/reading/parsing/rows/custom_config.rb +42 -0
- data/lib/reading/parsing/rows/regular.rb +15 -14
- data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
- data/lib/reading/parsing/rows/regular_columns/sources.rb +16 -10
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
- data/lib/reading/parsing/transformer.rb +13 -17
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +257 -0
- data/lib/reading/stats/operation.rb +345 -0
- data/lib/reading/stats/query.rb +37 -0
- data/lib/reading/stats/terminal_result_formatters.rb +91 -0
- data/lib/reading/util/exclude.rb +12 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
- data/lib/reading/util/hash_to_data.rb +2 -2
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +36 -21
- metadata +28 -24
- data/bin/readingfile +0 -31
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
@@ -1,5 +1,16 @@
|
|
1
|
-
require_relative
|
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, :
|
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
|
23
|
-
def initialize(parsed_row,
|
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
|
-
@
|
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
|
-
|
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 ||=
|
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
|
-
|
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
|
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[:
|
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
|
-
|
158
|
-
|
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
|
-
|
169
|
-
|
170
|
-
|
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
|
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
|
-
|
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) ||
|
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
|
-
|
258
|
+
open_range_id:,
|
212
259
|
# ... have their amounts adjusted to be relative to previous progress.
|
213
|
-
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] ||
|
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
|
-
#
|
224
|
-
#
|
225
|
-
|
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[:
|
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
|
-
|
294
|
-
|
295
|
-
active[:
|
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[:
|
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 [
|
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 :
|
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
|
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
|
-
|
367
|
-
a[:in_open_range] == b[:in_open_range]
|
421
|
+
a[:open_range_id] == b[:open_range_id]
|
368
422
|
}
|
369
423
|
|
370
|
-
|
424
|
+
next_start_date = nil
|
371
425
|
chunked_by_open_range
|
372
|
-
.
|
373
|
-
unless chunk.first[:
|
426
|
+
.to_a.reverse.map { |chunk|
|
427
|
+
unless chunk.first[:open_range_id]
|
374
428
|
# safe nav. in case of planned entry
|
375
|
-
|
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
|
-
|
380
|
-
|
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
|
-
|
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 { |
|
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
|
404
|
-
span =
|
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
|
-
|
424
|
-
|
425
|
-
|
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
|
-
|
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,
|
19
|
-
if both_date_columns?
|
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?
|
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?
|
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?
|
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?
|
43
|
-
|
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?
|
49
|
-
|
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?
|
55
|
-
start_dates_column?
|
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
|
-
.
|
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
|
-
.
|
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
|
-
.
|
93
|
-
.map { |exp| exp[:spans].last[:dates]
|
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.
|
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
|
-
.
|
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
|
2
|
-
require_relative
|
3
|
-
require_relative
|
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,
|
19
|
+
return HistoryTransformer.new(parsed_row, head_index).transform
|
20
20
|
end
|
21
21
|
|
22
|
-
DatesAndHeadTransformer.new(parsed_row, head_index
|
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
|
-
#
|
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
|
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
|
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
|
-
|
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
|