reading 0.8.0 → 0.9.1
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 +95 -10
- data/lib/reading/config.rb +27 -5
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/item/time_length.rb +60 -23
- data/lib/reading/item/view.rb +14 -19
- data/lib/reading/item.rb +324 -54
- data/lib/reading/parsing/attributes/attribute.rb +0 -7
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
- data/lib/reading/parsing/attributes/experiences.rb +5 -5
- data/lib/reading/parsing/attributes/shared.rb +17 -7
- data/lib/reading/parsing/attributes/variants.rb +9 -6
- data/lib/reading/parsing/csv.rb +38 -35
- data/lib/reading/parsing/parser.rb +23 -24
- 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 +16 -10
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
- data/lib/reading/parsing/transformer.rb +13 -17
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +257 -0
- data/lib/reading/stats/operation.rb +345 -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_array_deep_fetch.rb +1 -23
- data/lib/reading/util/hash_to_data.rb +2 -2
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +36 -21
- metadata +28 -24
- data/bin/readingfile +0 -31
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
@@ -0,0 +1,738 @@
|
|
1
|
+
module Reading
|
2
|
+
module Stats
|
3
|
+
# The parts of a query that filter the data being queried, e.g. "genre=history".
|
4
|
+
class Filter
|
5
|
+
# Determines which filters are contained in the given input, and then runs
|
6
|
+
# them to get the remaining Items. For the filters and their actions, see
|
7
|
+
# the constants below.
|
8
|
+
# @param input [String] the query string.
|
9
|
+
# @param items [Array<Item>] the Items on which to run the operation.
|
10
|
+
# @return [Object] the return value of the action.
|
11
|
+
def self.filter(input, items)
|
12
|
+
filtered_items = items
|
13
|
+
|
14
|
+
split_input = input.split(INPUT_SPLIT)
|
15
|
+
|
16
|
+
split_input[1..].each do |filter_input|
|
17
|
+
match_found = false
|
18
|
+
|
19
|
+
REGEXES.each do |key, regex|
|
20
|
+
match = filter_input.match(regex)
|
21
|
+
|
22
|
+
if match
|
23
|
+
match_found = true
|
24
|
+
|
25
|
+
begin
|
26
|
+
filtered_items = filter_single(
|
27
|
+
key,
|
28
|
+
match[:predicate],
|
29
|
+
match[:operator],
|
30
|
+
filtered_items,
|
31
|
+
)
|
32
|
+
rescue InputError => e
|
33
|
+
raise InputError, "#{e.message} in \"#{input}\""
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
unless match_found
|
39
|
+
raise InputError, "Invalid filter \"#{filter_input}\" in \"#{input}\""
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
filtered_items
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
INPUT_SPLIT = /\s+(?=[\w\-]+\s*(?:!=|=|!~|~|>=|>|<=|<))/
|
49
|
+
|
50
|
+
DATES_REGEX = %r{\A
|
51
|
+
(?<start_year>\d{4})
|
52
|
+
(
|
53
|
+
\/
|
54
|
+
(?<start_month>\d\d?)
|
55
|
+
)?
|
56
|
+
(
|
57
|
+
-
|
58
|
+
(
|
59
|
+
(?<end_year>\d{4})
|
60
|
+
)?
|
61
|
+
\/?
|
62
|
+
(?<end_month>\d\d?)?
|
63
|
+
)?
|
64
|
+
\z}x
|
65
|
+
|
66
|
+
# Each action filters the given Items.
|
67
|
+
# @param operator [Symbol] e.g. the method representing the operator,
|
68
|
+
# usually simply the operator string converted to a symbol, e.g.
|
69
|
+
# :">=" from "rating>=2"; but in some cases the method is alphabetic,
|
70
|
+
# e.g. :include? from "source~library".
|
71
|
+
# @param values [Array<String>] the values after the operator, split by
|
72
|
+
# commas.
|
73
|
+
# @param items [Array<Item>]
|
74
|
+
# @return [Array<Item>] a subset of the given Items.
|
75
|
+
ACTIONS = {
|
76
|
+
rating: proc { |values, operator, items|
|
77
|
+
ratings = values.map { |value|
|
78
|
+
if value
|
79
|
+
Integer(value, exception: false) ||
|
80
|
+
Float(value, exception: false) ||
|
81
|
+
(raise InputError, "Rating must be a number")
|
82
|
+
end
|
83
|
+
}
|
84
|
+
|
85
|
+
positive_operator = operator == :'!=' ? :== : operator
|
86
|
+
|
87
|
+
matches = items.select { |item|
|
88
|
+
ratings.any? { |rating|
|
89
|
+
if item.rating || %i[== !=].include?(operator)
|
90
|
+
item.rating.send(positive_operator, rating)
|
91
|
+
end
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
# Instead of using item.rating.send(operator, format) above, invert
|
96
|
+
# the matches here to ensure multiple values after a negative operator
|
97
|
+
# have an "and" relation: "not(x and y)", rather than "not(x or y)".
|
98
|
+
if operator == :'!='
|
99
|
+
matches = items - matches
|
100
|
+
end
|
101
|
+
|
102
|
+
matches
|
103
|
+
},
|
104
|
+
done: proc { |values, operator, items|
|
105
|
+
done_progresses = values.map { |value|
|
106
|
+
(value.match(/\A(\d+)?%/).captures.first.to_f.clamp(0.0, 100.0) / 100) ||
|
107
|
+
(raise InputError, "Progress must be a percentage")
|
108
|
+
}
|
109
|
+
|
110
|
+
filtered_items = items.map { |item|
|
111
|
+
# Ensure multiple values after a negative operator have an "and"
|
112
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
113
|
+
if operator == :'!='
|
114
|
+
item_done_progresses = item.experiences.map { |experience|
|
115
|
+
experience.spans.last.progress if experience.status == :done
|
116
|
+
}
|
117
|
+
|
118
|
+
next if (item_done_progresses - done_progresses).empty?
|
119
|
+
end
|
120
|
+
|
121
|
+
# Filter out non-matching experiences.
|
122
|
+
filtered_experiences = item.experiences.select { |experience|
|
123
|
+
done_progresses.any? { |done_progress|
|
124
|
+
experience.status == :done &&
|
125
|
+
experience.spans.last.progress.send(operator, done_progress)
|
126
|
+
}
|
127
|
+
}
|
128
|
+
|
129
|
+
item.with_experiences(filtered_experiences) if filtered_experiences.any?
|
130
|
+
}
|
131
|
+
.compact
|
132
|
+
|
133
|
+
filtered_items
|
134
|
+
},
|
135
|
+
format: proc { |values, operator, items|
|
136
|
+
formats = values.map { _1.to_sym if _1 }
|
137
|
+
|
138
|
+
filtered_items = items.map { |item|
|
139
|
+
# Treat empty variants as if they were a variant with a nil format.
|
140
|
+
if item.variants.empty?
|
141
|
+
if operator == :'!='
|
142
|
+
next item unless formats.include?(nil)
|
143
|
+
else
|
144
|
+
next item if formats.include?(nil)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Ensure multiple values after a negative operator have an "and"
|
149
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
150
|
+
if operator == :'!='
|
151
|
+
item_formats = item.variants.map(&:format)
|
152
|
+
|
153
|
+
next if (item_formats - formats).empty?
|
154
|
+
end
|
155
|
+
|
156
|
+
# Filter out non-matching variants.
|
157
|
+
filtered_variants = item.variants.select { |variant|
|
158
|
+
formats.any? { |format|
|
159
|
+
variant.format.send(operator, format)
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
item.with_variants(filtered_variants) if filtered_variants.any?
|
164
|
+
}
|
165
|
+
.compact
|
166
|
+
|
167
|
+
filtered_items
|
168
|
+
},
|
169
|
+
author: proc { |values, operator, items|
|
170
|
+
authors = values
|
171
|
+
.map { _1.downcase if _1 }
|
172
|
+
.map { _1.gsub(/[^a-zA-Z ]/, '').gsub(/\s/, '') if _1 }
|
173
|
+
|
174
|
+
matches = items.select { |item|
|
175
|
+
author = item
|
176
|
+
&.author
|
177
|
+
&.downcase
|
178
|
+
&.gsub(/[^a-zA-Z ]/, '')
|
179
|
+
&.gsub(/\s/, '')
|
180
|
+
|
181
|
+
if %i[include? exclude?].include? operator
|
182
|
+
authors.any? {
|
183
|
+
if _1.nil?
|
184
|
+
_1 == author
|
185
|
+
else
|
186
|
+
author.include?(_1) if author
|
187
|
+
end
|
188
|
+
}
|
189
|
+
else
|
190
|
+
authors.any? { author == _1 }
|
191
|
+
end
|
192
|
+
}
|
193
|
+
|
194
|
+
if %i[!= exclude?].include? operator
|
195
|
+
matches = items - matches
|
196
|
+
end
|
197
|
+
|
198
|
+
matches
|
199
|
+
},
|
200
|
+
title: proc { |values, operator, items|
|
201
|
+
titles = values
|
202
|
+
.map(&:downcase)
|
203
|
+
.map { _1.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '').gsub(/\s/, '') }
|
204
|
+
|
205
|
+
matches = items.select { |item|
|
206
|
+
next unless item.title
|
207
|
+
|
208
|
+
title = item
|
209
|
+
.title
|
210
|
+
.downcase
|
211
|
+
.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '')
|
212
|
+
.gsub(/\s/, '')
|
213
|
+
|
214
|
+
if %i[include? exclude?].include? operator
|
215
|
+
titles.any? { title.include? _1 }
|
216
|
+
else
|
217
|
+
titles.any? { title == _1 }
|
218
|
+
end
|
219
|
+
}
|
220
|
+
|
221
|
+
if %i[!= exclude?].include? operator
|
222
|
+
matches = items - matches
|
223
|
+
end
|
224
|
+
|
225
|
+
matches
|
226
|
+
},
|
227
|
+
series: proc { |values, operator, items|
|
228
|
+
format_name = ->(str) {
|
229
|
+
str
|
230
|
+
&.downcase
|
231
|
+
&.gsub(/[^a-zA-Z0-9 ]|\ba\b|\bthe\b/, '')
|
232
|
+
&.gsub(/\s/, '')
|
233
|
+
}
|
234
|
+
|
235
|
+
series_names = values.map { format_name.call(_1) }
|
236
|
+
|
237
|
+
filtered_items = items.map { |item|
|
238
|
+
# Treat empty variants as if they were a variant with no series.
|
239
|
+
if item.variants.empty?
|
240
|
+
if %i[!= exclude?].include? operator
|
241
|
+
next item unless series_names.include?(nil)
|
242
|
+
elsif %i[== include?].include? operator
|
243
|
+
next item if series_names.include?(nil)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
item_series_names = item.variants.flat_map { |variant|
|
248
|
+
variant.series.map { format_name.call(_1.name) }
|
249
|
+
}
|
250
|
+
|
251
|
+
# Ensure multiple values after a negative operator have an "and"
|
252
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
253
|
+
if %i[!= exclude?].include? operator
|
254
|
+
next if operator == :'!=' && (item_series_names - series_names).empty?
|
255
|
+
next if operator == :exclude? &&
|
256
|
+
item_series_names.all? { |item_series_name|
|
257
|
+
series_names.any? { |series_name|
|
258
|
+
if series_name.nil?
|
259
|
+
item_series_name == series_name
|
260
|
+
else
|
261
|
+
item_series_name.include?(series_name)
|
262
|
+
end
|
263
|
+
}
|
264
|
+
}
|
265
|
+
end
|
266
|
+
|
267
|
+
# Filter out non-matching variants.
|
268
|
+
filtered_variants = item.variants.select { |variant|
|
269
|
+
# Treat empty series as if they were a series with a nil name.
|
270
|
+
if variant.series.empty?
|
271
|
+
if %i[!= exclude?].include? operator
|
272
|
+
next variant unless series_names.include?(nil)
|
273
|
+
elsif %i[== include?].include? operator
|
274
|
+
next variant if series_names.include?(nil)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
variant.series.any? { |series|
|
279
|
+
item_series_name = format_name.call(series.name)
|
280
|
+
|
281
|
+
series_names.any? {
|
282
|
+
if _1.nil?
|
283
|
+
nil_operator = { include?: :==, exclude?: :'!=' }[operator]
|
284
|
+
end
|
285
|
+
|
286
|
+
item_series_name.send(nil_operator || operator, _1)
|
287
|
+
}
|
288
|
+
}
|
289
|
+
}
|
290
|
+
|
291
|
+
item.with_variants(filtered_variants) if filtered_variants.any?
|
292
|
+
}
|
293
|
+
.compact
|
294
|
+
|
295
|
+
filtered_items
|
296
|
+
},
|
297
|
+
source: proc { |values, operator, items|
|
298
|
+
sources = values.map { _1.downcase if _1 }
|
299
|
+
|
300
|
+
filtered_items = items.map { |item|
|
301
|
+
# Treat empty variants as if they were a variant with no sources.
|
302
|
+
if item.variants.empty?
|
303
|
+
if %i[!= exclude?].include? operator
|
304
|
+
next item unless sources.include?(nil)
|
305
|
+
elsif %i[== include?].include? operator
|
306
|
+
next item if sources.include?(nil)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
item_source_names_and_urls = item.variants.flat_map { |variant|
|
311
|
+
variant.sources.map { [_1.name&.downcase, _1.url&.downcase] }
|
312
|
+
}
|
313
|
+
|
314
|
+
# Ensure multiple values after a negative operator have an "and"
|
315
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
316
|
+
if %i[!= exclude?].include? operator
|
317
|
+
remainder_names_and_urls = item_source_names_and_urls.reject { |name, url|
|
318
|
+
name_nil_match = name.nil? && url.nil? && sources.include?(name)
|
319
|
+
url_nil_match = url.nil? && name.nil? && sources.include?(url)
|
320
|
+
|
321
|
+
(name.nil? ? name_nil_match : sources.include?(name)) ||
|
322
|
+
(url.nil? ? url_nil_match : sources.include?(url))
|
323
|
+
}
|
324
|
+
|
325
|
+
next if operator == :'!=' && remainder_names_and_urls.empty?
|
326
|
+
next if operator == :exclude? &&
|
327
|
+
item_source_names_and_urls.all? { |item_source_name_and_url|
|
328
|
+
sources.any? { |source|
|
329
|
+
item_source_name_and_url.any? {
|
330
|
+
_1.nil? ? _1 == source : _1.include?(source) if source
|
331
|
+
}
|
332
|
+
}
|
333
|
+
}
|
334
|
+
end
|
335
|
+
|
336
|
+
# Filter out non-matching variants.
|
337
|
+
filtered_variants = item.variants.select { |variant|
|
338
|
+
# Treat empty sources as if they were a source with a nil name.
|
339
|
+
if variant.sources.empty?
|
340
|
+
if %i[!= exclude?].include? operator
|
341
|
+
next variant unless sources.include?(nil)
|
342
|
+
elsif %i[== include?].include? operator
|
343
|
+
next variant if sources.include?(nil)
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
variant.sources.any? { |source|
|
348
|
+
sources.any? {
|
349
|
+
if _1.nil?
|
350
|
+
nil_operator = { include?: :==, exclude?: :'!=' }[operator]
|
351
|
+
end
|
352
|
+
|
353
|
+
source.name&.downcase&.send(nil_operator || operator, _1) ||
|
354
|
+
source.url&.downcase&.send(nil_operator || operator, _1)
|
355
|
+
}
|
356
|
+
}
|
357
|
+
}
|
358
|
+
|
359
|
+
item.with_variants(filtered_variants) if filtered_variants.any?
|
360
|
+
}
|
361
|
+
.compact
|
362
|
+
|
363
|
+
filtered_items
|
364
|
+
},
|
365
|
+
:'end-date' => proc { |values, operator, items|
|
366
|
+
end_date_ranges = values.map { |value|
|
367
|
+
match = value.match(DATES_REGEX) ||
|
368
|
+
(raise InputError,
|
369
|
+
"End date must be in yyyy/[mm] format, or a date range " \
|
370
|
+
"(yyyy/[mm]-[yyyy]/[mm])")
|
371
|
+
|
372
|
+
start_date = Date.new(
|
373
|
+
match[:start_year].to_i,
|
374
|
+
match[:start_month]&.to_i || 1,
|
375
|
+
1,
|
376
|
+
)
|
377
|
+
end_date = Date.new(
|
378
|
+
match[:end_year]&.to_i || start_date.year,
|
379
|
+
match[:end_month]&.to_i || match[:start_month]&.to_i || 12,
|
380
|
+
-1
|
381
|
+
)
|
382
|
+
|
383
|
+
start_date..end_date
|
384
|
+
}
|
385
|
+
|
386
|
+
end_date_ranges.each.with_index do |a, i_a|
|
387
|
+
end_date_ranges.each.with_index do |b, i_b|
|
388
|
+
next if i_a == i_b
|
389
|
+
|
390
|
+
if b.begin <= a.end && a.begin <= b.end
|
391
|
+
raise InputError, "Overlapping date ranges"
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
filtered_items = items.map { |item|
|
397
|
+
# Ensure multiple values after a negative operator have an "and"
|
398
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
399
|
+
if operator == :'!='
|
400
|
+
item_end_dates = item.experiences.map(&:last_end_date)
|
401
|
+
|
402
|
+
next if item_end_dates.all? { |item_end_date|
|
403
|
+
end_date_ranges.any? { |end_date_range|
|
404
|
+
end_date_range.include? item_end_date
|
405
|
+
}
|
406
|
+
}
|
407
|
+
end
|
408
|
+
|
409
|
+
# Filter out non-matching experiences.
|
410
|
+
filtered_experiences = item.experiences.select { |experience|
|
411
|
+
end_date_ranges.any? { |end_date_range|
|
412
|
+
if %i[== !=].include? operator
|
413
|
+
end_date_range
|
414
|
+
.include?(experience.last_end_date)
|
415
|
+
.send(operator, true)
|
416
|
+
elsif %i[< >=].include? operator
|
417
|
+
experience.last_end_date.send(operator, end_date_range.begin)
|
418
|
+
elsif %i[> <=].include? operator
|
419
|
+
experience.last_end_date.send(operator, end_date_range.end)
|
420
|
+
end
|
421
|
+
}
|
422
|
+
}
|
423
|
+
|
424
|
+
item.with_experiences(filtered_experiences) if filtered_experiences.any?
|
425
|
+
}
|
426
|
+
.compact
|
427
|
+
|
428
|
+
filtered_items
|
429
|
+
},
|
430
|
+
date: proc { |values, operator, items|
|
431
|
+
date_ranges = values.map { |value|
|
432
|
+
match = value.match(DATES_REGEX) ||
|
433
|
+
(raise InputError,
|
434
|
+
"Date must be in yyyy/[mm] format, or a date range " \
|
435
|
+
"(yyyy/[mm]-[yyyy]/[mm])")
|
436
|
+
|
437
|
+
start_date = Date.new(
|
438
|
+
match[:start_year].to_i,
|
439
|
+
match[:start_month]&.to_i || 1,
|
440
|
+
1,
|
441
|
+
)
|
442
|
+
end_date = Date.new(
|
443
|
+
match[:end_year]&.to_i || start_date.year,
|
444
|
+
match[:end_month]&.to_i || match[:start_month]&.to_i || 12,
|
445
|
+
-1
|
446
|
+
)
|
447
|
+
|
448
|
+
start_date..end_date
|
449
|
+
}
|
450
|
+
|
451
|
+
date_ranges.each.with_index do |a, i_a|
|
452
|
+
date_ranges.each.with_index do |b, i_b|
|
453
|
+
next if i_a == i_b
|
454
|
+
|
455
|
+
if b.begin <= a.end && a.begin <= b.end
|
456
|
+
raise InputError, "Overlapping date ranges"
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
filtered_items = items.map { |item|
|
462
|
+
case operator
|
463
|
+
when :==
|
464
|
+
split_items = date_ranges.sort_by(&:begin).flat_map { |date_range|
|
465
|
+
without_before = item.split(date_range.end.next_day).first
|
466
|
+
without_before_or_after = without_before&.split(date_range.begin)&.last
|
467
|
+
|
468
|
+
without_before_or_after
|
469
|
+
}
|
470
|
+
.compact
|
471
|
+
when :'!='
|
472
|
+
split_item = item
|
473
|
+
|
474
|
+
date_ranges.each do |date_range|
|
475
|
+
before = split_item.split(date_range.begin).first
|
476
|
+
after = split_item.split(date_range.end.next_day).last
|
477
|
+
|
478
|
+
split_item = split_item.with_experiences(
|
479
|
+
(before&.experiences || []) +
|
480
|
+
(after&.experiences || [])
|
481
|
+
)
|
482
|
+
end
|
483
|
+
|
484
|
+
split_items = [split_item]
|
485
|
+
when :<=
|
486
|
+
split_items = [item.split(date_ranges[0].end.next_day).first]
|
487
|
+
when :>=
|
488
|
+
split_items = [item.split(date_ranges[0].begin).last]
|
489
|
+
when :<
|
490
|
+
split_items = [item.split(date_ranges[0].begin).first]
|
491
|
+
when :>
|
492
|
+
split_items = [item.split(date_ranges[0].end.next_day).last]
|
493
|
+
end
|
494
|
+
|
495
|
+
split_items.reduce { |merged, split_item|
|
496
|
+
merged.with_experiences(merged.experiences + split_item.experiences)
|
497
|
+
}
|
498
|
+
}
|
499
|
+
.compact
|
500
|
+
|
501
|
+
filtered_items
|
502
|
+
},
|
503
|
+
experience: proc { |values, operator, items|
|
504
|
+
experience_counts = values.map { |value|
|
505
|
+
Integer(value, exception: false) ||
|
506
|
+
(raise InputError, "Experience count must be an integer")
|
507
|
+
}
|
508
|
+
|
509
|
+
positive_operator = operator == :'!=' ? :== : operator
|
510
|
+
|
511
|
+
matches = items.select { |item|
|
512
|
+
experience_counts.any? { |experience_count|
|
513
|
+
item.experiences.count.send(positive_operator, experience_count)
|
514
|
+
}
|
515
|
+
}
|
516
|
+
|
517
|
+
if operator == :'!='
|
518
|
+
matches = items - matches
|
519
|
+
end
|
520
|
+
|
521
|
+
matches
|
522
|
+
},
|
523
|
+
status: proc { |values, operator, items|
|
524
|
+
statuses = values.map { _1.squeeze(' ').gsub(' ', '_').to_sym }
|
525
|
+
|
526
|
+
filtered_items = items.map { |item|
|
527
|
+
# Ensure multiple values after a negative operator have an "and"
|
528
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
529
|
+
if operator == :'!='
|
530
|
+
item_statuses = item.experiences.map(&:status).presence || [:planned]
|
531
|
+
|
532
|
+
next unless (item_statuses - statuses).any?
|
533
|
+
end
|
534
|
+
|
535
|
+
# Check for a match on a planned Item (no experiences).
|
536
|
+
is_planned = item.experiences.empty?
|
537
|
+
next item if is_planned && statuses.include?(:planned)
|
538
|
+
|
539
|
+
# Filter out non-matching experiences.
|
540
|
+
filtered_experiences = item.experiences.select { |experience|
|
541
|
+
statuses.any? { |status|
|
542
|
+
experience.status.send(operator, status)
|
543
|
+
}
|
544
|
+
}
|
545
|
+
|
546
|
+
item.with_experiences(filtered_experiences) if filtered_experiences.any?
|
547
|
+
}
|
548
|
+
.compact
|
549
|
+
|
550
|
+
filtered_items
|
551
|
+
},
|
552
|
+
genre: proc { |values, operator, items|
|
553
|
+
genres = values.map { _1 ? _1.split('+').map(&:strip) : [_1] }
|
554
|
+
|
555
|
+
matches = items.select { |item|
|
556
|
+
genres.any? { |and_genres|
|
557
|
+
# Whether item.genres includes all elements of and_genres.
|
558
|
+
(item.genres.sort & and_genres.sort) == and_genres.sort ||
|
559
|
+
(item.genres.empty? && genres.include?([nil]))
|
560
|
+
}
|
561
|
+
}
|
562
|
+
|
563
|
+
if operator == :'!='
|
564
|
+
matches = items - matches
|
565
|
+
end
|
566
|
+
|
567
|
+
matches
|
568
|
+
},
|
569
|
+
length: proc { |values, operator, items|
|
570
|
+
lengths = values.map { |value|
|
571
|
+
if value
|
572
|
+
Integer(value, exception: false) ||
|
573
|
+
Item::TimeLength.parse(value) ||
|
574
|
+
(raise InputError, "Length must be a number of pages or time as hh:mm")
|
575
|
+
end
|
576
|
+
}
|
577
|
+
|
578
|
+
filtered_items = items.map { |item|
|
579
|
+
# Treat empty variants as if they were a variant with a nil length.
|
580
|
+
if item.variants.empty?
|
581
|
+
if operator == :'!='
|
582
|
+
next item unless lengths.include?(nil)
|
583
|
+
else
|
584
|
+
next item if lengths.include?(nil)
|
585
|
+
end
|
586
|
+
end
|
587
|
+
|
588
|
+
# Ensure multiple values after a negative operator have an "and"
|
589
|
+
# relation: "not(x and y)", rather than "not(x or y)".
|
590
|
+
if operator == :'!='
|
591
|
+
item_lengths = item.variants.map(&:length)
|
592
|
+
|
593
|
+
next if (item_lengths - lengths).empty?
|
594
|
+
end
|
595
|
+
|
596
|
+
# Filter out non-matching variants.
|
597
|
+
filtered_variants = item.variants.select { |variant|
|
598
|
+
lengths.any? { |length|
|
599
|
+
variant.length.send(operator, length)
|
600
|
+
}
|
601
|
+
}
|
602
|
+
|
603
|
+
item.with_variants(filtered_variants) if filtered_variants.any?
|
604
|
+
}
|
605
|
+
.compact
|
606
|
+
|
607
|
+
filtered_items
|
608
|
+
},
|
609
|
+
note: proc { |values, operator, items|
|
610
|
+
notes = values
|
611
|
+
.map { _1.downcase if _1 }
|
612
|
+
.map { _1.gsub(/[^a-zA-Z0-9 ]/, '') if _1 }
|
613
|
+
|
614
|
+
matches = items.select { |item|
|
615
|
+
item.notes.any? { |original_note|
|
616
|
+
note = original_note
|
617
|
+
.downcase
|
618
|
+
.gsub(/[^a-zA-Z0-9 ]/, '')
|
619
|
+
|
620
|
+
if %i[include? exclude?].include? operator
|
621
|
+
notes.any? { _1.nil? ? note == _1 : note.include?(_1) }
|
622
|
+
else
|
623
|
+
notes.any? { note == _1 }
|
624
|
+
end
|
625
|
+
} || (item.notes.empty? && notes.include?(nil))
|
626
|
+
}
|
627
|
+
|
628
|
+
if %i[!= exclude?].include? operator
|
629
|
+
matches = items - matches
|
630
|
+
end
|
631
|
+
|
632
|
+
matches
|
633
|
+
},
|
634
|
+
}
|
635
|
+
|
636
|
+
NUMERIC_OPERATORS = {
|
637
|
+
rating: true,
|
638
|
+
done: true,
|
639
|
+
progress: true,
|
640
|
+
experience: true,
|
641
|
+
date: true,
|
642
|
+
:'end-date' => true,
|
643
|
+
length: true,
|
644
|
+
}
|
645
|
+
|
646
|
+
PROHIBIT_INCLUDE_EXCLUDE_OPERATORS = {
|
647
|
+
format: true,
|
648
|
+
status: true,
|
649
|
+
genre: true,
|
650
|
+
}
|
651
|
+
|
652
|
+
PROHIBIT_NONE_VALUE = {
|
653
|
+
done: true,
|
654
|
+
title: true,
|
655
|
+
:'end-date' => true,
|
656
|
+
date: true,
|
657
|
+
experience: true,
|
658
|
+
status: true,
|
659
|
+
}
|
660
|
+
|
661
|
+
PROHIBIT_MULTIPLE_VALUES_AFTER_NOT = {
|
662
|
+
done: true,
|
663
|
+
title: true,
|
664
|
+
:'end-date' => true,
|
665
|
+
date: true,
|
666
|
+
experience: true,
|
667
|
+
status: true,
|
668
|
+
}
|
669
|
+
|
670
|
+
REGEXES = ACTIONS.map { |key, _action|
|
671
|
+
regex =
|
672
|
+
%r{\A
|
673
|
+
\s*
|
674
|
+
#{key}
|
675
|
+
e?s?
|
676
|
+
(?<operator>!=|=|!~|~|>=|>|<=|<)
|
677
|
+
(?<predicate>.+)
|
678
|
+
\z}x
|
679
|
+
|
680
|
+
[key, regex]
|
681
|
+
}.to_h
|
682
|
+
|
683
|
+
# Applies a single filter to an array of Items.
|
684
|
+
# @param key [Symbol] the filter's key in the constants above.
|
685
|
+
# @param predicate [String] the input value(s) after the operator.
|
686
|
+
# @param operator_str [String] from the input.
|
687
|
+
# @param items [Array<Item>]
|
688
|
+
# @return [Array<Item>] a subset of the given Items.
|
689
|
+
private_class_method def self.filter_single(key, predicate, operator_str, items)
|
690
|
+
filtered_items = []
|
691
|
+
|
692
|
+
if NUMERIC_OPERATORS[key]
|
693
|
+
allowed_operators = %w[= != > >= < <=]
|
694
|
+
elsif PROHIBIT_INCLUDE_EXCLUDE_OPERATORS[key]
|
695
|
+
allowed_operators = %w[= !=]
|
696
|
+
else
|
697
|
+
allowed_operators = %w[= != ~ !~]
|
698
|
+
end
|
699
|
+
|
700
|
+
unless allowed_operators.include? operator_str
|
701
|
+
raise InputError, "Operator \"#{operator_str}\" not allowed in the " \
|
702
|
+
"\"#{key}\" filter, only #{allowed_operators.join(', ')} allowed"
|
703
|
+
end
|
704
|
+
|
705
|
+
operator = operator_str.to_sym
|
706
|
+
operator = :== if operator == :'='
|
707
|
+
operator = :include? if operator == :~
|
708
|
+
operator = :exclude? if operator == :'!~'
|
709
|
+
|
710
|
+
values = predicate
|
711
|
+
.split(',')
|
712
|
+
.map(&:strip)
|
713
|
+
.map { _1.downcase == 'none' ? nil : _1 }
|
714
|
+
|
715
|
+
# if values.count > 1 && operator == :'!=' && PROHIBIT_MULTIPLE_VALUES_AFTER_NOT[key]
|
716
|
+
# end
|
717
|
+
|
718
|
+
if values.count > 1 && %i[> < >= <=].include?(operator)
|
719
|
+
raise InputError, "Multiple values not allowed after the operators >, <, >=, or <="
|
720
|
+
end
|
721
|
+
|
722
|
+
if values.include?(nil) && !%i[== != include? exclude?].include?(operator)
|
723
|
+
raise InputError,
|
724
|
+
"\"none\" can only be used after the operators ==, !=, ~, !~"
|
725
|
+
end
|
726
|
+
|
727
|
+
if values.any?(&:nil?) && PROHIBIT_NONE_VALUE[key]
|
728
|
+
raise InputError, "The \"#{key}\" filter cannot take a \"none\" value"
|
729
|
+
end
|
730
|
+
|
731
|
+
matched_items = ACTIONS[key].call(values, operator, items)
|
732
|
+
filtered_items += matched_items
|
733
|
+
|
734
|
+
filtered_items.uniq
|
735
|
+
end
|
736
|
+
end
|
737
|
+
end
|
738
|
+
end
|