reading 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +27 -5
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/item/time_length.rb +60 -23
  6. data/lib/reading/item/view.rb +14 -19
  7. data/lib/reading/item.rb +321 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +13 -6
  14. data/lib/reading/parsing/attributes/variants.rb +9 -6
  15. data/lib/reading/parsing/csv.rb +38 -35
  16. data/lib/reading/parsing/parser.rb +23 -24
  17. data/lib/reading/parsing/rows/blank.rb +23 -0
  18. data/lib/reading/parsing/rows/comment.rb +6 -7
  19. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  20. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  21. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  22. data/lib/reading/parsing/rows/regular.rb +15 -14
  23. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  24. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  25. data/lib/reading/parsing/transformer.rb +13 -17
  26. data/lib/reading/stats/filter.rb +738 -0
  27. data/lib/reading/stats/grouping.rb +243 -0
  28. data/lib/reading/stats/operation.rb +313 -0
  29. data/lib/reading/stats/query.rb +37 -0
  30. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  31. data/lib/reading/util/exclude.rb +12 -0
  32. data/lib/reading/util/hash_to_data.rb +2 -2
  33. data/lib/reading/version.rb +1 -1
  34. data/lib/reading.rb +36 -21
  35. metadata +10 -6
  36. data/bin/readingfile +0 -31
  37. data/lib/reading/util/string_remove.rb +0 -28
  38. data/lib/reading/util/string_truncate.rb +0 -22
@@ -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