reading 0.8.0 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
|