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.
- checksums.yaml +4 -4
- data/bin/reading +5 -5
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +96 -108
- data/lib/reading/errors.rb +10 -66
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +140 -0
- data/lib/reading/item/view.rb +121 -0
- data/lib/reading/item.rb +117 -0
- data/lib/reading/parsing/attributes/attribute.rb +26 -0
- data/lib/reading/parsing/attributes/author.rb +15 -0
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
- data/lib/reading/parsing/attributes/experiences.rb +27 -0
- data/lib/reading/parsing/attributes/genres.rb +16 -0
- data/lib/reading/parsing/attributes/notes.rb +22 -0
- data/lib/reading/parsing/attributes/rating.rb +17 -0
- data/lib/reading/parsing/attributes/shared.rb +62 -0
- data/lib/reading/parsing/attributes/title.rb +21 -0
- data/lib/reading/parsing/attributes/variants.rb +77 -0
- data/lib/reading/parsing/csv.rb +112 -0
- data/lib/reading/parsing/parser.rb +292 -0
- data/lib/reading/parsing/rows/column.rb +131 -0
- data/lib/reading/parsing/rows/comment.rb +26 -0
- data/lib/reading/parsing/rows/compact_planned.rb +30 -0
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
- data/lib/reading/parsing/rows/regular.rb +33 -0
- data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
- data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
- data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
- data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
- data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
- data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
- data/lib/reading/parsing/transformer.rb +70 -0
- data/lib/reading/util/hash_compact_by_template.rb +1 -0
- data/lib/reading/util/hash_deep_merge.rb +1 -1
- data/lib/reading/util/hash_to_data.rb +30 -0
- data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
- data/lib/reading/util/string_truncate.rb +13 -4
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +49 -0
- metadata +76 -42
- data/lib/reading/attribute/all_attributes.rb +0 -83
- data/lib/reading/attribute/attribute.rb +0 -25
- data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
- data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
- data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
- data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
- data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
- data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
- data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
- data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
- data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
- data/lib/reading/csv.rb +0 -76
- data/lib/reading/line.rb +0 -23
- data/lib/reading/row/blank_row.rb +0 -23
- data/lib/reading/row/compact_planned_row.rb +0 -130
- data/lib/reading/row/regular_row.rb +0 -99
- data/lib/reading/row/row.rb +0 -88
- 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
|