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.
@@ -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
@@ -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
@@ -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