reading 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -10
  3. data/lib/reading/config.rb +96 -52
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/filter.rb +95 -0
  6. data/lib/reading/item/time_length.rb +69 -30
  7. data/lib/reading/item/view.rb +116 -0
  8. data/lib/reading/item.rb +384 -0
  9. data/lib/reading/parsing/attributes/attribute.rb +1 -8
  10. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +11 -12
  11. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +31 -22
  12. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  13. data/lib/reading/parsing/attributes/experiences.rb +6 -6
  14. data/lib/reading/parsing/attributes/notes.rb +1 -1
  15. data/lib/reading/parsing/attributes/shared.rb +15 -8
  16. data/lib/reading/parsing/attributes/variants.rb +10 -7
  17. data/lib/reading/parsing/csv.rb +58 -44
  18. data/lib/reading/parsing/parser.rb +24 -25
  19. data/lib/reading/parsing/rows/blank.rb +23 -0
  20. data/lib/reading/parsing/rows/comment.rb +6 -7
  21. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  22. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  23. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  24. data/lib/reading/parsing/rows/regular.rb +15 -14
  25. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  26. data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
  27. data/lib/reading/parsing/transformer.rb +15 -19
  28. data/lib/reading/stats/filter.rb +738 -0
  29. data/lib/reading/stats/grouping.rb +243 -0
  30. data/lib/reading/stats/operation.rb +313 -0
  31. data/lib/reading/stats/query.rb +37 -0
  32. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  33. data/lib/reading/util/exclude.rb +12 -0
  34. data/lib/reading/util/hash_to_data.rb +30 -0
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +51 -5
  37. metadata +28 -7
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/hash_to_struct.rb +0 -30
  40. data/lib/reading/util/string_remove.rb +0 -28
  41. data/lib/reading/util/string_truncate.rb +0 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 531b4f54f11eed2f638079efbc0542977287d3ca887bea04a0785b6ca10c45fe
4
- data.tar.gz: 58881344b75fc84041275d3ad9b84955f03d53ff8ca35f09932509b584c8b218
3
+ metadata.gz: e839c46251c307a65a234a9a374ad964f68d934d974005d6aa38cbb35e10f756
4
+ data.tar.gz: fa20c0b78f3fd1194495e1e604bb1d95b37e0334ac12c745d6508e026cd6fe97
5
5
  SHA512:
6
- metadata.gz: 3539806e8c4472ba98d1ac19c862989a21d25782e4ec94ac04a3dfd4a3f30509187dce4ee6fa86fc189f742e19e2e649ffcf94f5e539cc1bc576856748b9adf6
7
- data.tar.gz: 1be98e8aa5fc04a87aae46832cc981a49d3b725eeeaee2c6b75f1d723cf907d2e6567b9474022a5b19bc113db3555a7d358ec924c469f269d361b440d1963ca2
6
+ metadata.gz: 2bbca1450b2f6ae4c0d2b6d4cfeec495bf30b20639fcb3a5b121711e1d995a8b13a5ca64100d7a5e11d00d60c3808d61d25583510e3cd19902d412eca216154f
7
+ data.tar.gz: 74a676238941cdd5a0a7ecf8b2a4ed3c705883daadc25b3710a17eb9e325ec294d8b9fad71e653779af1ea9e01d0e8fed6423592dbe750b4f37c79f0f690387d
data/bin/reading CHANGED
@@ -1,31 +1,101 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # A script that provides a quick way to see the output of a CSV string.
3
+ # Starts the reading statistics interactive CLI, if a CSV file path arg is given.
4
+ # If a CSV string is given instead, then parsing output (item hashes) is displayed.
4
5
  #
5
6
  # Usage:
6
7
  # Run on the command line:
7
- # reading "<CSV string>" "<optional comma-separated names of enabled columns>"
8
+ # reading "<CSV file path or string>" "<optional comma-separated names of enabled columns>"
8
9
  #
9
10
  # Examples:
11
+ # reading /home/felipe/reading.csv
12
+ # reading /home/felipe/reading.csv 'head, sources'
10
13
  # reading '3|📕Trying|Little Library 1970147288'
11
14
  # reading '📕Trying|Little Library 1970147288' 'head, sources'
12
15
 
16
+ require 'debug'
17
+ require_relative '../lib/reading'
18
+ require_relative '../lib/reading/stats/terminal_result_formatters'
19
+ require 'amazing_print'
20
+ require 'readline'
21
+ require 'pastel'
13
22
 
14
- require_relative "../lib/reading"
15
- require "amazing_print"
16
- require "debug"
23
+ EXIT_COMMANDS = %w[exit e quit q]
24
+ PASTEL = Pastel.new
25
+ GROUP_HEADING_FORMATTERS = [
26
+ -> { PASTEL.magenta.bold.underline(_1) },
27
+ -> { PASTEL.green.bold.underline(_1) },
28
+ -> { PASTEL.yellow.bold.underline(_1) },
29
+ -> { PASTEL.cyan.bold.underline(_1) },
30
+ -> { PASTEL.magenta.on_white(_1) },
31
+ -> { PASTEL.green.on_white(_1) },
32
+ -> { PASTEL.yellow.on_white(_1) },
33
+ ]
34
+
35
+ # Recursively prints a hash of results (possibly grouped).
36
+ # @param grouped_results [Hash, Array]
37
+ # @param group_heading_formatters [Array<Proc>] a subset of GROUP_HEADING_FORMATTERS
38
+ def print_grouped_results(grouped_results, group_heading_formatters)
39
+ indent_level = GROUP_HEADING_FORMATTERS.count - group_heading_formatters.count
40
+
41
+ if grouped_results.nil? || grouped_results.empty?
42
+ puts " " * indent_level + PASTEL.bright_black("none") + "\n"
43
+ return
44
+ elsif !grouped_results.is_a? Hash
45
+ puts " " * indent_level + grouped_results.gsub("\n", "\n" + " " * indent_level) + "\n"
46
+ return
47
+ end
48
+
49
+ grouped_results.each do |group_name, grouped|
50
+ puts " " * indent_level + group_heading_formatters.first.call(group_name)
51
+ print_grouped_results(grouped, group_heading_formatters[1..])
52
+ end
53
+ end
17
54
 
18
55
  input = ARGV[0]
19
56
  unless input
20
- raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Little Library 1970147288'"
57
+ raise ArgumentError,
58
+ "Argument required, either a CSV file path or a CSV string. Examples:\n" \
59
+ "parsereading /home/felipe/reading.csv\n" \
60
+ "parsereading '3|📕Trying|Little Library 1970147288'"
21
61
  end
22
62
 
23
- config = {}
24
63
  if ARGV[1]
25
64
  enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
26
- config = { enabled_columns: }
65
+ Reading::Config.build(enabled_columns:)
27
66
  end
28
67
 
29
- items = Reading.parse(stream: input, config:)
68
+ input_is_csv_path = input.end_with?('.csv')
69
+
70
+ if input_is_csv_path
71
+ error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
72
+
73
+ items = Reading.parse(path: input, item_view: false, error_handler:)
30
74
 
31
- ap items
75
+ loop do
76
+ raw_input = Readline.readline(PASTEL.bright_cyan("> "), true)
77
+
78
+ exit if EXIT_COMMANDS.include?(raw_input)
79
+
80
+ input = raw_input.presence
81
+ next if raw_input.blank?
82
+
83
+ results = Reading.stats(
84
+ input:,
85
+ items:,
86
+ result_formatters: Reading::Stats::ResultFormatters::TERMINAL,
87
+ )
88
+
89
+ print_grouped_results(results, GROUP_HEADING_FORMATTERS)
90
+ rescue Reading::Error => e
91
+ puts e
92
+ end
93
+ else # CSV string arg
94
+ begin
95
+ item_hashes = Reading.parse(lines: input, hash_output: true, item_view: false)
96
+ rescue Reading::Error => e
97
+ puts "Skipped a row due to a parsing error: #{e}"
98
+ end
99
+
100
+ ap item_hashes
101
+ end
@@ -1,15 +1,26 @@
1
- require_relative "util/hash_deep_merge"
2
- require_relative "util/hash_array_deep_fetch"
3
- require_relative "errors"
1
+ require_relative 'errors'
4
2
 
5
3
  module Reading
6
- # Builds a hash config.
4
+ # Builds a singleton hash config.
7
5
  class Config
8
6
  using Util::HashDeepMerge
9
7
  using Util::HashArrayDeepFetch
10
8
 
11
9
  attr_reader :hash
12
10
 
11
+ # Builds an entire config hash from a custom config hash (which is typically
12
+ # not an entire config, but it can be, in which case a copy is returned).
13
+ # @param custom_config [Hash, Config]
14
+ # @return [Hash]
15
+ def self.build(custom_config = {})
16
+ @hash = new(custom_config).hash
17
+ end
18
+
19
+ # @return [Hash]
20
+ def self.hash
21
+ @hash ||= build
22
+ end
23
+
13
24
  # @param custom_config [Hash] a custom config which overrides the defaults,
14
25
  # e.g. { enabled_columns: [:head, :end_dates] }
15
26
  def initialize(custom_config = {})
@@ -55,6 +66,16 @@ module Reading
55
66
  column_separator: "|",
56
67
  ignored_characters: "✅❌💲❓⏳",
57
68
  skip_compact_planned: false,
69
+ pages_per_hour: 35,
70
+ length_group_boundaries: [200, 400, 600, 1000, 2000],
71
+ speed: # e.g. listening speed for audiobooks and podcasts.
72
+ {
73
+ format:
74
+ {
75
+ audiobook: 1.0,
76
+ audio: 1.0,
77
+ },
78
+ },
58
79
  # The Head column is always enabled; the others can be disabled by
59
80
  # using a custom config that omits columns from this array.
60
81
  enabled_columns:
@@ -86,6 +107,7 @@ module Reading
86
107
  },
87
108
  source_names_from_urls:
88
109
  {
110
+ "audible.com" => "Audible",
89
111
  "youtube.com" => "YouTube",
90
112
  "youtu.be" => "YouTube",
91
113
  "books.google.com" => "Google Books",
@@ -94,61 +116,83 @@ module Reading
94
116
  "librivox.org" => "LibriVox",
95
117
  "tv.apple.com" => "Apple TV",
96
118
  },
97
- # The structure of an item, along with default values.
98
- # Wherever an array of hashes ends up with no data (i.e. equal to the
99
- # value in the template), it is collapsed into an empty array.
100
- # E.g. the row "|Dracula||🤝🏼book club" is parsed to a Struct analogous to:
101
- # {
102
- # rating: nil,
103
- # author: nil,
104
- # title: "Dracula",
105
- # genres: [],
106
- # variants: [],
107
- # experiences: [{ spans: [], group: "book club", variant_index: 0 }],
108
- # notes: [],
109
- # }
110
- item_template:
119
+ item:
111
120
  {
112
- rating: nil,
113
- author: nil,
114
- title: nil,
115
- genres: [],
116
- variants:
117
- [{
118
- format: nil,
119
- series:
121
+ # After how many days of no activity an item of indefinite length
122
+ # (e.g. a podcast) should change its status from :in_progress to :done.
123
+ indefinite_in_progress_grace_period_days: 30,
124
+ view:
125
+ {
126
+ name_separator: " 〜 ",
127
+ url_from_isbn: "https://www.goodreads.com/book/isbn?isbn=%{isbn}",
128
+ # Items rated this or above get a star. If nil, number ratings are shown instead.
129
+ minimum_rating_for_star: 5,
130
+ types:
131
+ {
132
+ book: { emoji: "📕", from_formats: %i[print ebook audiobook pdf] },
133
+ course: { emoji: "🏫", from_formats: %i[website] },
134
+ piece: { emoji: "✏️" },
135
+ video: { emoji: "🎞️" },
136
+ audio: { emoji: "🎤" },
137
+ },
138
+ default_type: :book,
139
+ },
140
+ # The structure of an item, along with default values.
141
+ # Wherever an array of hashes ends up with no data (i.e. equal to the
142
+ # value in the template), it is collapsed into an empty array.
143
+ # E.g. the row "|Dracula||🤝🏼book club" is parsed to an Item analogous to:
144
+ # {
145
+ # rating: nil,
146
+ # author: nil,
147
+ # title: "Dracula",
148
+ # genres: [],
149
+ # variants: [],
150
+ # experiences: [{ spans: [], group: "book club", variant_index: 0 }],
151
+ # notes: [],
152
+ # }
153
+ template:
154
+ {
155
+ rating: nil,
156
+ author: nil,
157
+ title: nil,
158
+ genres: [],
159
+ variants:
120
160
  [{
121
- name: nil,
122
- volume: nil,
161
+ format: nil,
162
+ series:
163
+ [{
164
+ name: nil,
165
+ volume: nil,
166
+ }],
167
+ sources:
168
+ [{
169
+ name: nil,
170
+ url: nil,
171
+ }],
172
+ isbn: nil,
173
+ length: nil,
174
+ extra_info: [],
123
175
  }],
124
- sources:
176
+ experiences:
125
177
  [{
126
- name: nil,
127
- url: nil,
178
+ spans:
179
+ [{
180
+ dates: nil,
181
+ progress: 1.0,
182
+ amount: 0,
183
+ name: nil,
184
+ favorite?: false,
185
+ }],
186
+ group: nil,
187
+ variant_index: 0,
128
188
  }],
129
- isbn: nil,
130
- length: nil,
131
- extra_info: [],
132
- }],
133
- experiences:
134
- [{
135
- spans:
189
+ notes:
136
190
  [{
137
- dates: nil,
138
- amount: 0,
139
- progress: nil,
140
- name: nil,
141
- favorite?: false,
191
+ blurb?: false,
192
+ private?: false,
193
+ content: nil,
142
194
  }],
143
- group: nil,
144
- variant_index: 0,
145
- }],
146
- notes:
147
- [{
148
- blurb?: false,
149
- private?: false,
150
- content: nil,
151
- }],
195
+ },
152
196
  },
153
197
  }
154
198
  end
@@ -7,7 +7,7 @@ module Reading
7
7
  # Means there is something wrong with the user-supplied custom config.
8
8
  class ConfigError < Reading::Error; end
9
9
 
10
- # Means unexpected input was encountered during parsing.
10
+ # Means unexpected input was encountered during CSV parsing.
11
11
  class ParsingError < Reading::Error; end
12
12
 
13
13
  # Means something in the Head column (author, title, etc.) is invalid.
@@ -21,4 +21,7 @@ module Reading
21
21
 
22
22
  # Means a date is unparsable, or a set of dates does not make logical sense.
23
23
  class InvalidDateError < Reading::Error; end
24
+
25
+ # Means a stats command was entered incorrectly.
26
+ class InputError < Reading::Error; end
24
27
  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
@@ -1,7 +1,8 @@
1
1
  module Reading
2
- module Item
3
- # For coercion, see https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
4
- class TimeLength
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 or Float.)
5
+ class Item::TimeLength
5
6
  include Comparable
6
7
 
7
8
  attr_reader :value # in total minutes
@@ -11,18 +12,34 @@ module Reading
11
12
  @value = value
12
13
  end
13
14
 
14
- # Builds a TimeLength from a string.
15
+ # Builds an Item::TimeLength from a string.
15
16
  # @param string [String] a time duration in "h:mm" format.
16
- # @return [TimeLength]
17
+ # @return [TimeLength, nil]
17
18
  def self.parse(string)
19
+ return nil unless string.match? /\A\d+:\d\d\z/
20
+
18
21
  hours, minutes = string.split(':').map(&:to_i)
19
22
  new((hours * 60) + minutes)
20
23
  end
21
24
 
25
+ # Builds an Item::TimeLength based on a page count.
26
+ # @param pages [Integer, Float]
27
+ # @return [TimeLength]
28
+ def self.from_pages(pages)
29
+ new(pages_to_minutes(pages))
30
+ end
31
+
32
+ # Converts a page count to minutes.
33
+ # @param pages [Integer, Float]
34
+ # @return [Integer]
35
+ def self.pages_to_minutes(pages)
36
+ (pages.to_f / Config.hash.fetch(:pages_per_hour) * 60)
37
+ end
38
+
22
39
  # Only the hours, e.g. the "h" value in "h:mm".
23
40
  # @return [Numeric]
24
41
  def hours
25
- value / 60
42
+ value.to_i / 60
26
43
  end
27
44
 
28
45
  # Only the hours, e.g. the "mm" value in "h:mm".
@@ -34,7 +51,13 @@ module Reading
34
51
  # A string in "h:mm" format.
35
52
  # @return [String]
36
53
  def to_s
37
- "#{hours}:#{minutes}"
54
+ "#{hours}:#{minutes.round.to_s.rjust(2, '0')} or #{(value / 60.0 * Config.hash.fetch(:pages_per_hour)).round} pages"
55
+ end
56
+
57
+ # To pages.
58
+ # @return [Integer]
59
+ def to_i
60
+ ((value / 60.0) * Config.hash.fetch(:pages_per_hour)).to_i
38
61
  end
39
62
 
40
63
  # @return [Boolean]
@@ -42,6 +65,12 @@ module Reading
42
65
  value.zero?
43
66
  end
44
67
 
68
+ # A copy of self with a rounded @value.
69
+ # @return [TimeLength]
70
+ def round
71
+ self.class.new(@value.round)
72
+ end
73
+
45
74
  # Converts @value to an Integer if it's a whole number, and returns self.
46
75
  # @return [TimeLength]
47
76
  def to_i_if_whole!
@@ -61,29 +90,27 @@ module Reading
61
90
  self.class.new(@value.to_i)
62
91
  end
63
92
 
64
- # TODO: addition with pages (nonzero Integer)
65
- # @param other [TimeLength, Integer] must be zero if it's an Integer.
93
+ # @param other [TimeLength, Numeric]
66
94
  # @return [TimeLength]
67
95
  def +(other)
68
- if other.is_a? TimeLength
96
+ if other.is_a? Item::TimeLength
69
97
  self.class.new(value + other.value)
70
- elsif other.zero?
71
- self
98
+ elsif other.is_a? Numeric
99
+ self.class.new(value + self.class.pages_to_minutes(other))
72
100
  else
73
- raise TypeError, "#{other.class} can't be added to TimeLength."
101
+ raise TypeError, "#{other.class} can't be added to Item::TimeLength."
74
102
  end
75
103
  end
76
104
 
77
- # TODO: subtraction with pages (nonzero Integer)
78
- # @param other [TimeLength, Integer] must be zero if it's an Integer.
105
+ # @param other [TimeLength, Numeric]
79
106
  # @return [TimeLength]
80
107
  def -(other)
81
- if other.is_a? TimeLength
108
+ if other.is_a? Item::TimeLength
82
109
  self.class.new(value - other.value)
83
- elsif other.zero?
84
- self
110
+ elsif other.is_a? Numeric
111
+ self.class.new(value - self.class.pages_to_minutes(other))
85
112
  else
86
- raise TypeError, "#{other.class} can't be subtracted from TimeLength."
113
+ raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
87
114
  end
88
115
  end
89
116
 
@@ -107,32 +134,44 @@ module Reading
107
134
  end
108
135
  end
109
136
 
110
- # TODO: add coercion for pages (nonzero Integer)
111
- # @param other [Integer] must be zero.
137
+ # See https://web.archive.org/web/20221206095821/https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
138
+ # @param other [Numeric]
112
139
  def coerce(other)
113
- if other.zero?
114
- [self.class.new(other), self]
140
+ if other.is_a? Numeric
141
+ [self.class.from_pages(other), self]
115
142
  else
116
- raise TypeError, "TimeLength can't be coerced into #{other.class}."
143
+ raise TypeError, "#{other.class} can't be coerced into a TimeLength."
117
144
  end
118
145
  end
119
146
 
120
- # TODO: add comparison to pages (nonzero Integer)
121
- # @param other [TimeLength, Integer] if Integer, must be zero.
147
+ # @param other [TimeLength, Numeric]
148
+ # @return [Integer]
122
149
  def <=>(other)
123
150
  return 1 if other.nil?
124
151
 
125
- if other.zero?
126
- return 0 if value.zero?
127
- return 1
152
+ if other.is_a? Numeric
153
+ other = self.class.from_pages(other)
128
154
  end
129
155
 
130
- unless other.is_a? TimeLength
156
+ unless other.is_a? Item::TimeLength
131
157
  raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
132
158
  end
133
159
 
134
160
  value <=> other.value
135
161
  end
162
+
163
+ # Must be implemented for hash key equality checks.
164
+ # @param other [TimeLength, Numeric]
165
+ # @return [Boolean]
166
+ def eql?(other)
167
+ hash == other.hash
168
+ end
169
+
170
+ # Must be implemented along with #eql? for hash key equality checks.
171
+ # @return [Integer]
172
+ def hash
173
+ value
174
+ end
136
175
  end
137
176
  end
138
177
  end