reading 0.8.0 → 0.9.1
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 +95 -10
- data/lib/reading/config.rb +27 -5
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/item/time_length.rb +60 -23
- data/lib/reading/item/view.rb +14 -19
- data/lib/reading/item.rb +324 -54
- data/lib/reading/parsing/attributes/attribute.rb +0 -7
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
- data/lib/reading/parsing/attributes/experiences.rb +5 -5
- data/lib/reading/parsing/attributes/shared.rb +17 -7
- data/lib/reading/parsing/attributes/variants.rb +9 -6
- data/lib/reading/parsing/csv.rb +38 -35
- data/lib/reading/parsing/parser.rb +23 -24
- data/lib/reading/parsing/rows/blank.rb +23 -0
- data/lib/reading/parsing/rows/comment.rb +6 -7
- data/lib/reading/parsing/rows/compact_planned.rb +9 -9
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
- data/lib/reading/parsing/rows/custom_config.rb +42 -0
- data/lib/reading/parsing/rows/regular.rb +15 -14
- data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
- data/lib/reading/parsing/rows/regular_columns/sources.rb +16 -10
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
- data/lib/reading/parsing/transformer.rb +13 -17
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +257 -0
- data/lib/reading/stats/operation.rb +345 -0
- data/lib/reading/stats/query.rb +37 -0
- data/lib/reading/stats/terminal_result_formatters.rb +91 -0
- data/lib/reading/util/exclude.rb +12 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
- data/lib/reading/util/hash_to_data.rb +2 -2
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +36 -21
- metadata +28 -24
- data/bin/readingfile +0 -31
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
@@ -4,6 +4,7 @@ module Reading
|
|
4
4
|
# Transformer for the :variant item attribute.
|
5
5
|
class Variants < Attribute
|
6
6
|
using Util::HashArrayDeepFetch
|
7
|
+
using Util::NumericToIIfWhole
|
7
8
|
|
8
9
|
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
9
10
|
# @param head_index [Integer] current item's position in the Head column.
|
@@ -14,13 +15,15 @@ module Reading
|
|
14
15
|
|
15
16
|
# || [{}] in case there is no Sources column.
|
16
17
|
(parsed_row[:sources].presence || [{}])&.map { |variant|
|
18
|
+
format = variant[:format] || head[:format]
|
19
|
+
|
17
20
|
{
|
18
|
-
format
|
21
|
+
format:,
|
19
22
|
series: (series(head) + series(variant)).presence,
|
20
23
|
sources: sources(variant) || sources(head),
|
21
24
|
isbn: variant[:isbn] || variant[:asin],
|
22
|
-
length: Attributes::Shared.length(variant) ||
|
23
|
-
Attributes::Shared.length(parsed_row[:length]),
|
25
|
+
length: Attributes::Shared.length(variant, format:) ||
|
26
|
+
Attributes::Shared.length(parsed_row[:length], format:),
|
24
27
|
extra_info: Array(head[:extra_info]) + Array(variant[:extra_info]),
|
25
28
|
}.map { |k, v| [k, v || template.fetch(k)] }.to_h
|
26
29
|
}&.compact&.presence
|
@@ -29,7 +32,7 @@ module Reading
|
|
29
32
|
# A shortcut to the variant template.
|
30
33
|
# @return [Hash]
|
31
34
|
def template
|
32
|
-
|
35
|
+
Config.hash.deep_fetch(:item, :template, :variants).first
|
33
36
|
end
|
34
37
|
|
35
38
|
# The :series sub-attribute for the given parsed hash.
|
@@ -57,11 +60,11 @@ module Reading
|
|
57
60
|
end
|
58
61
|
|
59
62
|
# The name for the given URL string, according to
|
60
|
-
#
|
63
|
+
# Config.hash[:source_names_from_urls], or nil.
|
61
64
|
# @param url [String] a URL.
|
62
65
|
# @return [String, nil]
|
63
66
|
def url_name(url)
|
64
|
-
|
67
|
+
Config.hash
|
65
68
|
.fetch(:source_names_from_urls)
|
66
69
|
.each do |url_part, name|
|
67
70
|
if url.include?(url_part)
|
data/lib/reading/parsing/csv.rb
CHANGED
@@ -1,23 +1,12 @@
|
|
1
|
-
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative "../util/numeric_to_i_if_whole"
|
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 "../item"
|
14
|
-
require_relative "parser"
|
15
|
-
require_relative "transformer"
|
1
|
+
require 'pastel'
|
2
|
+
require_relative '../item'
|
3
|
+
require_relative 'parser'
|
4
|
+
require_relative 'transformer'
|
16
5
|
|
17
6
|
module Reading
|
18
7
|
module Parsing
|
19
8
|
#
|
20
|
-
# Validates a path or
|
9
|
+
# Validates a path or lines (string, file, etc.) of a CSV reading log, then
|
21
10
|
# parses it into an array of Items.
|
22
11
|
#
|
23
12
|
# Parsing happens in two steps:
|
@@ -31,31 +20,35 @@ module Reading
|
|
31
20
|
# inspired by the Parslet gem: https://kschiess.github.io/parslet/transform.html
|
32
21
|
#
|
33
22
|
class CSV
|
34
|
-
private attr_reader :parser, :transformer, :hash_output, :item_view
|
23
|
+
private attr_reader :parser, :transformer, :hash_output, :item_view, :error_handler, :pastel
|
35
24
|
|
36
|
-
# Validates a path or
|
25
|
+
# Validates a path or lines (string, file, etc.) of a CSV reading log,
|
37
26
|
# builds the config, and initializes the parser and transformer.
|
38
|
-
# @param path [String] path to the CSV file; used if no
|
39
|
-
# @param
|
27
|
+
# @param path [String] path to the CSV file; used if no lines are given.
|
28
|
+
# @param lines [Object] an object responding to #each_line with CSV row(s);
|
40
29
|
# if nil, path is used instead.
|
41
|
-
# @param config [Hash] a custom config which overrides the defaults,
|
30
|
+
# @param config [Hash, Config] a custom config which overrides the defaults,
|
42
31
|
# e.g. { errors: { styling: :html } }
|
43
32
|
# @param hash_output [Boolean] whether an array of raw Hashes should be
|
44
33
|
# returned, without Items being created from them.
|
45
|
-
# @param
|
34
|
+
# @param item_view [Class, nil, Boolean] the class that will be used to build
|
46
35
|
# each Item's view object, or nil/false if no view object should be built.
|
47
36
|
# If you use a custom view class, the only requirement is that its
|
48
37
|
# #initialize take an Item and a full config as arguments.
|
49
|
-
|
50
|
-
|
51
|
-
|
38
|
+
# @param error_handler [Proc] if not provided, errors are raised.
|
39
|
+
def initialize(path: nil, lines: nil, config: nil, hash_output: false, item_view: Item::View, error_handler: nil)
|
40
|
+
validate_path_or_lines(path, lines)
|
41
|
+
|
42
|
+
Config.build(config) if config
|
52
43
|
|
53
44
|
@path = path
|
54
|
-
@
|
45
|
+
@lines = lines
|
55
46
|
@hash_output = hash_output
|
56
47
|
@item_view = item_view
|
57
|
-
@parser = Parser.new
|
58
|
-
@transformer = Transformer.new
|
48
|
+
@parser = Parser.new
|
49
|
+
@transformer = Transformer.new
|
50
|
+
@error_handler = error_handler
|
51
|
+
@pastel = Pastel.new
|
59
52
|
end
|
60
53
|
|
61
54
|
# Parses and transforms the reading log into item data.
|
@@ -64,16 +57,26 @@ module Reading
|
|
64
57
|
# structure to that Hash (with every inner Hash replaced by a Data for
|
65
58
|
# dot access).
|
66
59
|
def parse
|
67
|
-
input = @
|
60
|
+
input = @lines || File.open(@path)
|
68
61
|
items = []
|
69
62
|
|
70
63
|
input.each_line do |line|
|
71
64
|
begin
|
72
65
|
intermediate = parser.parse_row_to_intermediate_hash(line)
|
66
|
+
|
73
67
|
next if intermediate.empty? # When the row is blank or a comment.
|
68
|
+
|
74
69
|
row_items = transformer.transform_intermediate_hash_to_item_hashes(intermediate)
|
75
70
|
rescue Reading::Error => e
|
76
|
-
|
71
|
+
colored_e =
|
72
|
+
e.class.new("#{pastel.bright_red(e.message)} in the row #{pastel.bright_yellow(line.chomp)}")
|
73
|
+
|
74
|
+
if error_handler
|
75
|
+
error_handler.call(colored_e)
|
76
|
+
next
|
77
|
+
else
|
78
|
+
raise colored_e
|
79
|
+
end
|
77
80
|
end
|
78
81
|
|
79
82
|
items += row_items
|
@@ -90,11 +93,11 @@ module Reading
|
|
90
93
|
|
91
94
|
private
|
92
95
|
|
93
|
-
# Checks on the given
|
96
|
+
# Checks on the given lines and path (arguments to #initialize).
|
94
97
|
# @raise [FileError] if the given path is invalid.
|
95
|
-
# @raise [ArgumentError] if both
|
96
|
-
def
|
97
|
-
if
|
98
|
+
# @raise [ArgumentError] if both lines and path are nil.
|
99
|
+
def validate_path_or_lines(path, lines)
|
100
|
+
if lines && lines.respond_to?(:each_line)
|
98
101
|
return true
|
99
102
|
elsif path
|
100
103
|
if !File.exist?(path)
|
@@ -104,7 +107,7 @@ module Reading
|
|
104
107
|
end
|
105
108
|
else
|
106
109
|
raise ArgumentError,
|
107
|
-
"
|
110
|
+
"Provide either a file path or object implementing #each_line (String, File, etc.)."
|
108
111
|
end
|
109
112
|
end
|
110
113
|
end
|
@@ -1,6 +1,8 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
1
|
+
require_relative 'rows/blank'
|
2
|
+
require_relative 'rows/regular'
|
3
|
+
require_relative 'rows/compact_planned'
|
4
|
+
require_relative 'rows/custom_config'
|
5
|
+
require_relative 'rows/comment'
|
4
6
|
|
5
7
|
module Reading
|
6
8
|
module Parsing
|
@@ -43,14 +45,6 @@ module Reading
|
|
43
45
|
#
|
44
46
|
class Parser
|
45
47
|
using Util::HashArrayDeepFetch
|
46
|
-
using Util::StringRemove
|
47
|
-
|
48
|
-
attr_reader :config
|
49
|
-
|
50
|
-
# @param config [Hash] an entire config.
|
51
|
-
def initialize(config)
|
52
|
-
@config = config
|
53
|
-
end
|
54
48
|
|
55
49
|
# Parses a row string into a hash that mirrors the structure of the row.
|
56
50
|
# @param string [String] a string containing a row of a CSV reading log.
|
@@ -58,7 +52,7 @@ module Reading
|
|
58
52
|
def parse_row_to_intermediate_hash(string)
|
59
53
|
columns = extract_columns(string)
|
60
54
|
|
61
|
-
if
|
55
|
+
if Config.hash.fetch(:skip_compact_planned) && columns.has_key?(Rows::CompactPlanned::Head)
|
62
56
|
return {}
|
63
57
|
end
|
64
58
|
|
@@ -76,14 +70,19 @@ module Reading
|
|
76
70
|
# Parsing::Rows::Column.
|
77
71
|
def extract_columns(string)
|
78
72
|
string = string.dup.force_encoding(Encoding::UTF_8)
|
79
|
-
column_strings = string.split(
|
73
|
+
column_strings = string.split(Config.hash.fetch(:column_separator))
|
80
74
|
|
81
|
-
row_types = [Rows::Regular, Rows::CompactPlanned, Rows::Comment]
|
75
|
+
row_types = [Rows::Blank, Rows::Regular, Rows::CompactPlanned, Rows::CustomConfig, Rows::Comment]
|
82
76
|
column_classes = row_types
|
83
|
-
.find { |row_type| row_type.match?(string
|
77
|
+
.find { |row_type| row_type.match?(string) }
|
78
|
+
.tap { |row_type|
|
79
|
+
if row_type == Rows::CustomConfig
|
80
|
+
row_type.merge_custom_config!(string)
|
81
|
+
end
|
82
|
+
}
|
84
83
|
.column_classes
|
85
|
-
.
|
86
|
-
|
84
|
+
.select { |column_class|
|
85
|
+
Config.hash.fetch(:enabled_columns).include?(column_class.to_sym)
|
87
86
|
}
|
88
87
|
|
89
88
|
if !column_classes.count.zero? && column_strings.count > column_classes.count
|
@@ -123,7 +122,7 @@ module Reading
|
|
123
122
|
# it doesn't contain any format emojis, return the same as above but
|
124
123
|
# with an extra level of nesting (except when the parsed result is nil).
|
125
124
|
if column_class.split_by_format? &&
|
126
|
-
!column_string.match?(
|
125
|
+
!column_string.match?(Config.hash.deep_fetch(:regex, :formats))
|
127
126
|
|
128
127
|
parsed_column = parse_segments(column_class, column_string)
|
129
128
|
# Wrap a non-empty value in an array so that e.g. a head without
|
@@ -136,18 +135,18 @@ module Reading
|
|
136
135
|
# The rest is the complex case: if the column *can and is* split by format.
|
137
136
|
|
138
137
|
# Each format plus the string after it.
|
139
|
-
format_strings = column_string.split(
|
138
|
+
format_strings = column_string.split(Config.hash.deep_fetch(:regex, :formats_split))
|
140
139
|
|
141
140
|
# If there's a string before the first format, e.g. "DNF" in Head column.
|
142
|
-
unless format_strings.first.match?(
|
141
|
+
unless format_strings.first.match?(Config.hash.deep_fetch(:regex, :formats))
|
143
142
|
before_formats = parse_segment(column_class, format_strings.shift, before_formats: true)
|
144
143
|
end
|
145
144
|
|
146
145
|
# Parse each format-plus-string into an array of segments.
|
147
146
|
heads = format_strings.map { |string|
|
148
|
-
format_emoji = string[
|
149
|
-
string.
|
150
|
-
format =
|
147
|
+
format_emoji = string[Config.hash.deep_fetch(:regex, :formats)]
|
148
|
+
string.sub!(format_emoji, '')
|
149
|
+
format = Config.hash.fetch(:formats).key(format_emoji)
|
151
150
|
|
152
151
|
parse_segments(column_class, string)
|
153
152
|
.merge(format: format)
|
@@ -252,7 +251,7 @@ module Reading
|
|
252
251
|
# @return [Hash{Symbol => String}] e.g. { author: "Bram Stoker", title: "Dracula"}
|
253
252
|
def parse_segment_with_regex(segment, regex)
|
254
253
|
segment
|
255
|
-
.tr(
|
254
|
+
.tr(Config.hash.fetch(:ignored_characters), "")
|
256
255
|
.strip
|
257
256
|
.match(regex)
|
258
257
|
&.named_captures
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Rows
|
4
|
+
# A row that is a blank line.
|
5
|
+
module Blank
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
# No columns.
|
9
|
+
# @return [Array]
|
10
|
+
def self.column_classes
|
11
|
+
[]
|
12
|
+
end
|
13
|
+
|
14
|
+
# Is a blank line.
|
15
|
+
# @param row_string [String]
|
16
|
+
# @return [Boolean]
|
17
|
+
def self.match?(row_string)
|
18
|
+
row_string == "\n"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -5,20 +5,19 @@ module Reading
|
|
5
5
|
module Comment
|
6
6
|
using Util::HashArrayDeepFetch
|
7
7
|
|
8
|
-
# No columns;
|
8
|
+
# No columns; parsed as if the row were blank.
|
9
9
|
# @return [Array]
|
10
10
|
def self.column_classes
|
11
11
|
[]
|
12
12
|
end
|
13
13
|
|
14
|
-
# Starts with a comment character
|
15
|
-
#
|
16
|
-
#
|
14
|
+
# Starts with a comment character. Note: this must be called *after*
|
15
|
+
# calling ::match? on Rows::CompactPlanned and Rows::CustomConfig,
|
16
|
+
# because those check for starting with a comment character too.
|
17
17
|
# @param row_string [String]
|
18
|
-
# @param config [Hash]
|
19
18
|
# @return [Boolean]
|
20
|
-
def self.match?(row_string
|
21
|
-
row_string.lstrip.start_with?(
|
19
|
+
def self.match?(row_string)
|
20
|
+
row_string.lstrip.start_with?(Config.hash.fetch(:comment_character))
|
22
21
|
end
|
23
22
|
end
|
24
23
|
end
|
@@ -1,6 +1,7 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
1
|
+
require_relative 'column'
|
2
|
+
require_relative 'compact_planned_columns/head'
|
3
|
+
require_relative 'regular_columns/sources'
|
4
|
+
require_relative 'regular_columns/length'
|
4
5
|
|
5
6
|
module Reading
|
6
7
|
module Parsing
|
@@ -12,17 +13,16 @@ module Reading
|
|
12
13
|
# The columns that are possible in this type of row.
|
13
14
|
# @return [Array<Class>]
|
14
15
|
def self.column_classes
|
15
|
-
[CompactPlanned::Head, Regular::Sources]
|
16
|
+
[CompactPlanned::Head, Regular::Sources, Regular::Length]
|
16
17
|
end
|
17
18
|
|
18
19
|
# Starts with a comment character and includes one or more format emojis.
|
19
20
|
# @param row_string [String]
|
20
|
-
# @param config [Hash]
|
21
21
|
# @return [Boolean]
|
22
|
-
def self.match?(row_string
|
23
|
-
row_string.lstrip.start_with?(
|
24
|
-
row_string.match?(
|
25
|
-
row_string.count(
|
22
|
+
def self.match?(row_string)
|
23
|
+
row_string.lstrip.start_with?(Config.hash.fetch(:comment_character)) &&
|
24
|
+
row_string.match?(Config.hash.deep_fetch(:regex, :formats)) &&
|
25
|
+
row_string.count(Config.hash.fetch(:column_separator)) <= column_classes.count - 1
|
26
26
|
end
|
27
27
|
end
|
28
28
|
end
|
@@ -15,7 +15,7 @@ module Reading
|
|
15
15
|
\\ # comment character
|
16
16
|
\s*
|
17
17
|
(
|
18
|
-
(?<genres>[^a-z]+)?
|
18
|
+
(?<genres>[^a-z@]+)?
|
19
19
|
\s*
|
20
20
|
(?<sources>@.+)?
|
21
21
|
\s*:
|
@@ -49,7 +49,7 @@ module Reading
|
|
49
49
|
)?
|
50
50
|
(?<title>[^@]+)
|
51
51
|
(?<sources>@.+)?
|
52
|
-
\z}x if
|
52
|
+
\z}x if segment_index.zero?),
|
53
53
|
*Column::SHARED_REGEXES[:series_and_extra_info],
|
54
54
|
].compact
|
55
55
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Rows
|
4
|
+
# A row that declares custom config.
|
5
|
+
module CustomConfig
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
# No columns; parsed as if the row were blank.
|
9
|
+
# @return [Array]
|
10
|
+
def self.column_classes
|
11
|
+
[]
|
12
|
+
end
|
13
|
+
|
14
|
+
# Starts with a comment character and opening curly brace, and ends with
|
15
|
+
# a closing curly brace.
|
16
|
+
# @param row_string [String]
|
17
|
+
# @return [Boolean]
|
18
|
+
def self.match?(row_string)
|
19
|
+
row_string.match?(
|
20
|
+
%r{\A
|
21
|
+
\s*
|
22
|
+
#{Regexp.escape(Config.hash.fetch(:comment_character))}
|
23
|
+
\s*
|
24
|
+
\{.+\}
|
25
|
+
\s*
|
26
|
+
\z}x
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Adds this row's custom config to the singleton config.
|
31
|
+
# @param row_string [String]
|
32
|
+
# @param config [Hash] an entire config.
|
33
|
+
def self.merge_custom_config!(row_string)
|
34
|
+
stripped_row = row_string.strip.delete_prefix(Config.hash.fetch(:comment_character))
|
35
|
+
custom_config = eval(stripped_row)
|
36
|
+
|
37
|
+
Config.hash.merge!(custom_config)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -1,13 +1,13 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
6
|
-
require_relative
|
7
|
-
require_relative
|
8
|
-
require_relative
|
9
|
-
require_relative
|
10
|
-
require_relative
|
1
|
+
require_relative 'column'
|
2
|
+
require_relative 'regular_columns/rating'
|
3
|
+
require_relative 'regular_columns/head'
|
4
|
+
require_relative 'regular_columns/sources'
|
5
|
+
require_relative 'regular_columns/start_dates'
|
6
|
+
require_relative 'regular_columns/end_dates'
|
7
|
+
require_relative 'regular_columns/genres'
|
8
|
+
require_relative 'regular_columns/length'
|
9
|
+
require_relative 'regular_columns/notes'
|
10
|
+
require_relative 'regular_columns/history'
|
11
11
|
|
12
12
|
module Reading
|
13
13
|
module Parsing
|
@@ -20,12 +20,13 @@ module Reading
|
|
20
20
|
[Rating, Head, Sources, StartDates, EndDates, Genres, Length, Notes, History]
|
21
21
|
end
|
22
22
|
|
23
|
-
# Does not start with a comment character.
|
23
|
+
# Does not start with a comment character. Note: this must be called
|
24
|
+
# *after* calling ::match? on Rows::Blank, because that one catches
|
25
|
+
# blank lines.
|
24
26
|
# @param row_string [String]
|
25
|
-
# @param config [Hash]
|
26
27
|
# @return [Boolean]
|
27
|
-
def self.match?(row_string
|
28
|
-
!row_string.lstrip.start_with?(
|
28
|
+
def self.match?(row_string)
|
29
|
+
!row_string.lstrip.start_with?(Config.hash.fetch(:comment_character))
|
29
30
|
end
|
30
31
|
end
|
31
32
|
end
|
@@ -15,16 +15,16 @@ module Reading
|
|
15
15
|
)
|
16
16
|
(\s+|\z)
|
17
17
|
)
|
18
|
-
# each
|
18
|
+
# each and repetitions are used in conjunction with the History column
|
19
|
+
# each
|
19
20
|
(
|
20
|
-
# each
|
21
21
|
(?<each>each)
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
)
|
22
|
+
(\s+|\z)
|
23
|
+
)?
|
24
|
+
# repetitions
|
25
|
+
(
|
26
|
+
x
|
27
|
+
(?<repetitions>\d+)
|
28
28
|
)?
|
29
29
|
\z}x]
|
30
30
|
end
|
@@ -6,17 +6,12 @@ module Reading
|
|
6
6
|
# and https://github.com/fpsvogel/reading/blob/main/doc/csv-format.md#sources-column-variants
|
7
7
|
class Sources < Column
|
8
8
|
SOURCES_PARSING_ERRORS = {
|
9
|
-
"
|
10
|
-
->(source) {
|
11
|
-
source.match?(/\shttps?:\/\//) || source.scan(/https?:\/\//).count > 1
|
12
|
-
},
|
13
|
-
"The ISBN/ASIN must be placed after sources in the Sources column" =>
|
9
|
+
"The ISBN/ASIN must be placed last in the Sources column" =>
|
14
10
|
->(source) {
|
15
11
|
source.match?(/\A#{ISBN_REGEX}/o) || source.match(/\A#{ASIN_REGEX}/o)
|
16
12
|
},
|
17
13
|
}
|
18
14
|
|
19
|
-
|
20
15
|
def self.split_by_format?
|
21
16
|
true
|
22
17
|
end
|
@@ -32,7 +27,18 @@ module Reading
|
|
32
27
|
def self.tweaks
|
33
28
|
{
|
34
29
|
sources: -> {
|
35
|
-
|
30
|
+
comma = /\s*,\s*/
|
31
|
+
space_before_url = / (?=https?:\/\/)/
|
32
|
+
sources = _1.split(Regexp.union(comma, space_before_url))
|
33
|
+
|
34
|
+
# Split by space after URL.
|
35
|
+
sources = sources.flat_map { |src|
|
36
|
+
if src.match?(/\Ahttps?:\/\//)
|
37
|
+
src.split(" ", 2)
|
38
|
+
else
|
39
|
+
src
|
40
|
+
end
|
41
|
+
}
|
36
42
|
|
37
43
|
SOURCES_PARSING_ERRORS.each do |message, check|
|
38
44
|
if sources.any? { |source| check.call(source) }
|
@@ -58,7 +64,7 @@ module Reading
|
|
58
64
|
|
|
59
65
|
(?<length_time>\d+:\d\d)
|
60
66
|
)?
|
61
|
-
\z}x if
|
67
|
+
\z}x if segment_index.zero?),
|
62
68
|
# sources, ISBN/ASIN, length
|
63
69
|
(%r{\A
|
64
70
|
(
|
@@ -78,14 +84,14 @@ module Reading
|
|
78
84
|
|
|
79
85
|
(?<length_time>\d+:\d\d)
|
80
86
|
)?
|
81
|
-
\z}xo if
|
87
|
+
\z}xo if segment_index.zero?),
|
82
88
|
*Column::SHARED_REGEXES[:series_and_extra_info],
|
83
89
|
].compact
|
84
90
|
end
|
85
91
|
|
86
92
|
private
|
87
93
|
|
88
|
-
ISBN_REGEX = /(\d{3}[-\s]?)
|
94
|
+
ISBN_REGEX = /(\d{3}[-\s]?)?(\d{10}|\d{9}X)/
|
89
95
|
ASIN_REGEX = /B0[A-Z\d]{8}/
|
90
96
|
end
|
91
97
|
end
|
@@ -1,12 +1,12 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative
|
3
|
-
require_relative
|
4
|
-
require_relative
|
5
|
-
require_relative
|
6
|
-
require_relative
|
7
|
-
require_relative
|
8
|
-
require_relative
|
9
|
-
require_relative
|
1
|
+
require_relative 'attributes/shared'
|
2
|
+
require_relative 'attributes/attribute'
|
3
|
+
require_relative 'attributes/rating'
|
4
|
+
require_relative 'attributes/author'
|
5
|
+
require_relative 'attributes/title'
|
6
|
+
require_relative 'attributes/genres'
|
7
|
+
require_relative 'attributes/variants'
|
8
|
+
require_relative 'attributes/experiences'
|
9
|
+
require_relative 'attributes/notes'
|
10
10
|
|
11
11
|
module Reading
|
12
12
|
module Parsing
|
@@ -20,13 +20,9 @@ module Reading
|
|
20
20
|
using Util::HashArrayDeepFetch
|
21
21
|
using Util::HashCompactByTemplate
|
22
22
|
|
23
|
-
attr_reader :config
|
24
23
|
private attr_reader :attributes
|
25
24
|
|
26
|
-
|
27
|
-
def initialize(config)
|
28
|
-
@config = config
|
29
|
-
|
25
|
+
def initialize
|
30
26
|
set_attributes
|
31
27
|
end
|
32
28
|
|
@@ -40,7 +36,7 @@ module Reading
|
|
40
36
|
raise InvalidHeadError, "Blank or missing Head column"
|
41
37
|
end
|
42
38
|
|
43
|
-
template =
|
39
|
+
template = Config.hash.deep_fetch(:item, :template)
|
44
40
|
|
45
41
|
parsed_row[:head].map.with_index { |_head, head_index|
|
46
42
|
template.map { |attribute_name, default_value|
|
@@ -58,11 +54,11 @@ module Reading
|
|
58
54
|
# Sets the attributes classes which do all the transforming work.
|
59
55
|
# See parsing/attributes/*.
|
60
56
|
def set_attributes
|
61
|
-
@attributes ||=
|
57
|
+
@attributes ||= Config.hash.deep_fetch(:item, :template).map { |attribute_name, _default|
|
62
58
|
attribute_name_camelcase = attribute_name.to_s.split("_").map(&:capitalize).join
|
63
59
|
attribute_class = Attributes.const_get(attribute_name_camelcase)
|
64
60
|
|
65
|
-
[attribute_name, attribute_class.new
|
61
|
+
[attribute_name, attribute_class.new]
|
66
62
|
}.to_h
|
67
63
|
end
|
68
64
|
end
|