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
@@ -0,0 +1,149 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
class Experiences < Attribute
|
5
|
+
# Methods to validate dates in spans. This does not cover all the ways
|
6
|
+
# dates can be invalid, just the ones not caught during parsing.
|
7
|
+
module SpansValidator
|
8
|
+
using Util::HashArrayDeepFetch
|
9
|
+
|
10
|
+
class << self
|
11
|
+
# Checks the dates in the given experiences hash, and raises an error
|
12
|
+
# at the first invalid date found.
|
13
|
+
# @param experiences [Array<Hash>] experience hashes.
|
14
|
+
# @param config [Hash] an entire config.
|
15
|
+
# @param history_column [Boolean] whether this validation is for
|
16
|
+
# experiences from the History column.
|
17
|
+
# @raise [InvalidDateError] if any date is invalid.
|
18
|
+
def validate(experiences, config, history_column: false)
|
19
|
+
if both_date_columns?(config)
|
20
|
+
validate_number_of_start_dates_and_end_dates(experiences)
|
21
|
+
end
|
22
|
+
|
23
|
+
if start_dates_column?(config) || history_column
|
24
|
+
validate_start_dates_are_in_order(experiences)
|
25
|
+
end
|
26
|
+
|
27
|
+
if end_dates_column?(config) || history_column
|
28
|
+
validate_end_dates_are_in_order(experiences)
|
29
|
+
end
|
30
|
+
|
31
|
+
if both_date_columns?(config) || history_column
|
32
|
+
validate_experiences_of_same_variant_do_not_overlap(experiences)
|
33
|
+
end
|
34
|
+
|
35
|
+
validate_spans_are_in_order_and_not_overlapping(experiences)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Whether the Start Dates column is enabled.
|
41
|
+
# @return [Boolean]
|
42
|
+
def start_dates_column?(config)
|
43
|
+
config.fetch(:enabled_columns).include?(:start_dates)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Whether the End Dates column is enabled.
|
47
|
+
# @return [Boolean]
|
48
|
+
def end_dates_column?(config)
|
49
|
+
config.fetch(:enabled_columns).include?(:end_dates)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Whether both the Start Dates and End Dates columns are enabled.
|
53
|
+
# @return [Boolean]
|
54
|
+
def both_date_columns?(config)
|
55
|
+
start_dates_column?(config) && end_dates_column?(config)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Raises an error if there are more end dates than start dates, or
|
59
|
+
# if there is more than one more start date than end dates.
|
60
|
+
# @raise [InvalidDateError]
|
61
|
+
def validate_number_of_start_dates_and_end_dates(experiences)
|
62
|
+
both_dates, not_both_dates = experiences
|
63
|
+
.filter { |exp| exp[:spans].first&.dig(:dates) }
|
64
|
+
.map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
|
65
|
+
.partition { |start_date, end_date| start_date && end_date }
|
66
|
+
|
67
|
+
all_dates_paired = not_both_dates.empty?
|
68
|
+
last_date_started_present = not_both_dates.count == 1 && not_both_dates.first
|
69
|
+
|
70
|
+
unless all_dates_paired || last_date_started_present
|
71
|
+
raise InvalidDateError, "Start dates or end dates are missing"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Raises an error if the spans' first start dates are not in order.
|
76
|
+
# @raise [InvalidDateError]
|
77
|
+
def validate_start_dates_are_in_order(experiences)
|
78
|
+
experiences
|
79
|
+
.filter { |exp| exp[:spans].first&.dig(:dates) }
|
80
|
+
.map { |exp| exp[:spans].first[:dates].begin }
|
81
|
+
.each_cons(2) do |a, b|
|
82
|
+
if (a.nil? && b.nil?) || (a && b && a > b )
|
83
|
+
raise InvalidDateError, "Start dates are not in order"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Raises an error if the spans' last end dates are not in order.
|
89
|
+
# @raise [InvalidDateError]
|
90
|
+
def validate_end_dates_are_in_order(experiences)
|
91
|
+
experiences
|
92
|
+
.filter { |exp| exp[:spans].first&.dig(:dates) }
|
93
|
+
.map { |exp| exp[:spans].last[:dates].end }
|
94
|
+
.each_cons(2) do |a, b|
|
95
|
+
if (a.nil? && b.nil?) || (a && b && a > b )
|
96
|
+
raise InvalidDateError, "End dates are not in order"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Raises an error if two experiences of the same variant overlap.
|
102
|
+
# @raise [InvalidDateError]
|
103
|
+
def validate_experiences_of_same_variant_do_not_overlap(experiences)
|
104
|
+
experiences
|
105
|
+
.group_by { |exp| exp[:variant_index] }
|
106
|
+
.each do |_variant_index, exps|
|
107
|
+
exps.filter { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
|
108
|
+
a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
|
109
|
+
b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
|
110
|
+
if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
|
111
|
+
b_metaspan.cover?(a_metaspan.begin || b_metaspan.begin || b_metaspan.end)
|
112
|
+
raise InvalidDateError, "Experiences are overlapping"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Raises an error if the spans within an experience are out of order
|
119
|
+
# or if the spans overlap.
|
120
|
+
# @raise [InvalidDateError]
|
121
|
+
def validate_spans_are_in_order_and_not_overlapping(experiences)
|
122
|
+
experiences
|
123
|
+
.filter { |exp| exp[:spans].first&.dig(:dates) }
|
124
|
+
.each do |exp|
|
125
|
+
exp[:spans]
|
126
|
+
.map { |span| span[:dates] }
|
127
|
+
# Exclude nil dates (planned entries in History).
|
128
|
+
.reject { |dates| dates.nil? }
|
129
|
+
.each do |dates|
|
130
|
+
if dates.begin && dates.end && dates.begin > dates.end
|
131
|
+
raise InvalidDateError, "A date range is backward"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
.each_cons(2) do |a, b|
|
135
|
+
if a.begin > b.begin || a.end > b.end
|
136
|
+
raise InvalidDateError, "Dates are not in order"
|
137
|
+
end
|
138
|
+
if a.cover?(b.begin + 1)
|
139
|
+
raise InvalidDateError, "Dates are overlapping"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "date"
|
2
|
+
require_relative "experiences/history_transformer"
|
3
|
+
require_relative "experiences/dates_and_head_transformer"
|
4
|
+
|
5
|
+
module Reading
|
6
|
+
module Parsing
|
7
|
+
module Attributes
|
8
|
+
# Transformer for the :experiences item attribute.
|
9
|
+
class Experiences < Attribute
|
10
|
+
using Util::HashArrayDeepFetch
|
11
|
+
using Util::HashDeepMerge
|
12
|
+
|
13
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
14
|
+
# @param head_index [Integer] current item's position in the Head column.
|
15
|
+
# @return [Array<Hash>] an array of experiences; see
|
16
|
+
# Config#default_config[:item_template][:experiences]
|
17
|
+
def transform_from_parsed(parsed_row, head_index)
|
18
|
+
if !parsed_row[:history].blank?
|
19
|
+
return HistoryTransformer.new(parsed_row, config).transform
|
20
|
+
end
|
21
|
+
|
22
|
+
DatesAndHeadTransformer.new(parsed_row, head_index, config).transform
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :genres item attribute.
|
5
|
+
class Genres < 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 [Array<String>]
|
9
|
+
def transform_from_parsed(parsed_row, head_index)
|
10
|
+
(parsed_row[:genres] || parsed_row[:head][head_index][:genres])
|
11
|
+
&.map { _1.is_a?(Hash) ? _1[:genre] : _1 }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :notes item attribute.
|
5
|
+
class Notes < 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 [Array<Hash>] an array of notes; see
|
9
|
+
# Config#default_config[:item_template][:notes]
|
10
|
+
def transform_from_parsed(parsed_row, _head_index)
|
11
|
+
parsed_row[:notes]&.map { |note|
|
12
|
+
{
|
13
|
+
blurb?: note.has_key?(:note_blurb),
|
14
|
+
private?: note.has_key?(:note_private),
|
15
|
+
content: note[:note_regular] || note[:note_blurb] || note[:note_private],
|
16
|
+
}
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :rating item attribute.
|
5
|
+
class Rating < 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 [Integer, Float]
|
9
|
+
def transform_from_parsed(parsed_row, _head_index)
|
10
|
+
rating = parsed_row[:rating]&.dig(:number)
|
11
|
+
|
12
|
+
Integer(rating, exception: false) || Float(rating, exception: false)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Shared
|
5
|
+
module Shared
|
6
|
+
# Extracts the :progress sub-attribute (percent, pages, or time) from
|
7
|
+
# the given hash.
|
8
|
+
# @param hash [Hash] any parsed hash that contains progress.
|
9
|
+
# @return [Float, Integer, Reading::Item::TimeLength]
|
10
|
+
def self.progress(hash)
|
11
|
+
hash[:progress_percent]&.to_f&./(100) ||
|
12
|
+
hash[:progress_pages]&.to_i ||
|
13
|
+
hash[:progress_time]&.then { Item::TimeLength.parse _1 } ||
|
14
|
+
(0 if hash[:progress_dnf]) ||
|
15
|
+
(1.0 if hash[:progress_done]) ||
|
16
|
+
nil
|
17
|
+
end
|
18
|
+
|
19
|
+
# Extracts the :length sub-attribute (pages or time) from the given hash.
|
20
|
+
# @param hash [Hash] any parsed hash that contains length.
|
21
|
+
# @param key_name [Symbol] the first part of the keys to be checked.
|
22
|
+
# @param episodic [Boolean] whether to look for episodic (not total) length.
|
23
|
+
# If false, returns nil if hash contains :each. If true, returns a
|
24
|
+
# length only if hash contains :each or if it has repetitions, in
|
25
|
+
# which case repetitions are ignored. Examples of episodic lengths
|
26
|
+
# (before parsing) are "0:30 each" and "1:00 x14" (where the episodic
|
27
|
+
# length is 1:00). Examples of non-episodic lengths are "0:30" and "14:00".
|
28
|
+
# @param ignore_repetitions [Boolean] if true, ignores repetitions so
|
29
|
+
# that e.g. "1:00 x14" gives a length of 1 hour instead of 14 hours.
|
30
|
+
# This is useful for the History column, where that 1 hour can be used
|
31
|
+
# as the default amount.
|
32
|
+
# @return [Float, Integer, Reading::Item::TimeLength]
|
33
|
+
def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
|
34
|
+
return nil unless hash
|
35
|
+
|
36
|
+
length = hash[:"#{key_name}_pages"]&.to_i ||
|
37
|
+
hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse _1 }
|
38
|
+
|
39
|
+
return nil unless length
|
40
|
+
|
41
|
+
if hash[:each]
|
42
|
+
# Length is calculated based on History column in this case.
|
43
|
+
if episodic
|
44
|
+
return length
|
45
|
+
else
|
46
|
+
return nil
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
if hash[:repetitions]
|
51
|
+
return length if episodic
|
52
|
+
length *= hash[:repetitions].to_i unless ignore_repetitions
|
53
|
+
else
|
54
|
+
return nil if episodic && !hash[:each]
|
55
|
+
end
|
56
|
+
|
57
|
+
length
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :title item attribute.
|
5
|
+
class Title < 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
|
+
title = parsed_row[:head][head_index][:title]
|
11
|
+
|
12
|
+
if title.nil? || title.end_with?(" -")
|
13
|
+
raise InvalidHeadError, "Missing title in the head #{parsed_row[:head][head_index]}"
|
14
|
+
end
|
15
|
+
|
16
|
+
title
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :variant item attribute.
|
5
|
+
class Variants < Attribute
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
9
|
+
# @param head_index [Integer] current item's position in the Head column.
|
10
|
+
# @return [Array<Hash>] an array of variants; see
|
11
|
+
# Config#default_config[:item_template][:variants]
|
12
|
+
def transform_from_parsed(parsed_row, head_index)
|
13
|
+
head = parsed_row[:head][head_index]
|
14
|
+
|
15
|
+
# || [{}] in case there is no Sources column.
|
16
|
+
(parsed_row[:sources].presence || [{}])&.map { |variant|
|
17
|
+
{
|
18
|
+
format: variant[:format] || head[:format],
|
19
|
+
series: (series(head) + series(variant)).presence,
|
20
|
+
sources: sources(variant) || sources(head),
|
21
|
+
isbn: variant[:isbn] || variant[:asin],
|
22
|
+
length: Attributes::Shared.length(variant) ||
|
23
|
+
Attributes::Shared.length(parsed_row[:length]),
|
24
|
+
extra_info: Array(head[:extra_info]) + Array(variant[:extra_info]),
|
25
|
+
}.map { |k, v| [k, v || template.fetch(k)] }.to_h
|
26
|
+
}&.compact&.presence
|
27
|
+
end
|
28
|
+
|
29
|
+
# A shortcut to the variant template.
|
30
|
+
# @return [Hash]
|
31
|
+
def template
|
32
|
+
config.deep_fetch(:item_template, :variants).first
|
33
|
+
end
|
34
|
+
|
35
|
+
# The :series sub-attribute for the given parsed hash.
|
36
|
+
# @param hash [Hash] any parsed hash that contains :series_names and :series_volumes.
|
37
|
+
# @return [Array<Hash>]
|
38
|
+
def series(hash)
|
39
|
+
(hash[:series_names] || [])
|
40
|
+
.zip(hash[:series_volumes] || [])
|
41
|
+
.map { |name, volume|
|
42
|
+
{ name:, volume: Integer(volume, exception: false) }
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
# The :sources sub-attribute for the given parsed hash.
|
47
|
+
# @param hash [Hash] any parsed hash that contains :sources.
|
48
|
+
# @return [Array<Hash>]
|
49
|
+
def sources(hash)
|
50
|
+
hash[:sources]&.map { |source|
|
51
|
+
if source.match?(/\Ahttps?:\/\//)
|
52
|
+
{ name: url_name(source), url: source }
|
53
|
+
else
|
54
|
+
{ name: source, url: nil }
|
55
|
+
end
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
# The name for the given URL string, according to
|
60
|
+
# config[:source_names_from_urls], or nil.
|
61
|
+
# @param url [String] a URL.
|
62
|
+
# @return [String, nil]
|
63
|
+
def url_name(url)
|
64
|
+
config
|
65
|
+
.fetch(:source_names_from_urls)
|
66
|
+
.each do |url_part, name|
|
67
|
+
if url.include?(url_part)
|
68
|
+
return name
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,101 @@
|
|
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/numeric_to_i_if_whole"
|
6
|
+
require_relative "../util/hash_to_struct"
|
7
|
+
require_relative "../util/hash_deep_merge"
|
8
|
+
require_relative "../util/hash_array_deep_fetch"
|
9
|
+
require_relative "../util/hash_compact_by_template"
|
10
|
+
require_relative "../errors"
|
11
|
+
|
12
|
+
# Used just here.
|
13
|
+
require_relative "../config"
|
14
|
+
require_relative "parser"
|
15
|
+
require_relative "transformer"
|
16
|
+
|
17
|
+
module Reading
|
18
|
+
module Parsing
|
19
|
+
#
|
20
|
+
# Validates a path or stream (string, file, etc.) of a CSV reading log, then
|
21
|
+
# parses it into item data (an array of Structs).
|
22
|
+
#
|
23
|
+
# Parsing happens in two steps:
|
24
|
+
# (1) Parse a row string into an intermediate hash representing the columns.
|
25
|
+
# - See parsing/parser.rb, which uses parsing/rows/*
|
26
|
+
# (2) Transform the intermediate hash into an array of hashes structured
|
27
|
+
# around item attributes rather than CSV columns.
|
28
|
+
# - See parsing/transformer.rb, which uses parsing/attributes/*
|
29
|
+
#
|
30
|
+
# Keeping these steps separate makes the code easier to understand. It was
|
31
|
+
# inspired by the Parslet gem: https://kschiess.github.io/parslet/transform.html
|
32
|
+
#
|
33
|
+
class CSV
|
34
|
+
using Util::HashToStruct
|
35
|
+
|
36
|
+
private attr_reader :parser, :transformer
|
37
|
+
|
38
|
+
# Validates a path or stream (string, file, etc.) of a CSV reading log,
|
39
|
+
# builds the config, and initializes the parser and transformer.
|
40
|
+
# @param path [String] path to the CSV file; if nil, stream is used instead.
|
41
|
+
# @param stream [Object] an object responding to #each_linewith CSV row(s);
|
42
|
+
# used if no path is given.
|
43
|
+
# @param config [Hash] a custom config which overrides the defaults,
|
44
|
+
# e.g. { errors: { styling: :html } }
|
45
|
+
def initialize(path = nil, stream: nil, config: {})
|
46
|
+
validate_path_or_stream(path, stream)
|
47
|
+
full_config = Config.new(config).hash
|
48
|
+
|
49
|
+
@path = path
|
50
|
+
@stream = stream
|
51
|
+
@parser = Parser.new(full_config)
|
52
|
+
@transformer = Transformer.new(full_config)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Parses and transforms the reading log into item data.
|
56
|
+
# @return [Array<Struct>] an array of Structs like the template in
|
57
|
+
# Config#default_config[:item_template]. The Structs are identical in
|
58
|
+
# structure to that Hash (with every inner Hash replaced by a Struct).
|
59
|
+
def parse
|
60
|
+
input = @path ? File.open(@path) : @stream
|
61
|
+
items = []
|
62
|
+
|
63
|
+
input.each_line do |line|
|
64
|
+
begin
|
65
|
+
intermediate = parser.parse_row_to_intermediate_hash(line)
|
66
|
+
next if intermediate.empty? # When the row is blank or a comment.
|
67
|
+
row_items = transformer.transform_intermediate_hash_to_item_hashes(intermediate)
|
68
|
+
rescue Reading::Error => e
|
69
|
+
raise e.class, "#{e.message} in the row \"#{line}\""
|
70
|
+
end
|
71
|
+
|
72
|
+
items += row_items
|
73
|
+
end
|
74
|
+
|
75
|
+
items.map(&:to_struct)
|
76
|
+
ensure
|
77
|
+
input&.close if input.respond_to?(:close)
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
# Checks on the given stream and path (arguments to #initialize).
|
83
|
+
# @raise [FileError] if the given path is invalid.
|
84
|
+
# @raise [ArgumentError] if both stream and path are nil.
|
85
|
+
def validate_path_or_stream(path, stream)
|
86
|
+
if path
|
87
|
+
if !File.exist?(path)
|
88
|
+
raise FileError, "File not found! #{path}"
|
89
|
+
elsif File.directory?(path)
|
90
|
+
raise FileError, "A file is expected, but the path given is a directory: #{path}"
|
91
|
+
end
|
92
|
+
elsif stream && stream.respond_to?(:each_line)
|
93
|
+
return true
|
94
|
+
else
|
95
|
+
raise ArgumentError,
|
96
|
+
"Either a file path or a stream (string, file, etc.) must be provided."
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|