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.
- checksums.yaml +4 -4
- data/bin/reading +5 -5
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +96 -108
- data/lib/reading/errors.rb +10 -66
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +140 -0
- data/lib/reading/item/view.rb +121 -0
- data/lib/reading/item.rb +117 -0
- data/lib/reading/parsing/attributes/attribute.rb +26 -0
- data/lib/reading/parsing/attributes/author.rb +15 -0
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
- data/lib/reading/parsing/attributes/experiences.rb +27 -0
- data/lib/reading/parsing/attributes/genres.rb +16 -0
- data/lib/reading/parsing/attributes/notes.rb +22 -0
- data/lib/reading/parsing/attributes/rating.rb +17 -0
- data/lib/reading/parsing/attributes/shared.rb +62 -0
- data/lib/reading/parsing/attributes/title.rb +21 -0
- data/lib/reading/parsing/attributes/variants.rb +77 -0
- data/lib/reading/parsing/csv.rb +112 -0
- data/lib/reading/parsing/parser.rb +292 -0
- data/lib/reading/parsing/rows/column.rb +131 -0
- data/lib/reading/parsing/rows/comment.rb +26 -0
- data/lib/reading/parsing/rows/compact_planned.rb +30 -0
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
- data/lib/reading/parsing/rows/regular.rb +33 -0
- data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
- data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
- data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
- data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
- data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
- data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
- data/lib/reading/parsing/transformer.rb +70 -0
- data/lib/reading/util/hash_compact_by_template.rb +1 -0
- data/lib/reading/util/hash_deep_merge.rb +1 -1
- data/lib/reading/util/hash_to_data.rb +30 -0
- data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
- data/lib/reading/util/string_truncate.rb +13 -4
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +49 -0
- metadata +76 -42
- data/lib/reading/attribute/all_attributes.rb +0 -83
- data/lib/reading/attribute/attribute.rb +0 -25
- data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
- data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
- data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
- data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
- data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
- data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
- data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
- data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
- data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
- data/lib/reading/csv.rb +0 -76
- data/lib/reading/line.rb +0 -23
- data/lib/reading/row/blank_row.rb +0 -23
- data/lib/reading/row/compact_planned_row.rb +0 -130
- data/lib/reading/row/regular_row.rb +0 -99
- data/lib/reading/row/row.rb +0 -88
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ecba33e4bbdb2dd11482113bf46d6196b17c89e2274e574b2286818f309c9ccb
|
4
|
+
data.tar.gz: 95d094b19fd4509e8608f5db414062e633ba6665c949526cd34c271314db6845
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
26
|
+
config = { enabled_columns: }
|
26
27
|
end
|
27
28
|
|
28
|
-
|
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
|
data/lib/reading/config.rb
CHANGED
@@ -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. {
|
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.
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
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
|
37
|
-
@hash[:
|
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
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
70
|
-
|
71
|
-
"
|
72
|
-
"
|
73
|
-
|
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
|
-
|
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:
|
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
|
178
|
+
# Builds the regex portion of the config, based on the config so far.
|
162
179
|
# @return [Hash]
|
163
|
-
def
|
164
|
-
return @hash[:
|
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
|
-
|
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
|
data/lib/reading/errors.rb
CHANGED
@@ -1,80 +1,24 @@
|
|
1
|
-
require "pastel"
|
2
|
-
|
3
1
|
module Reading
|
4
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|