reading 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +27 -5
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/item/time_length.rb +60 -23
  6. data/lib/reading/item/view.rb +14 -19
  7. data/lib/reading/item.rb +321 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +13 -6
  14. data/lib/reading/parsing/attributes/variants.rb +9 -6
  15. data/lib/reading/parsing/csv.rb +38 -35
  16. data/lib/reading/parsing/parser.rb +23 -24
  17. data/lib/reading/parsing/rows/blank.rb +23 -0
  18. data/lib/reading/parsing/rows/comment.rb +6 -7
  19. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  20. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  21. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  22. data/lib/reading/parsing/rows/regular.rb +15 -14
  23. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  24. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  25. data/lib/reading/parsing/transformer.rb +13 -17
  26. data/lib/reading/stats/filter.rb +738 -0
  27. data/lib/reading/stats/grouping.rb +243 -0
  28. data/lib/reading/stats/operation.rb +313 -0
  29. data/lib/reading/stats/query.rb +37 -0
  30. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  31. data/lib/reading/util/exclude.rb +12 -0
  32. data/lib/reading/util/hash_to_data.rb +2 -2
  33. data/lib/reading/version.rb +1 -1
  34. data/lib/reading.rb +36 -21
  35. metadata +10 -6
  36. data/bin/readingfile +0 -31
  37. data/lib/reading/util/string_remove.rb +0 -28
  38. data/lib/reading/util/string_truncate.rb +0 -22
data/lib/reading/item.rb CHANGED
@@ -1,9 +1,6 @@
1
- require "forwardable"
1
+ require 'forwardable'
2
2
 
3
- require_relative "item/view"
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 :attributes, :config
19
- attr_reader :view, :status, :last_end_date
15
+ private attr_reader :data
16
+ attr_reader :view
20
17
 
21
- def_delegators :attributes, *ATTRIBUTES
18
+ def_delegators :data, *ATTRIBUTES
22
19
 
23
- # @param item_hash [Hash] a parsed item like the template in
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 and a full config as arguments.
30
- def initialize(item_hash, config: Config.new.hash, view: Item::View)
31
- item_hash = item_hash.dup
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
- add_missing_attributes_with_filler_values(item_hash, config)
32
+ add_missing_attributes_with_filler_values!(item_hash)
33
+ add_statuses_and_last_end_dates!(item_hash)
34
34
 
35
- @attributes = item_hash.to_data
36
- @config = config
35
+ @data = item_hash.to_data
36
+ end
37
+
38
+ @view = view.new(self) if view
39
+ end
37
40
 
38
- @status, @last_end_date = get_status_and_last_end_date
39
- @view = view.new(self, config) if view
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
- # Whether this item has a fixed length, such as a book or audiobook (as
49
- # opposed to an ongoing podcast).
50
- # @return [Boolean]
51
- def definite_length?
52
- attributes.variants.any? { |variant| !!variant.length }
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
- attributes == other.send(:attributes)
317
+ data == other.send(:data)
64
318
  end
65
319
 
66
320
  private
67
321
 
68
- # For each missing item attribute (key in config[:item][:template]) 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
- # @param config [Hash] an entire config.
72
- def add_missing_attributes_with_filler_values(item_hash, config)
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. Note: for an item of indefinite
82
- # length (e.g. podcast) there is a grace period during which the status
83
- # remains :in_progress after the last activity. If that grace period is over,
84
- # the status is :done. It's :planned if there are no spans with dates.
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 get_status_and_last_end_date
87
- return [:planned, nil] if experiences.none? || experiences.flat_map(&:spans).none?
341
+ def add_statuses_and_last_end_dates!(item_hash)
342
+ item_hash[:experiences] = item_hash[:experiences].dup
88
343
 
89
- experiences_with_spans_with_dates = experiences
90
- .select { |experience| experience.spans.any? { |span| span.dates } }
344
+ item_hash[:experiences].each do |experience|
345
+ experience[:status] = :planned
346
+ experience[:last_end_date] = nil
91
347
 
92
- return [:planned, nil] unless experiences_with_spans_with_dates.any?
348
+ next unless experience[:spans]&.any? { |span|
349
+ span[:dates]
350
+ }
93
351
 
94
- last_end_date = experiences_with_spans_with_dates
95
- .last
96
- .spans
97
- .select { |span| span.dates }
98
- .last
99
- .dates
100
- .end
352
+ experience[:status] = :in_progress
101
353
 
102
- return [:in_progress, nil] unless last_end_date
354
+ experience[:last_end_date] = experience[:spans]
355
+ .select { |span| span[:dates] }
356
+ .last[:dates]
357
+ .end
103
358
 
104
- if definite_length?
105
- [:done, last_end_date]
106
- else
107
- grace_period = config.deep_fetch(:item, :indefinite_in_progress_grace_period_days)
108
- indefinite_in_progress_grace_period_is_over =
109
- (Date.today - grace_period) > last_end_date
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
- return [:done, last_end_date] if indefinite_in_progress_grace_period_is_over
374
+ indefinite_in_progress_grace_period_is_over =
375
+ (Date.today - grace_period) > experience[:last_end_date]
112
376
 
113
- [:in_progress, last_end_date]
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 "spans_validator"
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 :config, :parsed_row, :head_index
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
- # @param config [Hash] an entire config.
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
- # Raises an error if any sequence of dates does not make sense.
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
- config.deep_fetch(:item, :template, :experiences).first
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
- config.deep_fetch(:item, :template, :experiences, 0, :spans).first
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
- length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index)) ||
88
- Attributes::Shared.length(parsed_row[:length])
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 "spans_validator"
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, :config
19
+ private attr_reader :parsed_row, :head_index
20
20
 
21
21
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
22
- # @param config [Hash] an entire config
23
- def initialize(parsed_row, config)
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
- @config = config
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
- # Raises an error if experiences overlap or are out of order.
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 ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
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) || span_template[: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] || (active[:planned] && !start_day)
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
- # 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]
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