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,243 @@
|
|
1
|
+
module Reading
|
2
|
+
module Stats
|
3
|
+
# The part of the query right after the operation, which groups the results,
|
4
|
+
# e.g. "by genre, rating".
|
5
|
+
class Grouping
|
6
|
+
# Determines which group(s) the input indicates, and then groups the
|
7
|
+
# Items accordingly. For the groups and their actions, see the constants
|
8
|
+
# below.
|
9
|
+
# @param input [String] the query string.
|
10
|
+
# @param items [Array<Item>] the Items on which to run the operation.
|
11
|
+
# @return [Hash] the return value of the group action(s).
|
12
|
+
def self.group(input, items)
|
13
|
+
grouped_items = {}
|
14
|
+
|
15
|
+
match = input.match(REGEX)
|
16
|
+
|
17
|
+
if match
|
18
|
+
group_names = match[:groups]
|
19
|
+
.split(',')
|
20
|
+
.tap { _1.last.sub!(/(\w)\s+\w+/, '\1') }
|
21
|
+
.map(&:strip)
|
22
|
+
.map { _1.delete_suffix('s') }
|
23
|
+
.map(&:to_sym)
|
24
|
+
|
25
|
+
if group_names.uniq.count < group_names.count
|
26
|
+
raise InputError, "Each grouping can be applied only once in a query."
|
27
|
+
end
|
28
|
+
|
29
|
+
begin
|
30
|
+
return group_hash(items, group_names)
|
31
|
+
rescue InputError => e
|
32
|
+
raise e.class, "#{e.message} in \"#{input}\""
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
{ all: items }
|
37
|
+
end
|
38
|
+
|
39
|
+
# Recursively builds a tree of groupings based on group_names.
|
40
|
+
# @group_names [Array<Symbol>]
|
41
|
+
# @items [Array<Item>]
|
42
|
+
# @return [Hash, Array<Item>]
|
43
|
+
private_class_method def self.group_hash(items, group_names)
|
44
|
+
return items if group_names.empty?
|
45
|
+
|
46
|
+
key = group_names.first
|
47
|
+
action = ACTIONS[key]
|
48
|
+
|
49
|
+
unless action
|
50
|
+
raise InputError, "Invalid grouping \"#{key}\""
|
51
|
+
end
|
52
|
+
|
53
|
+
action.call(items).transform_values do |grouped_items|
|
54
|
+
group_hash(grouped_items, group_names[1..])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
INPUT_SPLIT = /\s+(?=[\w\-]+\s*(?:!=|=|!~|~|>=|>|<=|<))/
|
61
|
+
|
62
|
+
# Each action groups the given Items.
|
63
|
+
# @param items [Array<Item>]
|
64
|
+
# @return [Hash{Symbol => Array<Item>}] the Items separated into groups.
|
65
|
+
ACTIONS = {
|
66
|
+
rating: proc { |items|
|
67
|
+
items
|
68
|
+
.group_by(&:rating)
|
69
|
+
.reject { |rating, _items| rating.nil? }
|
70
|
+
.sort_by { |rating, _items| rating }
|
71
|
+
.reverse
|
72
|
+
.to_h
|
73
|
+
},
|
74
|
+
format: proc { |items|
|
75
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
76
|
+
|
77
|
+
items.each do |item|
|
78
|
+
item.variants.group_by(&:format).each do |format, variants|
|
79
|
+
groups[format] << item.with_variants(variants)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
groups.sort.to_h
|
84
|
+
},
|
85
|
+
source: proc { |items|
|
86
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
87
|
+
|
88
|
+
items.each do |item|
|
89
|
+
item
|
90
|
+
.variants
|
91
|
+
.map { |variant|
|
92
|
+
variant.sources.map { |source|
|
93
|
+
[variant, source.name || source.url]
|
94
|
+
}
|
95
|
+
}
|
96
|
+
.flatten(1)
|
97
|
+
.group_by { |_variant, source| source }
|
98
|
+
.transform_values { |variants_and_sources|
|
99
|
+
variants_and_sources.map(&:first)
|
100
|
+
}
|
101
|
+
.each do |source, variants|
|
102
|
+
groups[source] << item.with_variants(variants)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
groups.sort.to_h
|
107
|
+
},
|
108
|
+
year: proc { |items|
|
109
|
+
begin_date = items
|
110
|
+
.map { _1.experiences.first&.spans&.first&.dates&.begin }
|
111
|
+
.compact
|
112
|
+
.min
|
113
|
+
|
114
|
+
if begin_date.nil?
|
115
|
+
{}
|
116
|
+
else
|
117
|
+
end_date = items
|
118
|
+
.flat_map { _1.experiences.map(&:last_end_date) }
|
119
|
+
.compact
|
120
|
+
.max
|
121
|
+
|
122
|
+
year_ranges = (begin_date.year..end_date.year).flat_map { |year|
|
123
|
+
beginning_of_year = Date.new(year, 1, 1)
|
124
|
+
end_of_year = Date.new(year + 1, 1, 1).prev_day
|
125
|
+
|
126
|
+
beginning_of_year..end_of_year
|
127
|
+
}
|
128
|
+
|
129
|
+
groups = year_ranges.map { [_1, []] }.to_h
|
130
|
+
|
131
|
+
groups.each do |year_range, year_items|
|
132
|
+
items.each do |item|
|
133
|
+
without_before = item.split(year_range.end.next_day).first
|
134
|
+
without_before_or_after = without_before&.split(year_range.begin)&.last
|
135
|
+
|
136
|
+
year_items << without_before_or_after if without_before_or_after
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
groups.transform_keys! { |year_range|
|
141
|
+
year_range.begin.year
|
142
|
+
}
|
143
|
+
|
144
|
+
groups
|
145
|
+
end
|
146
|
+
},
|
147
|
+
month: proc { |items|
|
148
|
+
begin_date = items
|
149
|
+
.map { _1.experiences.first&.spans&.first&.dates&.begin }
|
150
|
+
.compact
|
151
|
+
.min
|
152
|
+
|
153
|
+
if begin_date.nil?
|
154
|
+
{}
|
155
|
+
else
|
156
|
+
end_date = items
|
157
|
+
.flat_map { _1.experiences.map(&:last_end_date) }
|
158
|
+
.compact
|
159
|
+
.max
|
160
|
+
|
161
|
+
month_ranges = (begin_date.year..end_date.year).flat_map { |year|
|
162
|
+
(1..12).map { |month|
|
163
|
+
beginning_of_month = Date.new(year, month, 1)
|
164
|
+
|
165
|
+
end_of_month =
|
166
|
+
if month == 12
|
167
|
+
Date.new(year + 1, 1, 1).prev_day
|
168
|
+
else
|
169
|
+
Date.new(year, month + 1, 1).prev_day
|
170
|
+
end
|
171
|
+
|
172
|
+
beginning_of_month..end_of_month
|
173
|
+
}
|
174
|
+
}
|
175
|
+
|
176
|
+
groups = month_ranges.map { [_1, []] }.to_h
|
177
|
+
|
178
|
+
groups.each do |month_range, month_items|
|
179
|
+
items.each do |item|
|
180
|
+
without_before = item.split(month_range.end.next_day).first
|
181
|
+
without_before_or_after = without_before&.split(month_range.begin)&.last
|
182
|
+
|
183
|
+
month_items << without_before_or_after if without_before_or_after
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
groups.transform_keys! { |month_range|
|
188
|
+
[month_range.begin.year, month_range.begin.month]
|
189
|
+
}
|
190
|
+
|
191
|
+
groups
|
192
|
+
end
|
193
|
+
},
|
194
|
+
genre: proc { |items|
|
195
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
196
|
+
|
197
|
+
items.each do |item|
|
198
|
+
item.genres.each { |genre| groups[genre] << item }
|
199
|
+
end
|
200
|
+
|
201
|
+
groups.sort.to_h
|
202
|
+
},
|
203
|
+
length: proc { |items|
|
204
|
+
boundaries = Config.hash.fetch(:length_group_boundaries)
|
205
|
+
|
206
|
+
groups = boundaries.each_cons(2).map { |a, b|
|
207
|
+
[a..b, []]
|
208
|
+
}
|
209
|
+
|
210
|
+
groups.unshift([0..boundaries.first, []])
|
211
|
+
groups << [boundaries.last.., []]
|
212
|
+
|
213
|
+
groups = groups.to_h
|
214
|
+
|
215
|
+
items.each do |item|
|
216
|
+
item
|
217
|
+
.variants
|
218
|
+
.map { |variant| [variant, variant.length] }
|
219
|
+
.group_by { |_variant, length|
|
220
|
+
groups.keys.find { |length_range| length_range.include?(length) }
|
221
|
+
}
|
222
|
+
.transform_values { |variants_and_lengths|
|
223
|
+
variants_and_lengths.map(&:first)
|
224
|
+
}
|
225
|
+
.reject { |length_range, _variants| length_range.nil? }
|
226
|
+
.each do |length_range, variants|
|
227
|
+
groups[length_range] << item.with_variants(variants)
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
groups
|
232
|
+
},
|
233
|
+
}
|
234
|
+
|
235
|
+
REGEX = %r{\A
|
236
|
+
[^=]+ # the operation
|
237
|
+
by
|
238
|
+
\s*
|
239
|
+
(?<groups>[\w,\s]+)
|
240
|
+
}x
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
@@ -0,0 +1,313 @@
|
|
1
|
+
module Reading
|
2
|
+
module Stats
|
3
|
+
# The beginning of a query which specifies what it does, e.g.
|
4
|
+
# "average rating" or "total amount".
|
5
|
+
class Operation
|
6
|
+
using Util::NumericToIIfWhole
|
7
|
+
|
8
|
+
# Determines which operation is contained in the given input, and then
|
9
|
+
# runs it to get the result. For the operations and their actions, see
|
10
|
+
# the constants below.
|
11
|
+
# @param input [String] the query string.
|
12
|
+
# @param grouped_items [Hash{Symbol => Array<Item>}] if no group was used,
|
13
|
+
# the hash is just { all: items }
|
14
|
+
# @param result_formatters [Hash{Symbol => Proc}] to alter the appearance
|
15
|
+
# of results. Keys should be from among the keys of Operation::ACTIONS.
|
16
|
+
# @return [Object] the return value of the action; if items are grouped
|
17
|
+
# then a hash is returned with the same keys as grouped_items, otherwise
|
18
|
+
# just the array of all results (not grouped) is returned.
|
19
|
+
def self.execute(input, grouped_items, result_formatters)
|
20
|
+
REGEXES.each do |key, regex|
|
21
|
+
match = input.match(regex)
|
22
|
+
|
23
|
+
if match
|
24
|
+
if match[:number_arg]
|
25
|
+
number_arg = Integer(match[:number_arg], exception: false) ||
|
26
|
+
(raise InputError, "Argument must be an integer. Example: top 5 ratings")
|
27
|
+
end
|
28
|
+
|
29
|
+
results = apply_to_inner_items(grouped_items) do |inner_items|
|
30
|
+
result = ACTIONS[key].call(inner_items, number_arg)
|
31
|
+
|
32
|
+
default_formatter = :itself.to_proc # just the result itself
|
33
|
+
result_formatter = result_formatters[key] || default_formatter
|
34
|
+
|
35
|
+
result_formatter.call(result)
|
36
|
+
end
|
37
|
+
|
38
|
+
if results.keys == [:all] # no groupings
|
39
|
+
return results[:all]
|
40
|
+
else
|
41
|
+
return results
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
raise InputError, "No valid operation in stats query \"#{input}\""
|
47
|
+
end
|
48
|
+
|
49
|
+
# A recursive method that applies the block to the leaf nodes (arrays of
|
50
|
+
# Items) of the given hash of grouped items.
|
51
|
+
# @param grouped_items [Hash]
|
52
|
+
# @yield [Array<Item>]
|
53
|
+
def self.apply_to_inner_items(grouped_items, &)
|
54
|
+
if grouped_items.values.first.is_a? Array
|
55
|
+
grouped_items.transform_values! { |inner_items|
|
56
|
+
yield inner_items
|
57
|
+
}
|
58
|
+
else # It's a Hash, so go one level deeper.
|
59
|
+
grouped_items.each do |group_name, grouped|
|
60
|
+
apply_to_inner_items(grouped, &)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# The default number argument if one is not given, as in "top ratings"
|
68
|
+
# rather than "top 5 ratings".
|
69
|
+
DEFAULT_NUMBER_ARG = 10
|
70
|
+
|
71
|
+
# Each action makes some calculation based on the given Items.
|
72
|
+
# @param items [Array<Item>]
|
73
|
+
# @return [Object] in most cases an Integer.
|
74
|
+
ACTIONS = {
|
75
|
+
average_rating: proc { |items|
|
76
|
+
ratings = items.map(&:rating).compact
|
77
|
+
|
78
|
+
if ratings.any?
|
79
|
+
(ratings.sum.to_f / ratings.count).to_i_if_whole
|
80
|
+
end
|
81
|
+
},
|
82
|
+
average_length: proc { |items|
|
83
|
+
lengths = items.flat_map { |item|
|
84
|
+
item.variants.map(&:length)
|
85
|
+
}
|
86
|
+
.compact
|
87
|
+
|
88
|
+
if lengths.any?
|
89
|
+
(lengths.sum / lengths.count.to_f).to_i_if_whole
|
90
|
+
end
|
91
|
+
},
|
92
|
+
:"average_amount" => proc { |items|
|
93
|
+
total_amount = items.sum { |item|
|
94
|
+
item.experiences.sum { |experience|
|
95
|
+
experience.spans.sum(&:amount)
|
96
|
+
}
|
97
|
+
}
|
98
|
+
|
99
|
+
(total_amount / items.count.to_f).to_i_if_whole
|
100
|
+
},
|
101
|
+
:"average_daily-amount" => proc { |items|
|
102
|
+
amounts_by_date = calculate_amounts_by_date(items)
|
103
|
+
|
104
|
+
if amounts_by_date.any?
|
105
|
+
amounts_by_date.values.sum / amounts_by_date.count
|
106
|
+
end
|
107
|
+
},
|
108
|
+
total_item: proc { |items|
|
109
|
+
items.count
|
110
|
+
},
|
111
|
+
total_amount: proc { |items|
|
112
|
+
items.sum { |item|
|
113
|
+
item.experiences.sum { |experience|
|
114
|
+
experience.spans.sum { |span|
|
115
|
+
(span.amount * span.progress).to_i_if_whole
|
116
|
+
}
|
117
|
+
}
|
118
|
+
}
|
119
|
+
},
|
120
|
+
top_rating: proc { |items, number_arg|
|
121
|
+
items
|
122
|
+
.max_by(number_arg || DEFAULT_NUMBER_ARG, &:rating)
|
123
|
+
.map { |item| [author_and_title(item), item.rating] }
|
124
|
+
},
|
125
|
+
top_length: proc { |items, number_arg|
|
126
|
+
items
|
127
|
+
.map { |item| [author_and_title(item), item.variants.map(&:length).max] }
|
128
|
+
.reject { |_title, length| length.nil? }
|
129
|
+
.max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
|
130
|
+
},
|
131
|
+
top_amount: proc { |items, number_arg|
|
132
|
+
items
|
133
|
+
.map { |item|
|
134
|
+
amount = item.experiences.sum { |experience|
|
135
|
+
experience.spans.sum { |span|
|
136
|
+
(span.amount * span.progress).to_i_if_whole
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
[author_and_title(item), amount]
|
141
|
+
}
|
142
|
+
.reject { |_title, amount| amount.zero? }
|
143
|
+
.max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
|
144
|
+
},
|
145
|
+
top_speed: proc { |items, number_arg|
|
146
|
+
items
|
147
|
+
.map { |item| calculate_speed(item) }
|
148
|
+
.compact
|
149
|
+
.max_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
|
150
|
+
speed_hash[:amount] / speed_hash[:days].to_f
|
151
|
+
}
|
152
|
+
},
|
153
|
+
bottom_rating: proc { |items, number_arg|
|
154
|
+
items
|
155
|
+
.min_by(number_arg || DEFAULT_NUMBER_ARG, &:rating)
|
156
|
+
.map { |item| [author_and_title(item), item.rating] }
|
157
|
+
},
|
158
|
+
bottom_length: proc { |items, number_arg|
|
159
|
+
items
|
160
|
+
.map { |item| [author_and_title(item), item.variants.map(&:length).max] }
|
161
|
+
.reject { |_title, length| length.nil? }
|
162
|
+
.min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, length| length }
|
163
|
+
},
|
164
|
+
bottom_amount: proc { |items, number_arg|
|
165
|
+
items
|
166
|
+
.map { |item|
|
167
|
+
amount = item.experiences.sum { |experience|
|
168
|
+
experience.spans.sum { |span|
|
169
|
+
(span.amount * span.progress).to_i_if_whole
|
170
|
+
}
|
171
|
+
}
|
172
|
+
|
173
|
+
[author_and_title(item), amount]
|
174
|
+
}
|
175
|
+
.reject { |_title, amount| amount.zero? }
|
176
|
+
.min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, amount| amount }
|
177
|
+
},
|
178
|
+
bottom_speed: proc { |items, number_arg|
|
179
|
+
items
|
180
|
+
.map { |item| calculate_speed(item) }
|
181
|
+
.compact
|
182
|
+
.min_by(number_arg || DEFAULT_NUMBER_ARG) { |_title, speed_hash|
|
183
|
+
speed_hash[:amount] / speed_hash[:days].to_f
|
184
|
+
}
|
185
|
+
},
|
186
|
+
}
|
187
|
+
|
188
|
+
ALIASES = {
|
189
|
+
average_rating: %w[ar],
|
190
|
+
average_length: %w[al],
|
191
|
+
average_amount: %w[aia ai],
|
192
|
+
:"average_daily-amount" => %w[ada ad],
|
193
|
+
total_item: %w[item count],
|
194
|
+
total_amount: %w[amount],
|
195
|
+
top_rating: %w[tr],
|
196
|
+
top_length: %w[tl],
|
197
|
+
top_amount: %w[ta],
|
198
|
+
top_speed: %w[ts],
|
199
|
+
bottom_rating: %w[br],
|
200
|
+
bottom_length: %w[bl],
|
201
|
+
bottom_amount: %w[ba],
|
202
|
+
bottom_speed: %w[bs],
|
203
|
+
}
|
204
|
+
|
205
|
+
REGEXES = ACTIONS.map { |key, _action|
|
206
|
+
first_word, second_word = key.to_s.split('_')
|
207
|
+
aliases = ALIASES.fetch(key)
|
208
|
+
|
209
|
+
regex =
|
210
|
+
%r{
|
211
|
+
(
|
212
|
+
\A
|
213
|
+
\s*
|
214
|
+
#{first_word}
|
215
|
+
s?
|
216
|
+
\s*
|
217
|
+
(?<number_arg>
|
218
|
+
\d+
|
219
|
+
)?
|
220
|
+
\s*
|
221
|
+
(
|
222
|
+
#{second_word}
|
223
|
+
s?
|
224
|
+
)
|
225
|
+
\s*
|
226
|
+
)
|
227
|
+
|
|
228
|
+
(
|
229
|
+
\A
|
230
|
+
\s*
|
231
|
+
(#{aliases.join('|')})
|
232
|
+
s?
|
233
|
+
\s*
|
234
|
+
(?<number_arg>
|
235
|
+
\d+
|
236
|
+
)?
|
237
|
+
\s*
|
238
|
+
)
|
239
|
+
}x
|
240
|
+
|
241
|
+
[key, regex]
|
242
|
+
}.to_h
|
243
|
+
|
244
|
+
# Sums the given Items' amounts per date.
|
245
|
+
# @param items [Array<Item>]
|
246
|
+
# @return [Hash{Date => Numeric, Reading::Item::TimeLength}]
|
247
|
+
private_class_method def self.calculate_amounts_by_date(items)
|
248
|
+
amounts_by_date = {}
|
249
|
+
|
250
|
+
items.each do |item|
|
251
|
+
item.experiences.each do |experience|
|
252
|
+
experience.spans.each do |span|
|
253
|
+
next unless span.dates
|
254
|
+
|
255
|
+
dates = span.dates.begin..(span.dates.end || Date.today)
|
256
|
+
|
257
|
+
amount = span.amount / dates.count.to_f
|
258
|
+
progress = span.members.include?(:progress) ? span.progress : 1.0
|
259
|
+
|
260
|
+
dates.each do |date|
|
261
|
+
amounts_by_date[date] ||= 0
|
262
|
+
amounts_by_date[date] += amount * progress
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
amounts_by_date
|
269
|
+
end
|
270
|
+
|
271
|
+
# Calculates an Item's speed (total amount over how many days). Returns
|
272
|
+
# nil if a speed is not able to be calculated (e.g. in a planned Item).
|
273
|
+
# @param item [Item]
|
274
|
+
# @return [Array(String, Hash), nil]
|
275
|
+
private_class_method def self.calculate_speed(item)
|
276
|
+
speeds = item.experiences.map { |experience|
|
277
|
+
spans_with_finite_dates = experience.spans.reject { |span|
|
278
|
+
span.dates.nil? || span.dates.end.nil?
|
279
|
+
}
|
280
|
+
next unless spans_with_finite_dates.any?
|
281
|
+
|
282
|
+
amount = spans_with_finite_dates.sum { |span|
|
283
|
+
# Conditional in case Item was created with fragmentary experience hashes,
|
284
|
+
# as in stats_test.rb
|
285
|
+
progress = span.members.include?(:progress) ? span.progress : 1.0
|
286
|
+
|
287
|
+
span.amount * progress
|
288
|
+
}
|
289
|
+
.to_i_if_whole
|
290
|
+
|
291
|
+
days = spans_with_finite_dates.sum { |span| span.dates.count }.to_i
|
292
|
+
|
293
|
+
{ amount:, days: }
|
294
|
+
}
|
295
|
+
.compact
|
296
|
+
|
297
|
+
return nil unless speeds.any?
|
298
|
+
|
299
|
+
speed = speeds
|
300
|
+
.max_by { |hash| hash[:amount] / hash[:days].to_f }
|
301
|
+
|
302
|
+
[author_and_title(item), speed]
|
303
|
+
end
|
304
|
+
|
305
|
+
# A shorter version of Item::View#name.
|
306
|
+
# @param item [Item]
|
307
|
+
# @return [String]
|
308
|
+
private_class_method def self.author_and_title(item)
|
309
|
+
"#{item.author + " – " if item.author}#{item.title}"
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'pastel'
|
2
|
+
require_relative 'operation'
|
3
|
+
require_relative 'filter'
|
4
|
+
require_relative 'grouping'
|
5
|
+
|
6
|
+
module Reading
|
7
|
+
module Stats
|
8
|
+
# Gives statistics on an array of Items.
|
9
|
+
class Query
|
10
|
+
private attr_reader :input, :items, :result_formatters, :pastel
|
11
|
+
|
12
|
+
# @param input [String] the query string.
|
13
|
+
# @param items [Array<Item>] the Items to be queried.
|
14
|
+
# @param result_formatters [Boolean, Hash{Symbol => Proc}] to alter the
|
15
|
+
# appearance of results; keys should be from among the keys of
|
16
|
+
# Operation::ACTIONS. Pre-made formatters for terminal output are in
|
17
|
+
# terminal_result_formatters.rb.
|
18
|
+
def initialize(input:, items:, result_formatters: {})
|
19
|
+
@input = input
|
20
|
+
@items = items
|
21
|
+
@result_formatters = result_formatters
|
22
|
+
@pastel = Pastel.new
|
23
|
+
end
|
24
|
+
|
25
|
+
# Parses the query and returns the result.
|
26
|
+
# @return [Object]
|
27
|
+
def result
|
28
|
+
filtered_items = Stats::Filter.filter(input, items)
|
29
|
+
grouped_items = Grouping.group(input, filtered_items)
|
30
|
+
|
31
|
+
Operation.execute(input, grouped_items, result_formatters || {})
|
32
|
+
rescue Reading::Error => e
|
33
|
+
raise e.class, pastel.bright_red(e.message)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'pastel'
|
2
|
+
|
3
|
+
module Reading
|
4
|
+
module Stats
|
5
|
+
module ResultFormatters
|
6
|
+
TERMINAL = {
|
7
|
+
average_length: ->(result) { length_to_s(result) },
|
8
|
+
average_amount: ->(result) { length_to_s(result) },
|
9
|
+
:'average_daily-amount' => ->(result) { "#{length_to_s(result)} per day" },
|
10
|
+
total_item: ->(result) {
|
11
|
+
if result.zero?
|
12
|
+
PASTEL.bright_black("none")
|
13
|
+
else
|
14
|
+
color("#{result} #{result == 1 ? "item" : "items"}")
|
15
|
+
end
|
16
|
+
},
|
17
|
+
total_amount: ->(result) { length_to_s(result) },
|
18
|
+
top_length: ->(result) { top_or_bottom_lengths(result) },
|
19
|
+
top_amount: ->(result) { top_or_bottom_lengths(result) },
|
20
|
+
top_speed: ->(result) { top_or_bottom_speeds(result) },
|
21
|
+
bottom_length: ->(result) { top_or_bottom_lengths(result) },
|
22
|
+
botom_amount: ->(result) { top_or_bottom_lengths(result) },
|
23
|
+
bottom_speed: ->(result) { top_or_bottom_speeds(result) },
|
24
|
+
}
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
PASTEL = Pastel.new
|
29
|
+
|
30
|
+
# Applies a terminal color.
|
31
|
+
# @param string [String]
|
32
|
+
# @return [String]
|
33
|
+
private_class_method def self.color(string)
|
34
|
+
PASTEL.bright_blue(string)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Converts a length/amount (pages or time) into a string.
|
38
|
+
# @param length [Numeric, Reading::Item::TimeLength]
|
39
|
+
# @param color [Boolean] whether a terminal color should be applied.
|
40
|
+
# @return [String]
|
41
|
+
private_class_method def self.length_to_s(length, color: true)
|
42
|
+
if length.is_a?(Numeric)
|
43
|
+
length_string = "#{length.round} pages"
|
44
|
+
else
|
45
|
+
length_string = length.to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
color ? color(length_string) : length_string
|
49
|
+
end
|
50
|
+
|
51
|
+
# Formats a list of top/bottom length results as a string.
|
52
|
+
# @param result [Array]
|
53
|
+
# @return [String]
|
54
|
+
private_class_method def self.top_or_bottom_lengths(result)
|
55
|
+
offset = result.count.digits.count
|
56
|
+
|
57
|
+
result
|
58
|
+
.map.with_index { |(title, length), index|
|
59
|
+
pad = ' ' * (offset - (index + 1).digits.count)
|
60
|
+
|
61
|
+
title_line = "#{index + 1}. #{pad}#{title}"
|
62
|
+
indent = " #{' ' * offset}"
|
63
|
+
|
64
|
+
"#{title_line}\n#{indent}#{length_to_s(length)}"
|
65
|
+
}
|
66
|
+
.join("\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
# Formats a list of top/bottom speed results as a string.
|
70
|
+
# @param result [Array]
|
71
|
+
# @return [String]
|
72
|
+
private_class_method def self.top_or_bottom_speeds(result)
|
73
|
+
offset = result.count.digits.count
|
74
|
+
|
75
|
+
result
|
76
|
+
.map.with_index { |(title, hash), index|
|
77
|
+
amount = length_to_s(hash[:amount], color: false)
|
78
|
+
days = "#{hash[:days]} #{hash[:days] == 1 ? "day" : "days"}"
|
79
|
+
pad = ' ' * (offset - (index + 1).digits.count)
|
80
|
+
|
81
|
+
title_line = "#{index + 1}. #{pad}#{title}"
|
82
|
+
indent = " #{' ' * offset}"
|
83
|
+
colored_speed = color("#{amount} in #{days}")
|
84
|
+
|
85
|
+
"#{title_line}\n#{indent}#{colored_speed}"
|
86
|
+
}
|
87
|
+
.join("\n")
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|