reading 0.6.1 → 0.8.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +5 -5
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +96 -108
  5. data/lib/reading/errors.rb +10 -66
  6. data/lib/reading/filter.rb +95 -0
  7. data/lib/reading/item/time_length.rb +140 -0
  8. data/lib/reading/item/view.rb +121 -0
  9. data/lib/reading/item.rb +117 -0
  10. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  11. data/lib/reading/parsing/attributes/author.rb +15 -0
  12. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  13. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  14. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  15. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  16. data/lib/reading/parsing/attributes/genres.rb +16 -0
  17. data/lib/reading/parsing/attributes/notes.rb +22 -0
  18. data/lib/reading/parsing/attributes/rating.rb +17 -0
  19. data/lib/reading/parsing/attributes/shared.rb +62 -0
  20. data/lib/reading/parsing/attributes/title.rb +21 -0
  21. data/lib/reading/parsing/attributes/variants.rb +77 -0
  22. data/lib/reading/parsing/csv.rb +112 -0
  23. data/lib/reading/parsing/parser.rb +292 -0
  24. data/lib/reading/parsing/rows/column.rb +131 -0
  25. data/lib/reading/parsing/rows/comment.rb +26 -0
  26. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  27. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  28. data/lib/reading/parsing/rows/regular.rb +33 -0
  29. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  30. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  31. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  32. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  33. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  34. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  35. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  36. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  37. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  38. data/lib/reading/parsing/transformer.rb +70 -0
  39. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  40. data/lib/reading/util/hash_deep_merge.rb +1 -1
  41. data/lib/reading/util/hash_to_data.rb +30 -0
  42. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  43. data/lib/reading/util/string_truncate.rb +13 -4
  44. data/lib/reading/version.rb +1 -1
  45. data/lib/reading.rb +49 -0
  46. metadata +76 -42
  47. data/lib/reading/attribute/all_attributes.rb +0 -83
  48. data/lib/reading/attribute/attribute.rb +0 -25
  49. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  50. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  51. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  52. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  53. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  54. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  55. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  56. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  57. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  58. data/lib/reading/csv.rb +0 -76
  59. data/lib/reading/line.rb +0 -23
  60. data/lib/reading/row/blank_row.rb +0 -23
  61. data/lib/reading/row/compact_planned_row.rb +0 -130
  62. data/lib/reading/row/regular_row.rb +0 -99
  63. data/lib/reading/row/row.rb +0 -88
  64. data/lib/reading/util/hash_to_struct.rb +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4145847e60a6045a1381afe207be44d30636ca7b058d4c1c1e0db70009af0b4
4
- data.tar.gz: 833b042be4b575e4ed166d0269610504c031d03460a0f75c4dedc214b1eaf152
3
+ metadata.gz: ecba33e4bbdb2dd11482113bf46d6196b17c89e2274e574b2286818f309c9ccb
4
+ data.tar.gz: 95d094b19fd4509e8608f5db414062e633ba6665c949526cd34c271314db6845
5
5
  SHA512:
6
- metadata.gz: '09f08e0be8d6cd97481097d4193209af3cda404e076f2d5529c7c4bdee5f146afbf980a2cb05f2435c06633b89954067ee8bea9d02dae4eddd924f86626a9f63'
7
- data.tar.gz: 3a50b075c63f2a8b0082b309bef201a8351344c729e90b18482b5796a53bedee1af7fad71af3a0dd884d1245440cfd14dbf6d28aa38bdf5d09717059bd22e7f8
6
+ metadata.gz: ccfaace79d20ab57ab73349732735858049f217f3d0ad86408ae49b0f0f20591032dffd30e30d3f64d8c2644561d0b68e609e07398f3923fc85f4d25421504a6
7
+ data.tar.gz: 578c7f8389d12eda16a30f2d702a944a92dd931e62fa4db4908521ca1d1a01ca0fa0100d96b32f6e624fec1e7abf0f907417fc3043ce0aabe605fdde5b2c181c
data/bin/reading CHANGED
@@ -4,15 +4,16 @@
4
4
  #
5
5
  # Usage:
6
6
  # Run on the command line:
7
- # reading "<CSV string>" "<optional comma-separated names of enabled columns>`
7
+ # reading "<CSV string>" "<optional comma-separated names of enabled columns>"
8
8
  #
9
9
  # Examples:
10
10
  # reading '3|📕Trying|Little Library 1970147288'
11
11
  # reading '📕Trying|Little Library 1970147288' 'head, sources'
12
12
 
13
13
 
14
- require_relative "../lib/reading/csv"
14
+ require_relative "../lib/reading"
15
15
  require "amazing_print"
16
+ require "debug"
16
17
 
17
18
  input = ARGV[0]
18
19
  unless input
@@ -22,10 +23,9 @@ end
22
23
  config = {}
23
24
  if ARGV[1]
24
25
  enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
25
- config[:csv] = { enabled_columns: }
26
+ config = { enabled_columns: }
26
27
  end
27
28
 
28
- csv = Reading::CSV.new(input, config:)
29
- items = csv.parse
29
+ items = Reading.parse(stream: input, config:, hash_output: true)
30
30
 
31
31
  ap items
data/bin/readingfile ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # A script that provides a quick way to see the output of a CSV file.
4
+ #
5
+ # Usage:
6
+ # Run on the command line:
7
+ # reading "<file path>" "<optional comma-separated names of enabled columns>"
8
+ #
9
+ # Examples:
10
+ # reading '/home/felipe/reading.csv'
11
+ # reading '/home/felipe/reading.csv' 'head, sources'
12
+
13
+
14
+ require_relative "../lib/reading"
15
+ require "amazing_print"
16
+ require "debug"
17
+
18
+ path = ARGV[0]
19
+ unless path
20
+ raise ArgumentError, "CSV path argument required, such as '/home/felipe/reading.csv'"
21
+ end
22
+
23
+ config = {}
24
+ if ARGV[1]
25
+ enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
26
+ config = { enabled_columns: }
27
+ end
28
+
29
+ items = Reading.parse(path, config:, hash_output: true)
30
+
31
+ ap items
@@ -1,3 +1,7 @@
1
+ require_relative "util/hash_deep_merge"
2
+ require_relative "util/hash_array_deep_fetch"
3
+ require_relative "errors"
4
+
1
5
  module Reading
2
6
  # Builds a hash config.
3
7
  class Config
@@ -7,7 +11,7 @@ module Reading
7
11
  attr_reader :hash
8
12
 
9
13
  # @param custom_config [Hash] a custom config which overrides the defaults,
10
- # e.g. { errors: { styling: :html } }
14
+ # e.g. { enabled_columns: [:head, :end_dates] }
11
15
  def initialize(custom_config = {})
12
16
  @custom_config = custom_config
13
17
 
@@ -21,61 +25,109 @@ module Reading
21
25
  def build_hash
22
26
  @hash = default_config.deep_merge(@custom_config)
23
27
 
24
- # If custom formats are given, use only the custom formats. #dig is used here
25
- # (not #deep_fetch as most elsewhere) because custom_config may not include this data.
26
- if @custom_config[:item] && @custom_config.dig(:item, :formats)
27
- @hash[:item][:formats] = @custom_config.dig(:item, :formats)
28
+ # If custom formats are given, use only the custom formats.
29
+ if @custom_config.has_key?(:formats)
30
+ @hash[:formats] = @custom_config[:formats]
31
+ end
32
+
33
+ # Ensure enabled_columns includes :head, and sort them.
34
+ enabled_columns =
35
+ (@hash.fetch(:enabled_columns) + [:head])
36
+ .uniq
37
+ .sort_by { |col| default_config[:enabled_columns].index(col) || 0 }
38
+
39
+ invalid_columns = enabled_columns - default_config[:enabled_columns]
40
+ if invalid_columns.any?
41
+ raise ConfigError, "Invalid columns in custom config: #{invalid_columns.join(", ")}"
28
42
  end
29
43
 
30
- # Validate enabled_columns
31
- enabled_columns = @hash.deep_fetch(:csv, :enabled_columns)
32
- enabled_columns << :head
33
- enabled_columns.uniq!
34
- enabled_columns.sort_by! { |col| default_config.deep_fetch(:csv, :enabled_columns).index(col) }
44
+ @hash[:enabled_columns] = enabled_columns
35
45
 
36
- # Add the Regex config, which is built based on the config so far.
37
- @hash[:csv][:regex] = build_regex_config
46
+ # Add the regex config, which is built based on the config so far.
47
+ @hash[:regex] = regex_config
38
48
  end
39
49
 
40
50
  # The default config, excluding Regex config (see further down).
41
51
  # @return [Hash]
42
52
  def default_config
43
53
  {
44
- errors:
54
+ comment_character: "\\",
55
+ column_separator: "|",
56
+ ignored_characters: "✅❌💲❓⏳",
57
+ skip_compact_planned: false,
58
+ # The Head column is always enabled; the others can be disabled by
59
+ # using a custom config that omits columns from this array.
60
+ enabled_columns:
61
+ %i[
62
+ rating
63
+ head
64
+ sources
65
+ start_dates
66
+ end_dates
67
+ genres
68
+ length
69
+ notes
70
+ history
71
+ ],
72
+ # If your custom config includes formats, they will replace the defaults
73
+ # (unlike the rest of the config, to which custom config is deep merged).
74
+ # So if you want to keep any of these defaults, include them in your config.
75
+ formats:
76
+ {
77
+ print: "📕",
78
+ ebook: "⚡",
79
+ audiobook: "🔊",
80
+ pdf: "📄",
81
+ audio: "🎤",
82
+ video: "🎞️",
83
+ course: "🏫",
84
+ piece: "✏️",
85
+ website: "🌐",
86
+ },
87
+ source_names_from_urls:
45
88
  {
46
- handle_error: -> (error) { puts error },
47
- max_length: 100, # or require "io/console", then IO.console.winsize[1]
48
- catch_all_errors: false, # set this to false during development.
49
- styling: :terminal, # or :html
89
+ "youtube.com" => "YouTube",
90
+ "youtu.be" => "YouTube",
91
+ "books.google.com" => "Google Books",
92
+ "archive.org" => "Internet Archive",
93
+ "thegreatcourses.com" => "The Great Courses",
94
+ "librivox.org" => "LibriVox",
95
+ "tv.apple.com" => "Apple TV",
50
96
  },
51
97
  item:
52
98
  {
53
- formats:
99
+ # After how many days of no activity an item of indefinite length
100
+ # (e.g. a podcast) should change its status from :in_progress to :done.
101
+ indefinite_in_progress_grace_period_days: 30,
102
+ view:
54
103
  {
55
- print: "📕",
56
- ebook: "",
57
- audiobook: "🔊",
58
- pdf: "📄",
59
- audio: "🎤",
60
- video: "🎞️",
61
- course: "🏫",
62
- piece: "✏️",
63
- website: "🌐",
64
- },
65
- sources:
66
- {
67
- names_from_urls:
104
+ name_separator: "",
105
+ url_from_isbn: "https://www.goodreads.com/book/isbn?isbn=%{isbn}",
106
+ # Items rated this or above get a star. If nil, number ratings are shown instead.
107
+ minimum_rating_for_star: 5,
108
+ types:
68
109
  {
69
- "youtube.com" => "YouTube",
70
- "youtu.be" => "YouTube",
71
- "books.google.com" => "Google Books",
72
- "archive.org" => "Internet Archive",
73
- "thegreatcourses.com" => "The Great Courses",
74
- "librivox.org" => "LibriVox",
75
- "tv.apple.com" => "Apple TV",
110
+ book: { emoji: "📕", from_formats: %i[print ebook audiobook pdf] },
111
+ course: { emoji: "🏫", from_formats: %i[website] },
112
+ piece: { emoji: "✏️" },
113
+ video: { emoji: "🎞️" },
114
+ audio: { emoji: "🎤" },
76
115
  },
77
- default_name_for_url: "site",
116
+ default_type: :book,
78
117
  },
118
+ # The structure of an item, along with default values.
119
+ # Wherever an array of hashes ends up with no data (i.e. equal to the
120
+ # value in the template), it is collapsed into an empty array.
121
+ # E.g. the row "|Dracula||🤝🏼book club" is parsed to an Item analogous to:
122
+ # {
123
+ # rating: nil,
124
+ # author: nil,
125
+ # title: "Dracula",
126
+ # genres: [],
127
+ # variants: [],
128
+ # experiences: [{ spans: [], group: "book club", variant_index: 0 }],
129
+ # notes: [],
130
+ # }
79
131
  template:
80
132
  {
81
133
  rating: nil,
@@ -104,7 +156,7 @@ module Reading
104
156
  spans:
105
157
  [{
106
158
  dates: nil,
107
- amount: nil,
159
+ amount: 0,
108
160
  progress: nil,
109
161
  name: nil,
110
162
  favorite?: false,
@@ -120,83 +172,19 @@ module Reading
120
172
  }],
121
173
  },
122
174
  },
123
- csv:
124
- {
125
- # The Head column is always enabled; the others can be disabled by
126
- # using a custom config that omits columns from this array.
127
- enabled_columns:
128
- %i[
129
- rating
130
- head
131
- sources
132
- dates_started
133
- dates_finished
134
- genres
135
- length
136
- notes
137
- history
138
- ],
139
- # Custom columns are listed in a hash with default values, like simple columns in item[:template] above.
140
- custom_numeric_columns: {}, # e.g. { family_friendliness: 5, surprise_factor: nil }
141
- custom_text_columns: {}, # e.g. { mood: nil, rec_by: nil, will_reread: "no" }
142
- comment_character: "\\",
143
- column_separator: "|",
144
- separator: ",",
145
- short_separator: " - ",
146
- long_separator: " -- ",
147
- date_separator: "/",
148
- date_range_separator: "..",
149
- dnf_string: "DNF",
150
- series_prefix: "in",
151
- group_emoji: "🤝🏼",
152
- blurb_emoji: "💬",
153
- private_emoji: "🔒",
154
- compact_planned_source_prefix: "@",
155
- compact_planned_ignored_chars: "✅💲❓⏳⭐",
156
- skip_compact_planned: false,
157
- },
158
175
  }
159
176
  end
160
177
 
161
- # Builds the Regex portion of the config, based on the given config.
178
+ # Builds the regex portion of the config, based on the config so far.
162
179
  # @return [Hash]
163
- def build_regex_config
164
- return @hash[:csv][:regex] if @hash.dig(:csv, :regex)
165
-
166
- comment_character = Regexp.escape(@hash.deep_fetch(:csv, :comment_character))
167
- formats = @hash.deep_fetch(:item, :formats).values.join("|")
168
- dnf_string = Regexp.escape(@hash.deep_fetch(:csv, :dnf_string))
169
- compact_planned_ignored_chars = (
170
- @hash.deep_fetch(:csv, :compact_planned_ignored_chars).chars - [" "]
171
- ).join("|")
172
- time_length = /(?<time>\d+:\d\d)/
173
- pages_length = /p?(?<pages>\d+)p?/
174
- url = /https?:\/\/[^\s#{@hash.deep_fetch(:csv, :separator)}]+/
175
- date_sep = @hash.deep_fetch(:csv, :date_separator)
180
+ def regex_config
181
+ return @hash[:regex] if @hash.has_key?(:regex)
176
182
 
177
- isbn_lookbehind = "(?<=\\A|\\s|#{@hash.deep_fetch(:csv, :separator)})"
178
- isbn_lookahead = "(?=\\z|\\s|#{@hash.deep_fetch(:csv, :separator)})"
179
- isbn_bare_regex = /(?:\d{3}[-\s]?)?[A-Z\d]{10}/ # also includes ASIN
180
- isbn = /#{isbn_lookbehind}#{isbn_bare_regex.source}#{isbn_lookahead}/
183
+ formats = @hash.fetch(:formats).values.join("|")
181
184
 
182
185
  {
183
- compact_planned_row_start: /\A\s*#{comment_character}\s*(?:(?<genres>[^a-z@:\|]+)?\s*(?<sources>@[^\|]+)?\s*:)?\s*(?=#{formats})/,
184
- compact_planned_item: /\A(?<format_emoji>(?:#{formats}))(?<author_title>[^@\|]+)(?<sources>@.+)?(?:\|(?<sources_column>.+))?\z/,
185
186
  formats: /#{formats}/,
186
187
  formats_split: /\s*(?:,|--)?\s*(?=#{formats})/,
187
- compact_planned_ignored_chars: /#{compact_planned_ignored_chars}/,
188
- series_volume: /,\s*#(\d+)\z/,
189
- isbn: isbn,
190
- url: url,
191
- dnf: /\A\s*(#{dnf_string})/,
192
- progress: /(?<=#{dnf_string}|\A)\s*(?:(?<percent>\d?\d)%|#{time_length}|#{pages_length})\s+/,
193
- group_experience: /#{@hash.deep_fetch(:csv, :group_emoji)}\s*(.*)\s*\z/,
194
- variant_index: /\s+v(\d+)/,
195
- date: /\d{4}#{date_sep}\d?\d#{date_sep}\d?\d/,
196
- time_length: /\A#{time_length}(?<each>\s+each)?\z/,
197
- time_length_in_variant: time_length,
198
- pages_length: /\A#{pages_length}(?<each>\s+each)?\z/,
199
- pages_length_in_variant: /(?:\A|\s+|p)(?<pages>\d{1,9})(?:p|\s+|\z)/, # to exclude ISBN-10 and ISBN-13
200
188
  }
201
189
  end
202
190
  end
@@ -1,80 +1,24 @@
1
- require "pastel"
2
-
3
1
  module Reading
4
- # The base error class, which provides flexible error handling.
5
- class Error < StandardError
6
- using Util::StringTruncate
7
-
8
- # Handles this error based on config settings, and augments the error message
9
- # with styling and the line from the file. All this is handled here so that
10
- # the parser doesn't have to know all these things at the error's point of origin.
11
- # @param line [Reading::Line] the CSV line, through which the CSV config and
12
- # line string are accessed.
13
- def handle(line:)
14
- errors_config = line.csv.config.fetch(:errors)
15
- styled_error = styled_with_line(line.string, errors_config)
16
-
17
- handle = errors_config.fetch(:handle_error)
18
- handle.call(styled_error)
19
- end
20
-
21
- protected
22
-
23
- # Can be overridden in subclasses, e.g. yellow for a warning.
24
- def color
25
- :red
26
- end
27
-
28
- # Creates a new error having a message augmented with styling and the line string.
29
- # @return [AppError]
30
- def styled_with_line(line_string, errors_config)
31
- truncated_line = line_string.truncate(
32
- errors_config.fetch(:max_length),
33
- padding: message.length,
34
- )
35
-
36
- styled_message = case errors_config.fetch(:styling)
37
- when :terminal
38
- COLORS.send("bright_#{color}").bold(message)
39
- when :html
40
- "<rl-error class=\"#{color}\">#{message}</rl-error>"
41
- end
42
-
43
- self.class.new("#{styled_message}: #{truncated_line}")
44
- end
45
-
46
- private
47
-
48
- COLORS = Pastel.new
49
- end
50
-
51
- # FILE # # # # # # # # # # # # # # # # # # # # # # # # # #
2
+ class Error < StandardError; end
52
3
 
53
4
  # Means there was a problem accessing a file.
54
5
  class FileError < Reading::Error; end
55
6
 
56
- # MISC # # # # # # # # # # # # # # # # # # # # # # # # # #
57
-
58
- # Means the user-supplied custom config is invalid.
7
+ # Means there is something wrong with the user-supplied custom config.
59
8
  class ConfigError < Reading::Error; end
60
9
 
61
- # VALIDATION # # # # # # # # # # # # # # # # # # # # # # #
10
+ # Means unexpected input was encountered during parsing.
11
+ class ParsingError < Reading::Error; end
12
+
13
+ # Means something in the Head column (author, title, etc.) is invalid.
14
+ class InvalidHeadError < Reading::Error; end
15
+
16
+ # Means something in the History column is invalid.
17
+ class InvalidHistoryError < Reading::Error; end
62
18
 
63
19
  # Means there are too many columns in a row.
64
20
  class TooManyColumnsError < Reading::Error; end
65
21
 
66
22
  # Means a date is unparsable, or a set of dates does not make logical sense.
67
23
  class InvalidDateError < Reading::Error; end
68
-
69
- # Means something in the Source column is invalid.
70
- class InvalidSourceError < Reading::Error; end
71
-
72
- # Means something in the Head column (author, title, etc.) is invalid.
73
- class InvalidHeadError < Reading::Error; end
74
-
75
- # Means the Rating column can't be parsed as a number.
76
- class InvalidRatingError < Reading::Error; end
77
-
78
- # Means a valid length is missing.
79
- class InvalidLengthError < Reading::Error; end
80
24
  end
@@ -0,0 +1,95 @@
1
+ module Reading
2
+ # Filters Items based on given criteria.
3
+ class Filter
4
+ class << self
5
+ # Filters Items based on given criteria, and returns them sorted by last
6
+ # end date or (where there is none) status, where :planned Items are
7
+ # placed last, and :in_progress just before those.
8
+ # @param items [Array<Item>]
9
+ # @param no_sort [Boolean] to preserve the original ordering of the Items.
10
+ # @param criteria [Hash] one or more of the filters defined in by_x methods below.
11
+ # @return [Array<Item>]
12
+ # @raise [ArgumentError] if criteria are invalid or missing.
13
+ def by(items:, no_sort: false, **criteria)
14
+ validate_criteria(**criteria)
15
+
16
+ filtered = criteria.each.with_object(items.dup) { |(criterion, arg), filtered_items|
17
+ send("#{CRITERIA_PREFIX}#{criterion}#{CRITERIA_SUFFIX}", filtered_items, arg)
18
+ }
19
+
20
+ return filtered if no_sort
21
+
22
+ filtered.sort_by { |item|
23
+ if item.done?
24
+ item.last_end_date.strftime("%Y-%m-%d")
25
+ else
26
+ item.status.to_s
27
+ end
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ CRITERIA_PREFIX = "by_".freeze
34
+ CRITERIA_SUFFIX = "!".freeze
35
+
36
+ # Checks that the args match real Filter criteria.
37
+ # @param criteria [Hash] must include only one or more of the criteria
38
+ # defined in by_x methods below.
39
+ # @raise [ArgumentError] if criteria are empty or invalid.
40
+ def validate_criteria(**criteria)
41
+ available_criteria = private_methods(false)
42
+ .select { _1.to_s.start_with?(CRITERIA_PREFIX) }
43
+ .map { _1.to_s.delete_prefix(CRITERIA_PREFIX).delete_suffix(CRITERIA_SUFFIX).to_sym }
44
+
45
+ if criteria.empty?
46
+ raise ArgumentError, "Filter requires at least one of these criteria: #{available_criteria}"
47
+ end
48
+
49
+ unrecognized_criteria = criteria.keys - available_criteria
50
+ if unrecognized_criteria.any?
51
+ raise ArgumentError, "Unrecognized criteria passed to Filter: #{unrecognized_criteria}"
52
+ end
53
+ end
54
+
55
+ # Mutates the given array of Items to select only Items with a rating
56
+ # greater than or equal to the given minimum.
57
+ # @param items [Array<Item>]
58
+ # @param minimum_rating [Integer]
59
+ def by_minimum_rating!(items, minimum_rating)
60
+ return items unless minimum_rating
61
+
62
+ items.select! do |item|
63
+ if item.rating
64
+ item.rating >= minimum_rating
65
+ end
66
+ end
67
+ end
68
+
69
+ # Mutates the given array of Items to exclude Items with genres including
70
+ # any of the given genres.
71
+ # @param items [Array<Item>]
72
+ # @param excluded_genres [Array<String>]
73
+ def by_excluded_genres!(items, excluded_genres)
74
+ return items unless excluded_genres&.any?
75
+
76
+ items.select! do |item|
77
+ overlapping = item.genres & excluded_genres
78
+ overlapping.empty?
79
+ end
80
+ end
81
+
82
+ # Mutates the given array of Items to select only Items with a status
83
+ # equal to the given status (or one of the given statuses).
84
+ # @param items [Array<Item>]
85
+ # @param statuses [Symbol, Array<Symbol>]
86
+ def by_status!(items, statuses)
87
+ statuses = Array(statuses)
88
+
89
+ items.select! do |item|
90
+ statuses.include? item.status
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,140 @@
1
+ module Reading
2
+ class Item
3
+ # The length of an item when it is a time, as opposed to pages. (Pages are
4
+ # represented simply with an Integer.)
5
+ class Item::TimeLength
6
+ include Comparable
7
+
8
+ attr_reader :value # in total minutes
9
+
10
+ # @param value [Numeric] the total minutes
11
+ def initialize(value)
12
+ @value = value
13
+ end
14
+
15
+ # Builds an Item::TimeLength from a string.
16
+ # @param string [String] a time duration in "h:mm" format.
17
+ # @return [TimeLength]
18
+ def self.parse(string)
19
+ hours, minutes = string.split(':').map(&:to_i)
20
+ new((hours * 60) + minutes)
21
+ end
22
+
23
+ # Only the hours, e.g. the "h" value in "h:mm".
24
+ # @return [Numeric]
25
+ def hours
26
+ value / 60
27
+ end
28
+
29
+ # Only the hours, e.g. the "mm" value in "h:mm".
30
+ # @return [Numeric]
31
+ def minutes
32
+ value % 60
33
+ end
34
+
35
+ # A string in "h:mm" format.
36
+ # @return [String]
37
+ def to_s
38
+ "#{hours}:#{minutes}"
39
+ end
40
+
41
+ # @return [Boolean]
42
+ def zero?
43
+ value.zero?
44
+ end
45
+
46
+ # Converts @value to an Integer if it's a whole number, and returns self.
47
+ # @return [TimeLength]
48
+ def to_i_if_whole!
49
+ if @value.to_i == @value
50
+ @value = @value.to_i
51
+ end
52
+
53
+ self
54
+ end
55
+
56
+ # A non-mutating version of #to_i_if_whole! for compatibility with the
57
+ # refinement Numeric#to_i_if_whole.
58
+ # @return [TimeLength]
59
+ def to_i_if_whole
60
+ return self if @value.is_a?(Integer) || @value.to_i != @value
61
+
62
+ self.class.new(@value.to_i)
63
+ end
64
+
65
+ # TODO: addition with pages (nonzero Integer)
66
+ # @param other [TimeLength, Integer] must be zero if it's an Integer.
67
+ # @return [TimeLength]
68
+ def +(other)
69
+ if other.is_a? Item::TimeLength
70
+ self.class.new(value + other.value)
71
+ elsif other.zero?
72
+ self
73
+ else
74
+ raise TypeError, "#{other.class} can't be added to Item::TimeLength."
75
+ end
76
+ end
77
+
78
+ # TODO: subtraction with pages (nonzero Integer)
79
+ # @param other [TimeLength, Integer] must be zero if it's an Integer.
80
+ # @return [TimeLength]
81
+ def -(other)
82
+ if other.is_a? Item::TimeLength
83
+ self.class.new(value - other.value)
84
+ elsif other.zero?
85
+ self
86
+ else
87
+ raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
88
+ end
89
+ end
90
+
91
+ # @param other [TimeLength, Numeric]
92
+ # @return [TimeLength]
93
+ def *(other)
94
+ if other.is_a? Numeric
95
+ self.class.new(value * other).to_i_if_whole!
96
+ else
97
+ raise TypeError, "TimeLength can't be multiplied by #{other.class}."
98
+ end
99
+ end
100
+
101
+ # @param other [TimeLength, Numeric]
102
+ # @return [TimeLength]
103
+ def /(other)
104
+ if other.is_a? Numeric
105
+ self.class.new(value / other).to_i_if_whole!
106
+ else
107
+ raise TypeError, "TimeLength can't be divided by #{other.class}."
108
+ end
109
+ end
110
+
111
+ # TODO: add coercion for pages (nonzero Integer)
112
+ # See https://www.mutuallyhuman.com/blog/class-coercion-in-ruby
113
+ # @param other [Integer] must be zero.
114
+ def coerce(other)
115
+ if other.zero?
116
+ [self.class.new(other), self]
117
+ else
118
+ raise TypeError, "TimeLength can't be coerced into #{other.class}."
119
+ end
120
+ end
121
+
122
+ # TODO: add comparison to pages (nonzero Integer)
123
+ # @param other [TimeLength, Integer] if Integer, must be zero.
124
+ def <=>(other)
125
+ return 1 if other.nil?
126
+
127
+ if other.zero?
128
+ return 0 if value.zero?
129
+ return 1
130
+ end
131
+
132
+ unless other.is_a? Item::TimeLength
133
+ raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
134
+ end
135
+
136
+ value <=> other.value
137
+ end
138
+ end
139
+ end
140
+ end