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.
- checksums.yaml +4 -4
- data/bin/reading +80 -10
- data/lib/reading/config.rb +96 -52
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +69 -30
- data/lib/reading/item/view.rb +116 -0
- data/lib/reading/item.rb +384 -0
- data/lib/reading/parsing/attributes/attribute.rb +1 -8
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +11 -12
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +31 -22
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
- data/lib/reading/parsing/attributes/experiences.rb +6 -6
- data/lib/reading/parsing/attributes/notes.rb +1 -1
- data/lib/reading/parsing/attributes/shared.rb +15 -8
- data/lib/reading/parsing/attributes/variants.rb +10 -7
- data/lib/reading/parsing/csv.rb +58 -44
- data/lib/reading/parsing/parser.rb +24 -25
- data/lib/reading/parsing/rows/blank.rb +23 -0
- data/lib/reading/parsing/rows/comment.rb +6 -7
- data/lib/reading/parsing/rows/compact_planned.rb +9 -9
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
- data/lib/reading/parsing/rows/custom_config.rb +42 -0
- data/lib/reading/parsing/rows/regular.rb +15 -14
- data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
- data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
- data/lib/reading/parsing/transformer.rb +15 -19
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +243 -0
- data/lib/reading/stats/operation.rb +313 -0
- data/lib/reading/stats/query.rb +37 -0
- data/lib/reading/stats/terminal_result_formatters.rb +91 -0
- data/lib/reading/util/exclude.rb +12 -0
- data/lib/reading/util/hash_to_data.rb +30 -0
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +51 -5
- metadata +28 -7
- data/bin/readingfile +0 -31
- data/lib/reading/util/hash_to_struct.rb +0 -30
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e839c46251c307a65a234a9a374ad964f68d934d974005d6aa38cbb35e10f756
|
4
|
+
data.tar.gz: fa20c0b78f3fd1194495e1e604bb1d95b37e0334ac12c745d6508e026cd6fe97
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
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
|
-
|
15
|
-
|
16
|
-
|
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,
|
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
|
-
|
65
|
+
Reading::Config.build(enabled_columns:)
|
27
66
|
end
|
28
67
|
|
29
|
-
|
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
|
-
|
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
|
data/lib/reading/config.rb
CHANGED
@@ -1,15 +1,26 @@
|
|
1
|
-
require_relative
|
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
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|
-
|
122
|
-
|
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
|
-
|
176
|
+
experiences:
|
125
177
|
[{
|
126
|
-
|
127
|
-
|
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
|
-
|
130
|
-
length: nil,
|
131
|
-
extra_info: [],
|
132
|
-
}],
|
133
|
-
experiences:
|
134
|
-
[{
|
135
|
-
spans:
|
189
|
+
notes:
|
136
190
|
[{
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
name: nil,
|
141
|
-
favorite?: false,
|
191
|
+
blurb?: false,
|
192
|
+
private?: false,
|
193
|
+
content: nil,
|
142
194
|
}],
|
143
|
-
|
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
|
data/lib/reading/errors.rb
CHANGED
@@ -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
|
-
|
3
|
-
#
|
4
|
-
|
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
|
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
|
-
#
|
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.
|
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
|
-
#
|
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.
|
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
|
-
#
|
111
|
-
# @param other [
|
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.
|
114
|
-
[self.class.
|
140
|
+
if other.is_a? Numeric
|
141
|
+
[self.class.from_pages(other), self]
|
115
142
|
else
|
116
|
-
raise TypeError, "
|
143
|
+
raise TypeError, "#{other.class} can't be coerced into a TimeLength."
|
117
144
|
end
|
118
145
|
end
|
119
146
|
|
120
|
-
#
|
121
|
-
# @
|
147
|
+
# @param other [TimeLength, Numeric]
|
148
|
+
# @return [Integer]
|
122
149
|
def <=>(other)
|
123
150
|
return 1 if other.nil?
|
124
151
|
|
125
|
-
if other.
|
126
|
-
|
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
|