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