reading 0.6.0 → 0.7.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 +8 -8
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +115 -148
- data/lib/reading/errors.rb +11 -64
- 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 -67
- 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 -94
- 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,28 +4,28 @@
|
|
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
|
-
# reading '3|📕Trying|
|
11
|
-
# reading '📕Trying|
|
10
|
+
# reading '3|📕Trying|Little Library 1970147288'
|
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
|
19
|
-
raise ArgumentError, "CSV string argument required, such as '3|📕Trying|
|
20
|
+
raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Little Library 1970147288'"
|
20
21
|
end
|
21
22
|
|
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,181 +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
|
-
public_notes
|
137
|
-
blurb
|
138
|
-
private_notes
|
139
|
-
history
|
140
|
-
],
|
141
|
-
# Custom columns are listed in a hash with default values, like simple columns in item[:template] above.
|
142
|
-
custom_numeric_columns: {}, # e.g. { family_friendliness: 5, surprise_factor: nil }
|
143
|
-
custom_text_columns: {}, # e.g. { mood: nil, rec_by: nil, will_reread: "no" }
|
144
|
-
comment_character: "\\",
|
145
|
-
column_separator: "|",
|
146
|
-
separator: ",",
|
147
|
-
short_separator: " - ",
|
148
|
-
long_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)}]+/
|
158
|
+
def regex_config
|
159
|
+
return @hash[:regex] if @hash.has_key?(:regex)
|
175
160
|
|
176
|
-
|
177
|
-
isbn_lookahead = "(?=\\z|\\s|#{@hash.deep_fetch(:csv, :separator)})"
|
178
|
-
isbn_bare_regex = /(?:\d{3}[-\s]?)?[A-Z\d]{10}/ # also includes ASIN
|
179
|
-
isbn = /#{isbn_lookbehind}#{isbn_bare_regex.source}#{isbn_lookahead}/
|
161
|
+
formats = @hash.fetch(:formats).values.join("|")
|
180
162
|
|
181
163
|
{
|
182
|
-
compact_planned_row_start: /\A\s*#{comment_character}\s*(?:(?<genres>[^a-z@:\|]+)?\s*(?<sources>@[^\|]+)?\s*:)?\s*(?=#{formats})/,
|
183
|
-
compact_planned_item: /\A(?<format_emoji>(?:#{formats}))(?<author_title>[^@\|]+)(?<sources>@.+)?(?:\|(?<sources_column>.+))?\z/,
|
184
164
|
formats: /#{formats}/,
|
185
165
|
formats_split: /\s*(?:,|--)?\s*(?=#{formats})/,
|
186
|
-
compact_planned_ignored_chars: /#{compact_planned_ignored_chars}/,
|
187
|
-
series_volume: /,\s*#(\d+)\z/,
|
188
|
-
isbn: isbn,
|
189
|
-
url: url,
|
190
|
-
dnf: /\A\s*(#{dnf_string})/,
|
191
|
-
progress: /(?<=#{dnf_string}|\A)\s*(?:(?<percent>\d?\d)%|#{time_length}|#{pages_length})\s+/,
|
192
|
-
group_experience: /#{@hash.deep_fetch(:csv, :group_emoji)}\s*(.*)\s*\z/,
|
193
|
-
variant_index: /\s+v(\d+)/,
|
194
|
-
date: /\d{4}\/\d?\d\/\d?\d/,
|
195
|
-
time_length: /\A#{time_length}(?<each>\s+each)?\z/,
|
196
|
-
time_length_in_variant: time_length,
|
197
|
-
pages_length: /\A#{pages_length}(?<each>\s+each)?\z/,
|
198
|
-
pages_length_in_variant: /(?:\A|\s+|p)(?<pages>\d{1,9})(?:p|\s+|\z)/, # to exclude ISBN-10 and ISBN-13
|
199
166
|
}
|
200
167
|
end
|
201
168
|
end
|
data/lib/reading/errors.rb
CHANGED
@@ -1,77 +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
|
-
#
|
62
|
-
|
63
|
-
# Means a date is unparsable, or a set of dates does not make logical sense.
|
64
|
-
class InvalidDateError < Reading::Error; end
|
65
|
-
|
66
|
-
# Means something in the Source column is invalid.
|
67
|
-
class InvalidSourceError < Reading::Error; end
|
10
|
+
# Means unexpected input was encountered during parsing.
|
11
|
+
class ParsingError < Reading::Error; end
|
68
12
|
|
69
13
|
# Means something in the Head column (author, title, etc.) is invalid.
|
70
14
|
class InvalidHeadError < Reading::Error; end
|
71
15
|
|
72
|
-
# Means the
|
73
|
-
class
|
16
|
+
# Means something in the History column is invalid.
|
17
|
+
class InvalidHistoryError < Reading::Error; end
|
18
|
+
|
19
|
+
# Means there are too many columns in a row.
|
20
|
+
class TooManyColumnsError < Reading::Error; end
|
74
21
|
|
75
|
-
# Means a
|
76
|
-
class
|
22
|
+
# Means a date is unparsable, or a set of dates does not make logical sense.
|
23
|
+
class InvalidDateError < Reading::Error; end
|
77
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
|