reading 0.6.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 +7 -0
- data/bin/reading +31 -0
- data/lib/reading/attribute/all_attributes.rb +83 -0
- data/lib/reading/attribute/attribute.rb +25 -0
- data/lib/reading/attribute/experiences/dates_validator.rb +94 -0
- data/lib/reading/attribute/experiences/experiences_attribute.rb +74 -0
- data/lib/reading/attribute/experiences/progress_subattribute.rb +48 -0
- data/lib/reading/attribute/experiences/spans_subattribute.rb +82 -0
- data/lib/reading/attribute/variants/extra_info_subattribute.rb +44 -0
- data/lib/reading/attribute/variants/length_subattribute.rb +45 -0
- data/lib/reading/attribute/variants/series_subattribute.rb +57 -0
- data/lib/reading/attribute/variants/sources_subattribute.rb +78 -0
- data/lib/reading/attribute/variants/variants_attribute.rb +69 -0
- data/lib/reading/config.rb +202 -0
- data/lib/reading/csv.rb +67 -0
- data/lib/reading/errors.rb +77 -0
- data/lib/reading/line.rb +23 -0
- data/lib/reading/row/blank_row.rb +23 -0
- data/lib/reading/row/compact_planned_row.rb +130 -0
- data/lib/reading/row/regular_row.rb +94 -0
- data/lib/reading/row/row.rb +88 -0
- data/lib/reading/util/blank.rb +146 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +40 -0
- data/lib/reading/util/hash_compact_by_template.rb +38 -0
- data/lib/reading/util/hash_deep_merge.rb +44 -0
- data/lib/reading/util/hash_to_struct.rb +29 -0
- data/lib/reading/util/string_remove.rb +28 -0
- data/lib/reading/util/string_truncate.rb +13 -0
- data/lib/reading/version.rb +3 -0
- metadata +174 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative "series_subattribute"
|
2
|
+
require_relative "sources_subattribute"
|
3
|
+
require_relative "length_subattribute"
|
4
|
+
require_relative "extra_info_subattribute"
|
5
|
+
|
6
|
+
module Reading
|
7
|
+
class Row
|
8
|
+
class VariantsAttribute < Attribute
|
9
|
+
using Util::HashArrayDeepFetch
|
10
|
+
|
11
|
+
def parse
|
12
|
+
sources_str = columns[:sources]&.presence || " "
|
13
|
+
|
14
|
+
format_as_separator = config.deep_fetch(:csv, :regex, :formats_split)
|
15
|
+
|
16
|
+
sources_str.split(format_as_separator).map { |variant_with_extras|
|
17
|
+
# without extra info or series
|
18
|
+
bare_variant = variant_with_extras
|
19
|
+
.split(config.deep_fetch(:csv, :long_separator))
|
20
|
+
.first
|
21
|
+
|
22
|
+
series_attr = SeriesSubattribute.new(item_head:, variant_with_extras:, config:)
|
23
|
+
sources_attr = SourcesSubattribute.new(bare_variant:, config:)
|
24
|
+
# Length, despite not being very complex, is still split out into a
|
25
|
+
# subattribute because it needs to be accessible to
|
26
|
+
# ExperiencesAttribute (more specifically SpansSubattribute) which
|
27
|
+
# uses length as a default value for amount.
|
28
|
+
length_attr = LengthSubattribute.new(bare_variant:, columns:, config:)
|
29
|
+
extra_info_attr = ExtraInfoSubattribute.new(item_head:, variant_with_extras:, config:)
|
30
|
+
|
31
|
+
variant =
|
32
|
+
{
|
33
|
+
format: format(bare_variant) || format(item_head) || template.fetch(:format),
|
34
|
+
series: series_attr.parse || template.fetch(:series),
|
35
|
+
sources: sources_attr.parse || template.fetch(:sources),
|
36
|
+
isbn: isbn(bare_variant) || template.fetch(:isbn),
|
37
|
+
length: length_attr.parse || template.fetch(:length),
|
38
|
+
extra_info: extra_info_attr.parse || template.fetch(:extra_info)
|
39
|
+
}
|
40
|
+
|
41
|
+
if variant != template
|
42
|
+
variant
|
43
|
+
else
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
}.compact.presence
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def template
|
52
|
+
@template ||= config.deep_fetch(:item, :template, :variants).first
|
53
|
+
end
|
54
|
+
|
55
|
+
def format(str)
|
56
|
+
emoji = str.match(/^#{config.deep_fetch(:csv, :regex, :formats)}/).to_s
|
57
|
+
config.deep_fetch(:item, :formats).key(emoji)
|
58
|
+
end
|
59
|
+
|
60
|
+
def isbn(str)
|
61
|
+
isbns = str.scan(config.deep_fetch(:csv, :regex, :isbn))
|
62
|
+
if isbns.count > 1
|
63
|
+
raise InvalidSourceError, "Only one ISBN/ASIN is allowed per item variant"
|
64
|
+
end
|
65
|
+
isbns[0]&.to_s
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
module Reading
|
2
|
+
# Builds a hash config.
|
3
|
+
class Config
|
4
|
+
using Util::HashDeepMerge
|
5
|
+
using Util::HashArrayDeepFetch
|
6
|
+
|
7
|
+
attr_reader :hash
|
8
|
+
|
9
|
+
# @param custom_config [Hash] a custom config which overrides the defaults,
|
10
|
+
# e.g. { errors: { styling: :html } }
|
11
|
+
def initialize(custom_config = {})
|
12
|
+
@custom_config = custom_config
|
13
|
+
|
14
|
+
build_hash
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# Builds a hash of the default config combined with the given custom config.
|
20
|
+
# @return [Hash]
|
21
|
+
def build_hash
|
22
|
+
@hash = default_config.deep_merge(@custom_config)
|
23
|
+
|
24
|
+
# If custom formats are given, use only the custom formats. #dig is used here
|
25
|
+
# (not #deep_fetch as most elsewhere) because custom_config may not include this data.
|
26
|
+
if @custom_config[:item] && @custom_config.dig(:item, :formats)
|
27
|
+
@hash[:item][:formats] = @custom_config.dig(:item, :formats)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Validate enabled_columns
|
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) }
|
35
|
+
|
36
|
+
# Add the Regex config, which is built based on the config so far.
|
37
|
+
@hash[:csv][:regex] = build_regex_config
|
38
|
+
end
|
39
|
+
|
40
|
+
# The default config, excluding Regex config (see further down).
|
41
|
+
# @return [Hash]
|
42
|
+
def default_config
|
43
|
+
{
|
44
|
+
errors:
|
45
|
+
{
|
46
|
+
handle_error: -> (error) { puts error },
|
47
|
+
max_length: 100, # or require "io/console", then IO.console.winsize[1]
|
48
|
+
catch_all_errors: false, # set this to false during development.
|
49
|
+
styling: :terminal, # or :html
|
50
|
+
},
|
51
|
+
item:
|
52
|
+
{
|
53
|
+
formats:
|
54
|
+
{
|
55
|
+
print: "📕",
|
56
|
+
ebook: "⚡",
|
57
|
+
audiobook: "🔊",
|
58
|
+
pdf: "📄",
|
59
|
+
audio: "🎤",
|
60
|
+
video: "🎞️",
|
61
|
+
course: "🏫",
|
62
|
+
piece: "✏️",
|
63
|
+
website: "🌐",
|
64
|
+
},
|
65
|
+
sources:
|
66
|
+
{
|
67
|
+
names_from_urls:
|
68
|
+
{
|
69
|
+
"youtube.com" => "YouTube",
|
70
|
+
"youtu.be" => "YouTube",
|
71
|
+
"books.google.com" => "Google Books",
|
72
|
+
"archive.org" => "Internet Archive",
|
73
|
+
"thegreatcourses.com" => "The Great Courses",
|
74
|
+
"librivox.org" => "LibriVox",
|
75
|
+
"tv.apple.com" => "Apple TV",
|
76
|
+
},
|
77
|
+
default_name_for_url: "site",
|
78
|
+
},
|
79
|
+
template:
|
80
|
+
{
|
81
|
+
rating: nil,
|
82
|
+
author: nil,
|
83
|
+
title: nil,
|
84
|
+
genres: [],
|
85
|
+
variants:
|
86
|
+
[{
|
87
|
+
format: nil,
|
88
|
+
series:
|
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: [],
|
101
|
+
}],
|
102
|
+
experiences:
|
103
|
+
[{
|
104
|
+
spans:
|
105
|
+
[{
|
106
|
+
dates: nil,
|
107
|
+
amount: nil,
|
108
|
+
progress: nil,
|
109
|
+
name: nil,
|
110
|
+
favorite?: false,
|
111
|
+
}],
|
112
|
+
group: nil,
|
113
|
+
variant_index: 0,
|
114
|
+
}],
|
115
|
+
notes:
|
116
|
+
[{
|
117
|
+
blurb?: false,
|
118
|
+
private?: false,
|
119
|
+
content: nil,
|
120
|
+
}],
|
121
|
+
},
|
122
|
+
},
|
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
|
+
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,
|
157
|
+
},
|
158
|
+
}
|
159
|
+
end
|
160
|
+
|
161
|
+
# Builds the Regex portion of the config, based on the given config.
|
162
|
+
# @return [Hash]
|
163
|
+
def build_regex_config
|
164
|
+
return @hash[:csv][:regex] if @hash.dig(:csv, :regex)
|
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
|
+
|
176
|
+
isbn_lookbehind = "(?<=\\A|\\s|#{@hash.deep_fetch(:csv, :separator)})"
|
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}/
|
180
|
+
|
181
|
+
{
|
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
|
+
formats: /#{formats}/,
|
185
|
+
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
|
+
}
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
data/lib/reading/csv.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
# Used throughout, in other files.
|
2
|
+
require_relative "util/blank"
|
3
|
+
require_relative "util/string_remove"
|
4
|
+
require_relative "util/string_truncate"
|
5
|
+
require_relative "util/hash_to_struct"
|
6
|
+
require_relative "util/hash_deep_merge"
|
7
|
+
require_relative "util/hash_array_deep_fetch"
|
8
|
+
require_relative "util/hash_compact_by_template"
|
9
|
+
require_relative "errors"
|
10
|
+
|
11
|
+
# Used just here.
|
12
|
+
require_relative "config"
|
13
|
+
require_relative "line"
|
14
|
+
|
15
|
+
module Reading
|
16
|
+
class CSV
|
17
|
+
using Util::HashDeepMerge
|
18
|
+
using Util::HashArrayDeepFetch
|
19
|
+
using Util::HashToStruct
|
20
|
+
|
21
|
+
attr_reader :config
|
22
|
+
|
23
|
+
# @param feed [Object] the input source, which must respond to #each_line;
|
24
|
+
# if nil, the file at the given path is used.
|
25
|
+
# @param path [String] the path of the source file.
|
26
|
+
# @param config [Hash] a custom config which overrides the defaults,
|
27
|
+
# e.g. { errors: { styling: :html } }
|
28
|
+
def initialize(feed = nil, path: nil, config: {})
|
29
|
+
if feed.nil? && path.nil?
|
30
|
+
raise ArgumentError, "No file given to load."
|
31
|
+
end
|
32
|
+
|
33
|
+
if path
|
34
|
+
if !File.exist?(path)
|
35
|
+
raise FileError, "File not found! #{@path}"
|
36
|
+
elsif File.directory?(path)
|
37
|
+
raise FileError, "The reading log must be a file, but the path given is a directory: #{@path}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
@feed = feed
|
42
|
+
@path = path
|
43
|
+
@config ||= Config.new(config).hash
|
44
|
+
end
|
45
|
+
|
46
|
+
# Parses a CSV reading log into item data (an array of Structs).
|
47
|
+
# For what the Structs look like, see the Hash at @default_config[:item][:template]
|
48
|
+
# in config.rb. The Structs are identical in structure to that Hash (with
|
49
|
+
# every inner Hash replaced with a Struct).
|
50
|
+
# @return [Array<Struct>] an array of Structs like the template in config.rb
|
51
|
+
def parse
|
52
|
+
feed = @feed || File.open(@path)
|
53
|
+
items = []
|
54
|
+
|
55
|
+
feed.each_line do |string|
|
56
|
+
line = Line.new(string, self)
|
57
|
+
row = line.to_row
|
58
|
+
|
59
|
+
items += row.parse
|
60
|
+
end
|
61
|
+
|
62
|
+
items.map(&:to_struct)
|
63
|
+
ensure
|
64
|
+
feed&.close if feed.respond_to?(:close)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "pastel"
|
2
|
+
|
3
|
+
module Reading
|
4
|
+
# The base error class, which provides flexible error handling.
|
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 # # # # # # # # # # # # # # # # # # # # # # # # # #
|
52
|
+
|
53
|
+
# Means there was a problem accessing a file.
|
54
|
+
class FileError < Reading::Error; end
|
55
|
+
|
56
|
+
# MISC # # # # # # # # # # # # # # # # # # # # # # # # # #
|
57
|
+
|
58
|
+
# Means the user-supplied custom config is invalid.
|
59
|
+
class ConfigError < Reading::Error; end
|
60
|
+
|
61
|
+
# VALIDATION # # # # # # # # # # # # # # # # # # # # # # #
|
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
|
68
|
+
|
69
|
+
# Means something in the Head column (author, title, etc.) is invalid.
|
70
|
+
class InvalidHeadError < Reading::Error; end
|
71
|
+
|
72
|
+
# Means the Rating column can't be parsed as a number.
|
73
|
+
class InvalidRatingError < Reading::Error; end
|
74
|
+
|
75
|
+
# Means a valid length is missing.
|
76
|
+
class InvalidLengthError < Reading::Error; end
|
77
|
+
end
|
data/lib/reading/line.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require_relative "row/compact_planned_row"
|
2
|
+
require_relative "row/blank_row"
|
3
|
+
require_relative "row/regular_row"
|
4
|
+
|
5
|
+
module Reading
|
6
|
+
# A bridge between rows as strings and as parsable Rows, used whenever the
|
7
|
+
# context of the line in the CSV is needed, e.g. converting a line to a Row,
|
8
|
+
# or adding a CSV line to a Row parsing error.
|
9
|
+
class Line
|
10
|
+
attr_reader :string, :csv
|
11
|
+
|
12
|
+
def initialize(string, csv)
|
13
|
+
@string = string.dup.force_encoding(Encoding::UTF_8).strip
|
14
|
+
@csv = csv
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_row
|
18
|
+
return CompactPlannedRow.new(self) if CompactPlannedRow.match?(self)
|
19
|
+
return BlankRow.new(self) if BlankRow.match?(self)
|
20
|
+
RegularRow.new(self)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require_relative "row"
|
2
|
+
|
3
|
+
module Reading
|
4
|
+
# An empty or commented-out row. A null object which returns an empty array.
|
5
|
+
class BlankRow < Row
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
# Whether the given CSV line is a blank row.
|
9
|
+
# @param line [Reading::Line]
|
10
|
+
# @return [Boolean]
|
11
|
+
def self.match?(line)
|
12
|
+
comment_char = line.csv.config.deep_fetch(:csv, :comment_character)
|
13
|
+
|
14
|
+
line.string.strip.empty? ||
|
15
|
+
line.string.strip.start_with?(comment_char)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Overrides Row#parse.
|
19
|
+
def parse
|
20
|
+
[]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
require_relative "row"
|
2
|
+
require "debug"
|
3
|
+
|
4
|
+
module Reading
|
5
|
+
# Parses a row of compactly listed planned items into an array of hashes of
|
6
|
+
# item data.
|
7
|
+
class CompactPlannedRow < Row
|
8
|
+
using Util::StringRemove
|
9
|
+
using Util::HashDeepMerge
|
10
|
+
using Util::HashArrayDeepFetch
|
11
|
+
|
12
|
+
# Whether the given CSV line is a compact planned row.
|
13
|
+
# @param line [Reading::Line]
|
14
|
+
# @return [Boolean]
|
15
|
+
def self.match?(line)
|
16
|
+
comment_char = line.csv.config.deep_fetch(:csv, :comment_character)
|
17
|
+
|
18
|
+
line.string.strip.start_with?(comment_char) &&
|
19
|
+
line.string.match?(line.csv.config.deep_fetch(:csv, :regex, :compact_planned_row_start))
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def skip?
|
25
|
+
config.deep_fetch(:csv, :skip_compact_planned)
|
26
|
+
end
|
27
|
+
|
28
|
+
def before_parse
|
29
|
+
to_ignore = config.deep_fetch(:csv, :regex, :compact_planned_ignored_chars)
|
30
|
+
start_regex = config.deep_fetch(:csv, :regex, :compact_planned_row_start)
|
31
|
+
|
32
|
+
string_without_ignored_chars = string.remove_all(to_ignore)
|
33
|
+
start = string_without_ignored_chars.match(start_regex)
|
34
|
+
|
35
|
+
@genres = Array(start[:genres]&.downcase&.strip&.split(",")&.map(&:strip))
|
36
|
+
@sources = sources(start[:sources])
|
37
|
+
@row_without_genre = string_without_ignored_chars.remove(start.to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
def string_to_be_split_by_format_emojis
|
41
|
+
@row_without_genre
|
42
|
+
end
|
43
|
+
|
44
|
+
def item_hash(item_head)
|
45
|
+
item_match = item_head.match(config.deep_fetch(:csv, :regex, :compact_planned_item))
|
46
|
+
unless item_match
|
47
|
+
raise InvalidHeadError, "Title missing after #{item_head} in compact planned row"
|
48
|
+
end
|
49
|
+
|
50
|
+
author = AuthorAttribute.new(item_head: item_match[:author_title], config:).parse
|
51
|
+
|
52
|
+
begin
|
53
|
+
title = TitleAttribute.new(item_head: item_match[:author_title], config:).parse
|
54
|
+
rescue InvalidHeadError
|
55
|
+
raise InvalidHeadError, "Title missing after #{item_head} in compact planned row"
|
56
|
+
end
|
57
|
+
|
58
|
+
if item_match[:sources_column]
|
59
|
+
if item_match[:sources_column].include?(config.deep_fetch(:csv, :column_separator))
|
60
|
+
raise InvalidSourceError, "Too many columns (only Sources allowed) " \
|
61
|
+
"after #{item_head} in compact planned row"
|
62
|
+
end
|
63
|
+
|
64
|
+
variants_attr = VariantsAttribute.new(
|
65
|
+
item_head: item_match[:format_emoji] + item_match[:author_title],
|
66
|
+
columns: { sources: item_match[:sources_column], length: nil },
|
67
|
+
config:,
|
68
|
+
)
|
69
|
+
variants = variants_attr.parse
|
70
|
+
else
|
71
|
+
variants = [parse_variant(item_match)]
|
72
|
+
end
|
73
|
+
|
74
|
+
template.deep_merge(
|
75
|
+
author: author || template.fetch(:author),
|
76
|
+
title: title,
|
77
|
+
genres: @genres.presence || template.fetch(:genres),
|
78
|
+
variants:,
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
def template
|
83
|
+
@template ||= config.deep_fetch(:item, :template)
|
84
|
+
end
|
85
|
+
|
86
|
+
def parse_variant(item_match)
|
87
|
+
item_head = item_match[:format_emoji] + item_match[:author_title]
|
88
|
+
series_attr = SeriesSubattribute.new(item_head:, config:)
|
89
|
+
extra_info_attr = ExtraInfoSubattribute.new(item_head:, config:)
|
90
|
+
sources = (@sources + sources(item_match[:sources])).uniq.presence
|
91
|
+
|
92
|
+
{
|
93
|
+
format: format(item_match[:format_emoji]),
|
94
|
+
series: series_attr.parse_head || template.deep_fetch(:variants, 0, :series),
|
95
|
+
sources: sources || template.deep_fetch(:variants, 0, :sources),
|
96
|
+
isbn: template.deep_fetch(:variants, 0, :isbn),
|
97
|
+
length: template.deep_fetch(:variants, 0, :length),
|
98
|
+
extra_info: extra_info_attr.parse_head || template.deep_fetch(:variants, 0, :extra_info),
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def format(format_emoji)
|
103
|
+
config.deep_fetch(:item, :formats).key(format_emoji)
|
104
|
+
end
|
105
|
+
|
106
|
+
def sources(sources_str)
|
107
|
+
return [] if sources_str.nil?
|
108
|
+
|
109
|
+
sources_str
|
110
|
+
.split(config.deep_fetch(:csv, :compact_planned_source_prefix))
|
111
|
+
.map { |source| source.remove(/\s*,\s*/) }
|
112
|
+
.map(&:strip)
|
113
|
+
.reject(&:empty?)
|
114
|
+
.map { |source_name|
|
115
|
+
if valid_url?(source_name)
|
116
|
+
source_name = source_name.chop if source_name.chars.last == "/"
|
117
|
+
{ name: config.deep_fetch(:item, :sources, :default_name_for_url),
|
118
|
+
url: source_name }
|
119
|
+
else
|
120
|
+
{ name: source_name,
|
121
|
+
url: nil }
|
122
|
+
end
|
123
|
+
}
|
124
|
+
end
|
125
|
+
|
126
|
+
def valid_url?(str)
|
127
|
+
str&.match?(/http[^\s,]+/)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require_relative "row"
|
2
|
+
require_relative "../attribute/all_attributes"
|
3
|
+
|
4
|
+
module Reading
|
5
|
+
# Parses a normal CSV row into an array of hashes of item data. Typically
|
6
|
+
# a normal row describes one item and so it's parsed into an array containing
|
7
|
+
# a single hash, but it's also possible for a row to describe multiple items.
|
8
|
+
class RegularRow < Row
|
9
|
+
using Util::HashArrayDeepFetch
|
10
|
+
|
11
|
+
private attr_reader :columns, :attribute_classes
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def after_initialize
|
16
|
+
set_attribute_classes
|
17
|
+
end
|
18
|
+
|
19
|
+
def before_parse
|
20
|
+
set_columns
|
21
|
+
ensure_head_column_present
|
22
|
+
end
|
23
|
+
|
24
|
+
def string_to_be_split_by_format_emojis
|
25
|
+
columns[:head]
|
26
|
+
end
|
27
|
+
|
28
|
+
def set_attribute_classes
|
29
|
+
@attribute_classes ||= config.deep_fetch(:item, :template).map { |attribute_name, _default|
|
30
|
+
attribute_name_camelcase = attribute_name.to_s.split("_").map(&:capitalize).join
|
31
|
+
attribute_class_name = "#{attribute_name_camelcase}Attribute"
|
32
|
+
attribute_class = self.class.const_get(attribute_class_name)
|
33
|
+
|
34
|
+
[attribute_name, attribute_class]
|
35
|
+
}.to_h
|
36
|
+
.merge(custom_attribute_classes)
|
37
|
+
end
|
38
|
+
|
39
|
+
def custom_attribute_classes
|
40
|
+
numeric = custom_attribute_classes_of_type(:numeric) do |value|
|
41
|
+
Float(value, exception: false)
|
42
|
+
end
|
43
|
+
|
44
|
+
text = custom_attribute_classes_of_type(:text) do |value|
|
45
|
+
value
|
46
|
+
end
|
47
|
+
|
48
|
+
(numeric + text).to_h
|
49
|
+
end
|
50
|
+
|
51
|
+
def custom_attribute_classes_of_type(type, &process_value)
|
52
|
+
config.deep_fetch(:csv, :"custom_#{type}_columns").map { |attribute, _default_value|
|
53
|
+
custom_class = Class.new(Attribute)
|
54
|
+
|
55
|
+
custom_class.define_method(:parse) do
|
56
|
+
value = columns[attribute.to_sym]&.strip&.presence
|
57
|
+
process_value.call(value)
|
58
|
+
end
|
59
|
+
|
60
|
+
[attribute.to_sym, custom_class]
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
def set_columns
|
65
|
+
@columns = (
|
66
|
+
config.deep_fetch(:csv, :enabled_columns) +
|
67
|
+
config.deep_fetch(:csv, :custom_numeric_columns).keys +
|
68
|
+
config.deep_fetch(:csv, :custom_text_columns).keys
|
69
|
+
)
|
70
|
+
.zip(string.split(config.deep_fetch(:csv, :column_separator)))
|
71
|
+
.to_h
|
72
|
+
end
|
73
|
+
|
74
|
+
def ensure_head_column_present
|
75
|
+
if columns[:head].nil? || columns[:head].strip.empty?
|
76
|
+
raise InvalidHeadError, "The Head column must not be blank"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def item_hash(item_head)
|
81
|
+
config
|
82
|
+
.deep_fetch(:item, :template)
|
83
|
+
.merge(config.deep_fetch(:csv, :custom_numeric_columns))
|
84
|
+
.merge(config.deep_fetch(:csv, :custom_text_columns))
|
85
|
+
.map { |attribute_name, default_value|
|
86
|
+
attribute_class = attribute_classes.fetch(attribute_name)
|
87
|
+
attribute_parser = attribute_class.new(item_head:, columns:, config:)
|
88
|
+
parsed = attribute_parser.parse
|
89
|
+
|
90
|
+
[attribute_name, parsed || default_value]
|
91
|
+
}.to_h
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|