reading 0.9.0 → 1.0.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 +33 -18
- data/lib/reading/config.rb +2 -2
- data/lib/reading/item/time_length.rb +2 -2
- data/lib/reading/item/view.rb +2 -2
- data/lib/reading/item.rb +15 -12
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +8 -3
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +151 -48
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +2 -2
- data/lib/reading/parsing/attributes/experiences.rb +3 -3
- data/lib/reading/parsing/attributes/shared.rb +4 -1
- data/lib/reading/parsing/csv.rb +4 -4
- data/lib/reading/parsing/parser.rb +6 -6
- data/lib/reading/parsing/rows/compact_planned.rb +4 -4
- data/lib/reading/parsing/rows/regular.rb +10 -10
- data/lib/reading/parsing/rows/regular_columns/sources.rb +1 -1
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
- data/lib/reading/parsing/transformer.rb +9 -9
- data/lib/reading/stats/filter.rb +55 -51
- data/lib/reading/stats/grouping.rb +18 -4
- data/lib/reading/stats/operation.rb +104 -22
- data/lib/reading/stats/query.rb +7 -7
- data/lib/reading/stats/result_formatters.rb +140 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +7 -7
- metadata +46 -22
- data/lib/reading/stats/terminal_result_formatters.rb +0 -91
@@ -1,6 +1,6 @@
|
|
1
|
-
require
|
2
|
-
require_relative
|
3
|
-
require_relative
|
1
|
+
require "date"
|
2
|
+
require_relative "experiences/history_transformer"
|
3
|
+
require_relative "experiences/dates_and_head_transformer"
|
4
4
|
|
5
5
|
module Reading
|
6
6
|
module Parsing
|
@@ -9,13 +9,16 @@ module Reading
|
|
9
9
|
# Extracts the :progress sub-attribute (percent, pages, or time) from
|
10
10
|
# the given hash.
|
11
11
|
# @param hash [Hash] any parsed hash that contains progress.
|
12
|
+
# @param no_end_date [Boolean] for start and end dates (as opposed to
|
13
|
+
# the History column), whether an end date is present.
|
12
14
|
# @return [Float, Integer, Item::TimeLength]
|
13
|
-
def self.progress(hash)
|
15
|
+
def self.progress(hash, no_end_date: nil)
|
14
16
|
hash[:progress_percent]&.to_f&./(100) ||
|
15
17
|
hash[:progress_pages]&.to_i ||
|
16
18
|
hash[:progress_time]&.then { Item::TimeLength.parse(_1) } ||
|
17
19
|
(0 if hash[:progress_dnf]) ||
|
18
20
|
(1.0 if hash[:progress_done]) ||
|
21
|
+
(0.0 if no_end_date) ||
|
19
22
|
nil
|
20
23
|
end
|
21
24
|
|
data/lib/reading/parsing/csv.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
1
|
+
require_relative "rows/blank"
|
2
|
+
require_relative "rows/regular"
|
3
|
+
require_relative "rows/compact_planned"
|
4
|
+
require_relative "rows/custom_config"
|
5
|
+
require_relative "rows/comment"
|
6
6
|
|
7
7
|
module Reading
|
8
8
|
module Parsing
|
@@ -145,7 +145,7 @@ module Reading
|
|
145
145
|
# Parse each format-plus-string into an array of segments.
|
146
146
|
heads = format_strings.map { |string|
|
147
147
|
format_emoji = string[Config.hash.deep_fetch(:regex, :formats)]
|
148
|
-
string.sub!(format_emoji,
|
148
|
+
string.sub!(format_emoji, "")
|
149
149
|
format = Config.hash.fetch(:formats).key(format_emoji)
|
150
150
|
|
151
151
|
parse_segments(column_class, string)
|
@@ -1,7 +1,7 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
1
|
+
require_relative "column"
|
2
|
+
require_relative "compact_planned_columns/head"
|
3
|
+
require_relative "regular_columns/sources"
|
4
|
+
require_relative "regular_columns/length"
|
5
5
|
|
6
6
|
module Reading
|
7
7
|
module Parsing
|
@@ -1,13 +1,13 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
6
|
-
require_relative
|
7
|
-
require_relative
|
8
|
-
require_relative
|
9
|
-
require_relative
|
10
|
-
require_relative
|
1
|
+
require_relative "column"
|
2
|
+
require_relative "regular_columns/rating"
|
3
|
+
require_relative "regular_columns/head"
|
4
|
+
require_relative "regular_columns/sources"
|
5
|
+
require_relative "regular_columns/start_dates"
|
6
|
+
require_relative "regular_columns/end_dates"
|
7
|
+
require_relative "regular_columns/genres"
|
8
|
+
require_relative "regular_columns/length"
|
9
|
+
require_relative "regular_columns/notes"
|
10
|
+
require_relative "regular_columns/history"
|
11
11
|
|
12
12
|
module Reading
|
13
13
|
module Parsing
|
@@ -1,12 +1,12 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
6
|
-
require_relative
|
7
|
-
require_relative
|
8
|
-
require_relative
|
9
|
-
require_relative
|
1
|
+
require_relative "attributes/shared"
|
2
|
+
require_relative "attributes/attribute"
|
3
|
+
require_relative "attributes/rating"
|
4
|
+
require_relative "attributes/author"
|
5
|
+
require_relative "attributes/title"
|
6
|
+
require_relative "attributes/genres"
|
7
|
+
require_relative "attributes/variants"
|
8
|
+
require_relative "attributes/experiences"
|
9
|
+
require_relative "attributes/notes"
|
10
10
|
|
11
11
|
module Reading
|
12
12
|
module Parsing
|
data/lib/reading/stats/filter.rb
CHANGED
@@ -82,7 +82,7 @@ module Reading
|
|
82
82
|
end
|
83
83
|
}
|
84
84
|
|
85
|
-
positive_operator = operator == :
|
85
|
+
positive_operator = operator == :"!=" ? :== : operator
|
86
86
|
|
87
87
|
matches = items.select { |item|
|
88
88
|
ratings.any? { |rating|
|
@@ -95,34 +95,39 @@ module Reading
|
|
95
95
|
# Instead of using item.rating.send(operator, format) above, invert
|
96
96
|
# the matches here to ensure multiple values after a negative operator
|
97
97
|
# have an "and" relation: "not(x and y)", rather than "not(x or y)".
|
98
|
-
if operator == :
|
98
|
+
if operator == :"!="
|
99
99
|
matches = items - matches
|
100
100
|
end
|
101
101
|
|
102
102
|
matches
|
103
103
|
},
|
104
|
-
|
105
|
-
|
106
|
-
|
104
|
+
progress: proc { |values, operator, items|
|
105
|
+
progresses = values.map { |value|
|
106
|
+
value = "0%" if value == "0"
|
107
|
+
match = value.match(/\A(\d+)?%/)
|
108
|
+
|
109
|
+
(match && match.captures.first.to_f.clamp(0.0, 100.0) / 100) ||
|
107
110
|
(raise InputError, "Progress must be a percentage")
|
108
111
|
}
|
109
112
|
|
110
113
|
filtered_items = items.map { |item|
|
111
114
|
# Ensure multiple values after a negative operator have an "and"
|
112
115
|
# relation: "not(x and y)", rather than "not(x or y)".
|
113
|
-
if operator == :
|
114
|
-
|
115
|
-
experience.spans.last.progress
|
116
|
+
if operator == :"!="
|
117
|
+
item_progresses = item.experiences.map { |experience|
|
118
|
+
experience.spans.last.progress || 0.0
|
116
119
|
}
|
117
120
|
|
118
|
-
next if (
|
121
|
+
next if (item_progresses - progresses).empty?
|
119
122
|
end
|
120
123
|
|
121
124
|
# Filter out non-matching experiences.
|
125
|
+
# TODO: Make this more accurate by calculating the average progress
|
126
|
+
# across spans? Currently it checks just the last span's progress.
|
122
127
|
filtered_experiences = item.experiences.select { |experience|
|
123
|
-
|
124
|
-
experience.
|
125
|
-
experience.spans.last.progress.send(operator,
|
128
|
+
progresses.any? { |progress|
|
129
|
+
experience.spans.any? &&
|
130
|
+
(experience.spans.last.progress || 0.0).send(operator, progress)
|
126
131
|
}
|
127
132
|
}
|
128
133
|
|
@@ -138,7 +143,7 @@ module Reading
|
|
138
143
|
filtered_items = items.map { |item|
|
139
144
|
# Treat empty variants as if they were a variant with a nil format.
|
140
145
|
if item.variants.empty?
|
141
|
-
if operator == :
|
146
|
+
if operator == :"!="
|
142
147
|
next item unless formats.include?(nil)
|
143
148
|
else
|
144
149
|
next item if formats.include?(nil)
|
@@ -147,7 +152,7 @@ module Reading
|
|
147
152
|
|
148
153
|
# Ensure multiple values after a negative operator have an "and"
|
149
154
|
# relation: "not(x and y)", rather than "not(x or y)".
|
150
|
-
if operator == :
|
155
|
+
if operator == :"!="
|
151
156
|
item_formats = item.variants.map(&:format)
|
152
157
|
|
153
158
|
next if (item_formats - formats).empty?
|
@@ -169,14 +174,14 @@ module Reading
|
|
169
174
|
author: proc { |values, operator, items|
|
170
175
|
authors = values
|
171
176
|
.map { _1.downcase if _1 }
|
172
|
-
.map { _1.gsub(/[^a-zA-Z ]/,
|
177
|
+
.map { _1.gsub(/[^a-zA-Z ]/, "").gsub(/\s/, "") if _1 }
|
173
178
|
|
174
179
|
matches = items.select { |item|
|
175
180
|
author = item
|
176
181
|
&.author
|
177
182
|
&.downcase
|
178
|
-
&.gsub(/[^a-zA-Z ]/,
|
179
|
-
&.gsub(/\s/,
|
183
|
+
&.gsub(/[^a-zA-Z ]/, "")
|
184
|
+
&.gsub(/\s/, "")
|
180
185
|
|
181
186
|
if %i[include? exclude?].include? operator
|
182
187
|
authors.any? {
|
@@ -200,7 +205,7 @@ module Reading
|
|
200
205
|
title: proc { |values, operator, items|
|
201
206
|
titles = values
|
202
207
|
.map(&:downcase)
|
203
|
-
.map { _1.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/,
|
208
|
+
.map { _1.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "").gsub(/\s/, "") }
|
204
209
|
|
205
210
|
matches = items.select { |item|
|
206
211
|
next unless item.title
|
@@ -208,8 +213,8 @@ module Reading
|
|
208
213
|
title = item
|
209
214
|
.title
|
210
215
|
.downcase
|
211
|
-
.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/,
|
212
|
-
.gsub(/\s/,
|
216
|
+
.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "")
|
217
|
+
.gsub(/\s/, "")
|
213
218
|
|
214
219
|
if %i[include? exclude?].include? operator
|
215
220
|
titles.any? { title.include? _1 }
|
@@ -228,8 +233,8 @@ module Reading
|
|
228
233
|
format_name = ->(str) {
|
229
234
|
str
|
230
235
|
&.downcase
|
231
|
-
&.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/,
|
232
|
-
&.gsub(/\s/,
|
236
|
+
&.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, "")
|
237
|
+
&.gsub(/\s/, "")
|
233
238
|
}
|
234
239
|
|
235
240
|
series_names = values.map { format_name.call(_1) }
|
@@ -251,7 +256,7 @@ module Reading
|
|
251
256
|
# Ensure multiple values after a negative operator have an "and"
|
252
257
|
# relation: "not(x and y)", rather than "not(x or y)".
|
253
258
|
if %i[!= exclude?].include? operator
|
254
|
-
next if operator == :
|
259
|
+
next if operator == :"!=" && (item_series_names - series_names).empty?
|
255
260
|
next if operator == :exclude? &&
|
256
261
|
item_series_names.all? { |item_series_name|
|
257
262
|
series_names.any? { |series_name|
|
@@ -280,7 +285,7 @@ module Reading
|
|
280
285
|
|
281
286
|
series_names.any? {
|
282
287
|
if _1.nil?
|
283
|
-
nil_operator = { include?: :==, exclude?: :
|
288
|
+
nil_operator = { include?: :==, exclude?: :"!=" }[operator]
|
284
289
|
end
|
285
290
|
|
286
291
|
item_series_name.send(nil_operator || operator, _1)
|
@@ -322,7 +327,7 @@ module Reading
|
|
322
327
|
(url.nil? ? url_nil_match : sources.include?(url))
|
323
328
|
}
|
324
329
|
|
325
|
-
next if operator == :
|
330
|
+
next if operator == :"!=" && remainder_names_and_urls.empty?
|
326
331
|
next if operator == :exclude? &&
|
327
332
|
item_source_names_and_urls.all? { |item_source_name_and_url|
|
328
333
|
sources.any? { |source|
|
@@ -347,7 +352,7 @@ module Reading
|
|
347
352
|
variant.sources.any? { |source|
|
348
353
|
sources.any? {
|
349
354
|
if _1.nil?
|
350
|
-
nil_operator = { include?: :==, exclude?: :
|
355
|
+
nil_operator = { include?: :==, exclude?: :"!=" }[operator]
|
351
356
|
end
|
352
357
|
|
353
358
|
source.name&.downcase&.send(nil_operator || operator, _1) ||
|
@@ -362,7 +367,7 @@ module Reading
|
|
362
367
|
|
363
368
|
filtered_items
|
364
369
|
},
|
365
|
-
:
|
370
|
+
:"end-date" => proc { |values, operator, items|
|
366
371
|
end_date_ranges = values.map { |value|
|
367
372
|
match = value.match(DATES_REGEX) ||
|
368
373
|
(raise InputError,
|
@@ -396,7 +401,7 @@ module Reading
|
|
396
401
|
filtered_items = items.map { |item|
|
397
402
|
# Ensure multiple values after a negative operator have an "and"
|
398
403
|
# relation: "not(x and y)", rather than "not(x or y)".
|
399
|
-
if operator == :
|
404
|
+
if operator == :"!="
|
400
405
|
item_end_dates = item.experiences.map(&:last_end_date)
|
401
406
|
|
402
407
|
next if item_end_dates.all? { |item_end_date|
|
@@ -468,7 +473,7 @@ module Reading
|
|
468
473
|
without_before_or_after
|
469
474
|
}
|
470
475
|
.compact
|
471
|
-
when :
|
476
|
+
when :"!="
|
472
477
|
split_item = item
|
473
478
|
|
474
479
|
date_ranges.each do |date_range|
|
@@ -506,7 +511,7 @@ module Reading
|
|
506
511
|
(raise InputError, "Experience count must be an integer")
|
507
512
|
}
|
508
513
|
|
509
|
-
positive_operator = operator == :
|
514
|
+
positive_operator = operator == :"!=" ? :== : operator
|
510
515
|
|
511
516
|
matches = items.select { |item|
|
512
517
|
experience_counts.any? { |experience_count|
|
@@ -514,19 +519,19 @@ module Reading
|
|
514
519
|
}
|
515
520
|
}
|
516
521
|
|
517
|
-
if operator == :
|
522
|
+
if operator == :"!="
|
518
523
|
matches = items - matches
|
519
524
|
end
|
520
525
|
|
521
526
|
matches
|
522
527
|
},
|
523
528
|
status: proc { |values, operator, items|
|
524
|
-
statuses = values.map { _1.squeeze(
|
529
|
+
statuses = values.map { _1.squeeze(" ").gsub(" ", "_").to_sym }
|
525
530
|
|
526
531
|
filtered_items = items.map { |item|
|
527
532
|
# Ensure multiple values after a negative operator have an "and"
|
528
533
|
# relation: "not(x and y)", rather than "not(x or y)".
|
529
|
-
if operator == :
|
534
|
+
if operator == :"!="
|
530
535
|
item_statuses = item.experiences.map(&:status).presence || [:planned]
|
531
536
|
|
532
537
|
next unless (item_statuses - statuses).any?
|
@@ -550,7 +555,7 @@ module Reading
|
|
550
555
|
filtered_items
|
551
556
|
},
|
552
557
|
genre: proc { |values, operator, items|
|
553
|
-
genres = values.map { _1 ? _1.split(
|
558
|
+
genres = values.map { _1 ? _1.split("+").map(&:strip) : [_1] }
|
554
559
|
|
555
560
|
matches = items.select { |item|
|
556
561
|
genres.any? { |and_genres|
|
@@ -560,7 +565,7 @@ module Reading
|
|
560
565
|
}
|
561
566
|
}
|
562
567
|
|
563
|
-
if operator == :
|
568
|
+
if operator == :"!="
|
564
569
|
matches = items - matches
|
565
570
|
end
|
566
571
|
|
@@ -578,7 +583,7 @@ module Reading
|
|
578
583
|
filtered_items = items.map { |item|
|
579
584
|
# Treat empty variants as if they were a variant with a nil length.
|
580
585
|
if item.variants.empty?
|
581
|
-
if operator == :
|
586
|
+
if operator == :"!="
|
582
587
|
next item unless lengths.include?(nil)
|
583
588
|
else
|
584
589
|
next item if lengths.include?(nil)
|
@@ -587,7 +592,7 @@ module Reading
|
|
587
592
|
|
588
593
|
# Ensure multiple values after a negative operator have an "and"
|
589
594
|
# relation: "not(x and y)", rather than "not(x or y)".
|
590
|
-
if operator == :
|
595
|
+
if operator == :"!="
|
591
596
|
item_lengths = item.variants.map(&:length)
|
592
597
|
|
593
598
|
next if (item_lengths - lengths).empty?
|
@@ -609,13 +614,13 @@ module Reading
|
|
609
614
|
note: proc { |values, operator, items|
|
610
615
|
notes = values
|
611
616
|
.map { _1.downcase if _1 }
|
612
|
-
.map { _1.gsub(/[^a-zA-Z0-9 ]/,
|
617
|
+
.map { _1.gsub(/[^a-zA-Z0-9 ]/, "") if _1 }
|
613
618
|
|
614
619
|
matches = items.select { |item|
|
615
620
|
item.notes.any? { |original_note|
|
616
621
|
note = original_note
|
617
622
|
.downcase
|
618
|
-
.gsub(/[^a-zA-Z0-9 ]/,
|
623
|
+
.gsub(/[^a-zA-Z0-9 ]/, "")
|
619
624
|
|
620
625
|
if %i[include? exclude?].include? operator
|
621
626
|
notes.any? { _1.nil? ? note == _1 : note.include?(_1) }
|
@@ -635,11 +640,10 @@ module Reading
|
|
635
640
|
|
636
641
|
NUMERIC_OPERATORS = {
|
637
642
|
rating: true,
|
638
|
-
done: true,
|
639
643
|
progress: true,
|
640
644
|
experience: true,
|
641
645
|
date: true,
|
642
|
-
:
|
646
|
+
:"end-date" => true,
|
643
647
|
length: true,
|
644
648
|
}
|
645
649
|
|
@@ -650,18 +654,18 @@ module Reading
|
|
650
654
|
}
|
651
655
|
|
652
656
|
PROHIBIT_NONE_VALUE = {
|
653
|
-
|
657
|
+
progress: true,
|
654
658
|
title: true,
|
655
|
-
:
|
659
|
+
:"end-date" => true,
|
656
660
|
date: true,
|
657
661
|
experience: true,
|
658
662
|
status: true,
|
659
663
|
}
|
660
664
|
|
661
665
|
PROHIBIT_MULTIPLE_VALUES_AFTER_NOT = {
|
662
|
-
|
666
|
+
progress: true,
|
663
667
|
title: true,
|
664
|
-
:
|
668
|
+
:"end-date" => true,
|
665
669
|
date: true,
|
666
670
|
experience: true,
|
667
671
|
status: true,
|
@@ -699,20 +703,20 @@ module Reading
|
|
699
703
|
|
700
704
|
unless allowed_operators.include? operator_str
|
701
705
|
raise InputError, "Operator \"#{operator_str}\" not allowed in the " \
|
702
|
-
"\"#{key}\" filter, only #{allowed_operators.join(
|
706
|
+
"\"#{key}\" filter, only #{allowed_operators.join(", ")} allowed"
|
703
707
|
end
|
704
708
|
|
705
709
|
operator = operator_str.to_sym
|
706
|
-
operator = :== if operator == :
|
710
|
+
operator = :== if operator == :"="
|
707
711
|
operator = :include? if operator == :~
|
708
|
-
operator = :exclude? if operator == :
|
712
|
+
operator = :exclude? if operator == :"!~"
|
709
713
|
|
710
714
|
values = predicate
|
711
|
-
.split(
|
715
|
+
.split(",")
|
712
716
|
.map(&:strip)
|
713
|
-
.map { _1.downcase ==
|
717
|
+
.map { _1.downcase == "none" ? nil : _1 }
|
714
718
|
|
715
|
-
# if values.count > 1 && operator == :
|
719
|
+
# if values.count > 1 && operator == :"!=" && PROHIBIT_MULTIPLE_VALUES_AFTER_NOT[key]
|
716
720
|
# end
|
717
721
|
|
718
722
|
if values.count > 1 && %i[> < >= <=].include?(operator)
|
@@ -16,10 +16,10 @@ module Reading
|
|
16
16
|
|
17
17
|
if match
|
18
18
|
group_names = match[:groups]
|
19
|
-
.split(
|
19
|
+
.split(",")
|
20
20
|
.tap { _1.last.sub!(/(\w)\s+\w+/, '\1') }
|
21
21
|
.map(&:strip)
|
22
|
-
.map { _1.delete_suffix(
|
22
|
+
.map { _1.delete_suffix("s") }
|
23
23
|
.map(&:to_sym)
|
24
24
|
|
25
25
|
if group_names.uniq.count < group_names.count
|
@@ -119,7 +119,9 @@ module Reading
|
|
119
119
|
.compact
|
120
120
|
.max
|
121
121
|
|
122
|
-
|
122
|
+
end_year = [Date.today.year, end_date.year].min
|
123
|
+
|
124
|
+
year_ranges = (begin_date.year..end_year).flat_map { |year|
|
123
125
|
beginning_of_year = Date.new(year, 1, 1)
|
124
126
|
end_of_year = Date.new(year + 1, 1, 1).prev_day
|
125
127
|
|
@@ -191,7 +193,7 @@ module Reading
|
|
191
193
|
groups
|
192
194
|
end
|
193
195
|
},
|
194
|
-
|
196
|
+
eachgenre: proc { |items|
|
195
197
|
groups = Hash.new { |h, k| h[k] = [] }
|
196
198
|
|
197
199
|
items.each do |item|
|
@@ -200,6 +202,18 @@ module Reading
|
|
200
202
|
|
201
203
|
groups.sort.to_h
|
202
204
|
},
|
205
|
+
genre: proc { |items|
|
206
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
207
|
+
|
208
|
+
items.each do |item|
|
209
|
+
if item.genres.any?
|
210
|
+
genre_combination = item.genres.sort.join(", ")
|
211
|
+
groups[genre_combination] << item
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
groups.sort.to_h
|
216
|
+
},
|
203
217
|
length: proc { |items|
|
204
218
|
boundaries = Config.hash.fetch(:length_group_boundaries)
|
205
219
|
|