reading 0.6.1 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bin/reading +5 -5
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +115 -149
- data/lib/reading/errors.rb +10 -66
- data/lib/reading/item/time_length.rb +138 -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 +101 -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_struct.rb +1 -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 +18 -0
- metadata +58 -41
- 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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 531b4f54f11eed2f638079efbc0542977287d3ca887bea04a0785b6ca10c45fe
|
4
|
+
data.tar.gz: 58881344b75fc84041275d3ad9b84955f03d53ff8ca35f09932509b584c8b218
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3539806e8c4472ba98d1ac19c862989a21d25782e4ec94ac04a3dfd4a3f30509187dce4ee6fa86fc189f742e19e2e649ffcf94f5e539cc1bc576856748b9adf6
|
7
|
+
data.tar.gz: 1be98e8aa5fc04a87aae46832cc981a49d3b725eeeaee2c6b75f1d723cf907d2e6567b9474022a5b19bc113db3555a7d358ec924c469f269d361b440d1963ca2
|
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:)
|
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/alex/reading.csv'
|
11
|
+
# reading '/home/alex/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/alex/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:)
|
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,182 +25,144 @@ 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
|
-
@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]
|
28
31
|
end
|
29
32
|
|
30
|
-
#
|
31
|
-
enabled_columns =
|
32
|
-
|
33
|
-
|
34
|
-
|
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(", ")}"
|
42
|
+
end
|
35
43
|
|
36
|
-
|
37
|
-
|
44
|
+
@hash[:enabled_columns] = enabled_columns
|
45
|
+
|
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:
|
45
76
|
{
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
77
|
+
print: "📕",
|
78
|
+
ebook: "⚡",
|
79
|
+
audiobook: "🔊",
|
80
|
+
pdf: "📄",
|
81
|
+
audio: "🎤",
|
82
|
+
video: "🎞️",
|
83
|
+
course: "🏫",
|
84
|
+
piece: "✏️",
|
85
|
+
website: "🌐",
|
50
86
|
},
|
51
|
-
|
87
|
+
source_names_from_urls:
|
52
88
|
{
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
genres: [],
|
85
|
-
variants:
|
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",
|
96
|
+
},
|
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:
|
111
|
+
{
|
112
|
+
rating: nil,
|
113
|
+
author: nil,
|
114
|
+
title: nil,
|
115
|
+
genres: [],
|
116
|
+
variants:
|
117
|
+
[{
|
118
|
+
format: nil,
|
119
|
+
series:
|
86
120
|
[{
|
87
|
-
|
88
|
-
|
89
|
-
[{
|
90
|
-
name: nil,
|
91
|
-
volume: nil,
|
92
|
-
}],
|
93
|
-
sources:
|
94
|
-
[{
|
95
|
-
name: nil,
|
96
|
-
url: nil,
|
97
|
-
}],
|
98
|
-
isbn: nil,
|
99
|
-
length: nil,
|
100
|
-
extra_info: [],
|
121
|
+
name: nil,
|
122
|
+
volume: nil,
|
101
123
|
}],
|
102
|
-
|
124
|
+
sources:
|
103
125
|
[{
|
104
|
-
|
105
|
-
|
106
|
-
dates: nil,
|
107
|
-
amount: nil,
|
108
|
-
progress: nil,
|
109
|
-
name: nil,
|
110
|
-
favorite?: false,
|
111
|
-
}],
|
112
|
-
group: nil,
|
113
|
-
variant_index: 0,
|
126
|
+
name: nil,
|
127
|
+
url: nil,
|
114
128
|
}],
|
115
|
-
|
129
|
+
isbn: nil,
|
130
|
+
length: nil,
|
131
|
+
extra_info: [],
|
132
|
+
}],
|
133
|
+
experiences:
|
134
|
+
[{
|
135
|
+
spans:
|
116
136
|
[{
|
117
|
-
|
118
|
-
|
119
|
-
|
137
|
+
dates: nil,
|
138
|
+
amount: 0,
|
139
|
+
progress: nil,
|
140
|
+
name: nil,
|
141
|
+
favorite?: false,
|
120
142
|
}],
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
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,
|
143
|
+
group: nil,
|
144
|
+
variant_index: 0,
|
145
|
+
}],
|
146
|
+
notes:
|
147
|
+
[{
|
148
|
+
blurb?: false,
|
149
|
+
private?: false,
|
150
|
+
content: nil,
|
151
|
+
}],
|
157
152
|
},
|
158
153
|
}
|
159
154
|
end
|
160
155
|
|
161
|
-
# Builds the
|
156
|
+
# Builds the regex portion of the config, based on the config so far.
|
162
157
|
# @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)
|
158
|
+
def regex_config
|
159
|
+
return @hash[:regex] if @hash.has_key?(:regex)
|
176
160
|
|
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}/
|
161
|
+
formats = @hash.fetch(:formats).values.join("|")
|
181
162
|
|
182
163
|
{
|
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
164
|
formats: /#{formats}/,
|
186
165
|
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
166
|
}
|
201
167
|
end
|
202
168
|
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,138 @@
|
|
1
|
+
module Reading
|
2
|
+
module Item
|
3
|
+
# For coercion, see https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
|
4
|
+
class TimeLength
|
5
|
+
include Comparable
|
6
|
+
|
7
|
+
attr_reader :value # in total minutes
|
8
|
+
|
9
|
+
# @param value [Numeric] the total minutes
|
10
|
+
def initialize(value)
|
11
|
+
@value = value
|
12
|
+
end
|
13
|
+
|
14
|
+
# Builds a TimeLength from a string.
|
15
|
+
# @param string [String] a time duration in "h:mm" format.
|
16
|
+
# @return [TimeLength]
|
17
|
+
def self.parse(string)
|
18
|
+
hours, minutes = string.split(':').map(&:to_i)
|
19
|
+
new((hours * 60) + minutes)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Only the hours, e.g. the "h" value in "h:mm".
|
23
|
+
# @return [Numeric]
|
24
|
+
def hours
|
25
|
+
value / 60
|
26
|
+
end
|
27
|
+
|
28
|
+
# Only the hours, e.g. the "mm" value in "h:mm".
|
29
|
+
# @return [Numeric]
|
30
|
+
def minutes
|
31
|
+
value % 60
|
32
|
+
end
|
33
|
+
|
34
|
+
# A string in "h:mm" format.
|
35
|
+
# @return [String]
|
36
|
+
def to_s
|
37
|
+
"#{hours}:#{minutes}"
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [Boolean]
|
41
|
+
def zero?
|
42
|
+
value.zero?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Converts @value to an Integer if it's a whole number, and returns self.
|
46
|
+
# @return [TimeLength]
|
47
|
+
def to_i_if_whole!
|
48
|
+
if @value.to_i == @value
|
49
|
+
@value = @value.to_i
|
50
|
+
end
|
51
|
+
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
# A non-mutating version of #to_i_if_whole! for compatibility with the
|
56
|
+
# refinement Numeric#to_i_if_whole.
|
57
|
+
# @return [TimeLength]
|
58
|
+
def to_i_if_whole
|
59
|
+
return self if @value.is_a?(Integer) || @value.to_i != @value
|
60
|
+
|
61
|
+
self.class.new(@value.to_i)
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO: addition with pages (nonzero Integer)
|
65
|
+
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
66
|
+
# @return [TimeLength]
|
67
|
+
def +(other)
|
68
|
+
if other.is_a? TimeLength
|
69
|
+
self.class.new(value + other.value)
|
70
|
+
elsif other.zero?
|
71
|
+
self
|
72
|
+
else
|
73
|
+
raise TypeError, "#{other.class} can't be added to TimeLength."
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# TODO: subtraction with pages (nonzero Integer)
|
78
|
+
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
79
|
+
# @return [TimeLength]
|
80
|
+
def -(other)
|
81
|
+
if other.is_a? TimeLength
|
82
|
+
self.class.new(value - other.value)
|
83
|
+
elsif other.zero?
|
84
|
+
self
|
85
|
+
else
|
86
|
+
raise TypeError, "#{other.class} can't be subtracted from TimeLength."
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# @param other [TimeLength, Numeric]
|
91
|
+
# @return [TimeLength]
|
92
|
+
def *(other)
|
93
|
+
if other.is_a? Numeric
|
94
|
+
self.class.new(value * other).to_i_if_whole!
|
95
|
+
else
|
96
|
+
raise TypeError, "TimeLength can't be multiplied by #{other.class}."
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# @param other [TimeLength, Numeric]
|
101
|
+
# @return [TimeLength]
|
102
|
+
def /(other)
|
103
|
+
if other.is_a? Numeric
|
104
|
+
self.class.new(value / other).to_i_if_whole!
|
105
|
+
else
|
106
|
+
raise TypeError, "TimeLength can't be divided by #{other.class}."
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# TODO: add coercion for pages (nonzero Integer)
|
111
|
+
# @param other [Integer] must be zero.
|
112
|
+
def coerce(other)
|
113
|
+
if other.zero?
|
114
|
+
[self.class.new(other), self]
|
115
|
+
else
|
116
|
+
raise TypeError, "TimeLength can't be coerced into #{other.class}."
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# TODO: add comparison to pages (nonzero Integer)
|
121
|
+
# @param other [TimeLength, Integer] if Integer, must be zero.
|
122
|
+
def <=>(other)
|
123
|
+
return 1 if other.nil?
|
124
|
+
|
125
|
+
if other.zero?
|
126
|
+
return 0 if value.zero?
|
127
|
+
return 1
|
128
|
+
end
|
129
|
+
|
130
|
+
unless other.is_a? TimeLength
|
131
|
+
raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
|
132
|
+
end
|
133
|
+
|
134
|
+
value <=> other.value
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# The base class for all the attribute in parsing/attributes, each of which
|
5
|
+
# extracts an attribute from a parsed row. Together they transform the
|
6
|
+
# parsed row (an intermediate hash) into item attributes, as in
|
7
|
+
# Config#default_config[:item_template].
|
8
|
+
class Attribute
|
9
|
+
private attr_reader :config
|
10
|
+
|
11
|
+
# @param config [Hash] an entire config.
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
# Extracts this attribute's value from a parsed row.
|
17
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
18
|
+
# @param head_index [Integer] current item's position in the Head column.
|
19
|
+
# @return [Object]
|
20
|
+
def transform_from_parsed(parsed_row, head_index)
|
21
|
+
raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :author item attribute.
|
5
|
+
class Author < Attribute
|
6
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
7
|
+
# @param head_index [Integer] current item's position in the Head column.
|
8
|
+
# @return [String]
|
9
|
+
def transform_from_parsed(parsed_row, head_index)
|
10
|
+
parsed_row[:head][head_index][:author]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|