reading 0.7.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 +96 -52
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +69 -30
- data/lib/reading/item/view.rb +116 -0
- data/lib/reading/item.rb +384 -0
- data/lib/reading/parsing/attributes/attribute.rb +1 -8
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +11 -12
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +31 -22
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
- data/lib/reading/parsing/attributes/experiences.rb +6 -6
- data/lib/reading/parsing/attributes/notes.rb +1 -1
- data/lib/reading/parsing/attributes/shared.rb +15 -8
- data/lib/reading/parsing/attributes/variants.rb +10 -7
- data/lib/reading/parsing/csv.rb +58 -44
- data/lib/reading/parsing/parser.rb +24 -25
- 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 +15 -19
- 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 +30 -0
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +51 -5
- metadata +28 -7
- data/bin/readingfile +0 -31
- data/lib/reading/util/hash_to_struct.rb +0 -30
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
@@ -0,0 +1,116 @@
|
|
1
|
+
module Reading
|
2
|
+
class Item
|
3
|
+
# A view object for an Item, providing shortcuts to information that is handy
|
4
|
+
# to show (for example) on a webpage.
|
5
|
+
class View
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
attr_reader :name, :rating, :type_emoji, :genres, :date_or_status,
|
9
|
+
:isbn, :url, :experience_count, :groups, :blurb, :public_notes
|
10
|
+
|
11
|
+
# @param item [Item] the Item from which to extract view information.
|
12
|
+
def initialize(item)
|
13
|
+
@genres = item.genres
|
14
|
+
@rating = extract_star_or_rating(item)
|
15
|
+
@isbn, @url, variant = extract_first_source_info(item)
|
16
|
+
@name = extract_name(item, variant)
|
17
|
+
@type_emoji = extract_type_emoji(variant&.format)
|
18
|
+
@date_or_status = extract_date_or_status(item)
|
19
|
+
@experience_count = item.experiences.count
|
20
|
+
@groups = item.experiences.map(&:group).compact
|
21
|
+
@blurb = item.notes.find(&:blurb?)&.content
|
22
|
+
@public_notes = item.notes.reject(&:private?).reject(&:blurb?).map(&:content)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# A star (or nil if the item doesn't make the cut), or the number rating if
|
28
|
+
# star ratings are disabled.
|
29
|
+
# @param item [Item]
|
30
|
+
# @return [String, Integer, Float]
|
31
|
+
def extract_star_or_rating(item)
|
32
|
+
minimum_rating = Config.hash.deep_fetch(:item, :view, :minimum_rating_for_star)
|
33
|
+
if minimum_rating
|
34
|
+
"⭐" if item.rating && item.rating >= minimum_rating
|
35
|
+
else
|
36
|
+
item.rating
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
# The ISBN/ASIN, URL, format, and extra info of the first variant that has
|
42
|
+
# an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
|
43
|
+
# Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
|
44
|
+
# @param item [Item]
|
45
|
+
# @return [Array(String, String, Symbol, Array<String>)]
|
46
|
+
def extract_first_source_info(item)
|
47
|
+
item.variants.map { |variant|
|
48
|
+
isbn = variant.isbn
|
49
|
+
if isbn
|
50
|
+
url = Config.hash.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
|
51
|
+
else
|
52
|
+
url = variant.sources.map { |source| source.url }.compact.first
|
53
|
+
end
|
54
|
+
|
55
|
+
[isbn, url, variant]
|
56
|
+
}
|
57
|
+
.select { |isbn, url, _variant| isbn || url }
|
58
|
+
.first || [nil, nil, item.variants.first]
|
59
|
+
end
|
60
|
+
|
61
|
+
# The view name of the item.
|
62
|
+
# @param item [Item]
|
63
|
+
# @param variant [Data, nil] a variant from the Item.
|
64
|
+
# @return [String]
|
65
|
+
def extract_name(item, variant)
|
66
|
+
author_and_title = "#{item.author + " – " if item.author}#{item.title}"
|
67
|
+
return author_and_title if variant.nil?
|
68
|
+
|
69
|
+
unless variant.series.empty? && variant.extra_info.empty?
|
70
|
+
pretty_series = variant.series.map { |series|
|
71
|
+
if series.volume
|
72
|
+
"#{series.name}, ##{series.volume}"
|
73
|
+
else
|
74
|
+
"in #{series.name}"
|
75
|
+
end
|
76
|
+
}
|
77
|
+
|
78
|
+
name_separator = Config.hash.deep_fetch(:item, :view, :name_separator)
|
79
|
+
series_and_extra_info = name_separator +
|
80
|
+
(pretty_series + variant.extra_info).join(name_separator)
|
81
|
+
end
|
82
|
+
|
83
|
+
author_and_title + (series_and_extra_info || "")
|
84
|
+
end
|
85
|
+
|
86
|
+
# The emoji for the type that represents (encompasses) a given format.
|
87
|
+
# @param format [Symbol, nil]
|
88
|
+
# @return [String]
|
89
|
+
def extract_type_emoji(format)
|
90
|
+
types = Config.hash.deep_fetch(:item, :view, :types)
|
91
|
+
|
92
|
+
return types.deep_fetch(format, :emoji) if types.has_key?(format)
|
93
|
+
|
94
|
+
type = types
|
95
|
+
.find { |type, hash| hash[:from_formats]&.include?(format) }
|
96
|
+
&.first # key
|
97
|
+
|
98
|
+
types.deep_fetch(
|
99
|
+
type || Config.hash.deep_fetch(:item, :view, :default_type),
|
100
|
+
:emoji,
|
101
|
+
)
|
102
|
+
end
|
103
|
+
|
104
|
+
# The date (if done) or status, stringified.
|
105
|
+
# @param item [Item]
|
106
|
+
# @return [String]
|
107
|
+
def extract_date_or_status(item)
|
108
|
+
if item.done?
|
109
|
+
item.last_end_date&.strftime("%Y-%m-%d")
|
110
|
+
else
|
111
|
+
item.status.to_s.gsub('_', ' ')
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
data/lib/reading/item.rb
ADDED
@@ -0,0 +1,384 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
require_relative 'item/view'
|
4
|
+
|
5
|
+
module Reading
|
6
|
+
# A wrapper for an item parsed from a CSV reading log, providing convenience
|
7
|
+
# methods beyond what the parser's raw Hash output can provide.
|
8
|
+
class Item
|
9
|
+
using Util::HashToData
|
10
|
+
using Util::HashArrayDeepFetch
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
ATTRIBUTES = %i[rating author title genres variants experiences notes]
|
14
|
+
|
15
|
+
private attr_reader :data
|
16
|
+
attr_reader :view
|
17
|
+
|
18
|
+
def_delegators :data, *ATTRIBUTES
|
19
|
+
|
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.
|
22
|
+
# @param view [Class, nil, Boolean] the class that will be used to build the
|
23
|
+
# view object, or nil/false if no view object should be built. If you use
|
24
|
+
# a custom view class, the only requirement is that its #initialize take
|
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
|
31
|
+
|
32
|
+
add_missing_attributes_with_filler_values!(item_hash)
|
33
|
+
add_statuses_and_last_end_dates!(item_hash)
|
34
|
+
|
35
|
+
@data = item_hash.to_data
|
36
|
+
end
|
37
|
+
|
38
|
+
@view = view.new(self) if view
|
39
|
+
end
|
40
|
+
|
41
|
+
# This item's status.
|
42
|
+
# @return [Symbol] :planned, :in_progress, or :done
|
43
|
+
def status
|
44
|
+
data.experiences.last&.status || :planned
|
45
|
+
end
|
46
|
+
|
47
|
+
# Whether this item is done.
|
48
|
+
# @return [Boolean]
|
49
|
+
def done?
|
50
|
+
status == :done
|
51
|
+
end
|
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]
|
307
|
+
end
|
308
|
+
|
309
|
+
# Equality to another Item.
|
310
|
+
# @other [Item]
|
311
|
+
# @return [Boolean]
|
312
|
+
def ==(other)
|
313
|
+
unless other.is_a?(Item)
|
314
|
+
raise ArgumentError, "An Item can be compared only with another Item."
|
315
|
+
end
|
316
|
+
|
317
|
+
data == other.send(:data)
|
318
|
+
end
|
319
|
+
|
320
|
+
private
|
321
|
+
|
322
|
+
# For each missing item attribute (key in Config.hash[:item][:template]) in
|
323
|
+
# item_hash, adds the key and a filler value.
|
324
|
+
# @param item_hash [Hash]
|
325
|
+
def add_missing_attributes_with_filler_values!(item_hash)
|
326
|
+
Config.hash.deep_fetch(:item, :template).each do |k, v|
|
327
|
+
next if item_hash.has_key?(k)
|
328
|
+
|
329
|
+
filler = v.is_a?(Array) ? [] : nil
|
330
|
+
item_hash[k] = filler
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
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]
|
340
|
+
# @return [Array(Symbol, Date)]
|
341
|
+
def add_statuses_and_last_end_dates!(item_hash)
|
342
|
+
item_hash[:experiences] = item_hash[:experiences].dup
|
343
|
+
|
344
|
+
item_hash[:experiences].each do |experience|
|
345
|
+
experience[:status] = :planned
|
346
|
+
experience[:last_end_date] = nil
|
347
|
+
|
348
|
+
next unless experience[:spans]&.any? { |span|
|
349
|
+
span[:dates]
|
350
|
+
}
|
351
|
+
|
352
|
+
experience[:status] = :in_progress
|
353
|
+
|
354
|
+
experience[:last_end_date] = experience[:spans]
|
355
|
+
.select { |span| span[:dates] }
|
356
|
+
.last[:dates]
|
357
|
+
.end
|
358
|
+
|
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
|
+
)
|
373
|
+
|
374
|
+
indefinite_in_progress_grace_period_is_over =
|
375
|
+
(Date.today - grace_period) > experience[:last_end_date]
|
376
|
+
|
377
|
+
if indefinite_in_progress_grace_period_is_over
|
378
|
+
experience[:status] = :done
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
@@ -4,15 +4,8 @@ module Reading
|
|
4
4
|
# The base class for all the attribute in parsing/attributes, each of which
|
5
5
|
# extracts an attribute from a parsed row. Together they transform the
|
6
6
|
# parsed row (an intermediate hash) into item attributes, as in
|
7
|
-
# Config#default_config[:
|
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,20 +10,18 @@ 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
|
23
21
|
|
24
22
|
# Extracts experiences from the parsed row.
|
25
23
|
# @return [Array<Hash>] an array of experiences; see
|
26
|
-
# Config#default_config[:
|
24
|
+
# Config#default_config[:item][:template][:experiences]
|
27
25
|
def transform
|
28
26
|
size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
|
29
27
|
# Pad start dates with {} and end dates with nil up to the size of
|
@@ -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
|
{
|