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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +96 -52
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/filter.rb +95 -0
  6. data/lib/reading/item/time_length.rb +69 -30
  7. data/lib/reading/item/view.rb +116 -0
  8. data/lib/reading/item.rb +384 -0
  9. data/lib/reading/parsing/attributes/attribute.rb +1 -8
  10. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +11 -12
  11. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +31 -22
  12. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  13. data/lib/reading/parsing/attributes/experiences.rb +6 -6
  14. data/lib/reading/parsing/attributes/notes.rb +1 -1
  15. data/lib/reading/parsing/attributes/shared.rb +15 -8
  16. data/lib/reading/parsing/attributes/variants.rb +10 -7
  17. data/lib/reading/parsing/csv.rb +58 -44
  18. data/lib/reading/parsing/parser.rb +24 -25
  19. data/lib/reading/parsing/rows/blank.rb +23 -0
  20. data/lib/reading/parsing/rows/comment.rb +6 -7
  21. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  22. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  23. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  24. data/lib/reading/parsing/rows/regular.rb +15 -14
  25. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  26. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  27. data/lib/reading/parsing/transformer.rb +15 -19
  28. data/lib/reading/stats/filter.rb +738 -0
  29. data/lib/reading/stats/grouping.rb +243 -0
  30. data/lib/reading/stats/operation.rb +313 -0
  31. data/lib/reading/stats/query.rb +37 -0
  32. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  33. data/lib/reading/util/exclude.rb +12 -0
  34. data/lib/reading/util/hash_to_data.rb +30 -0
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +51 -5
  37. metadata +28 -7
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/hash_to_struct.rb +0 -30
  40. data/lib/reading/util/string_remove.rb +0 -28
  41. 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
@@ -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[:item_template].
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,20 +10,18 @@ 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
23
21
 
24
22
  # Extracts experiences from the parsed row.
25
23
  # @return [Array<Hash>] an array of experiences; see
26
- # Config#default_config[:item_template][:experiences]
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
- # 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
  {