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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +95 -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 +324 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +17 -7
  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 +16 -10
  25. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
  26. data/lib/reading/parsing/transformer.rb +13 -17
  27. data/lib/reading/stats/filter.rb +738 -0
  28. data/lib/reading/stats/grouping.rb +257 -0
  29. data/lib/reading/stats/operation.rb +345 -0
  30. data/lib/reading/stats/query.rb +37 -0
  31. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  32. data/lib/reading/util/exclude.rb +12 -0
  33. data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
  34. data/lib/reading/util/hash_to_data.rb +2 -2
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +36 -21
  37. metadata +28 -24
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/string_remove.rb +0 -28
  40. 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