reading 0.8.0 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bin/reading +80 -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 +321 -54
- data/lib/reading/parsing/attributes/attribute.rb +0 -7
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
- data/lib/reading/parsing/attributes/experiences.rb +5 -5
- data/lib/reading/parsing/attributes/shared.rb +13 -6
- 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 +15 -9
- data/lib/reading/parsing/transformer.rb +13 -17
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +243 -0
- data/lib/reading/stats/operation.rb +313 -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_to_data.rb +2 -2
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +36 -21
- metadata +10 -6
- data/bin/readingfile +0 -31
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
@@ -11,24 +11,23 @@ module Reading
|
|
11
11
|
# Checks the dates in the given experiences hash, and raises an error
|
12
12
|
# at the first invalid date found.
|
13
13
|
# @param experiences [Array<Hash>] experience hashes.
|
14
|
-
# @param config [Hash] an entire config.
|
15
14
|
# @param history_column [Boolean] whether this validation is for
|
16
15
|
# experiences from the History column.
|
17
16
|
# @raise [InvalidDateError] if any date is invalid.
|
18
|
-
def validate(experiences,
|
19
|
-
if both_date_columns?
|
17
|
+
def validate(experiences, history_column: false)
|
18
|
+
if both_date_columns?
|
20
19
|
validate_number_of_start_dates_and_end_dates(experiences)
|
21
20
|
end
|
22
21
|
|
23
|
-
if start_dates_column?
|
22
|
+
if start_dates_column? || history_column
|
24
23
|
validate_start_dates_are_in_order(experiences)
|
25
24
|
end
|
26
25
|
|
27
|
-
if end_dates_column?
|
26
|
+
if end_dates_column? || history_column
|
28
27
|
validate_end_dates_are_in_order(experiences)
|
29
28
|
end
|
30
29
|
|
31
|
-
if both_date_columns?
|
30
|
+
if both_date_columns? || history_column
|
32
31
|
validate_experiences_of_same_variant_do_not_overlap(experiences)
|
33
32
|
end
|
34
33
|
|
@@ -39,20 +38,20 @@ module Reading
|
|
39
38
|
|
40
39
|
# Whether the Start Dates column is enabled.
|
41
40
|
# @return [Boolean]
|
42
|
-
def start_dates_column?
|
43
|
-
|
41
|
+
def start_dates_column?
|
42
|
+
Config.hash.fetch(:enabled_columns).include?(:start_dates)
|
44
43
|
end
|
45
44
|
|
46
45
|
# Whether the End Dates column is enabled.
|
47
46
|
# @return [Boolean]
|
48
|
-
def end_dates_column?
|
49
|
-
|
47
|
+
def end_dates_column?
|
48
|
+
Config.hash.fetch(:enabled_columns).include?(:end_dates)
|
50
49
|
end
|
51
50
|
|
52
51
|
# Whether both the Start Dates and End Dates columns are enabled.
|
53
52
|
# @return [Boolean]
|
54
|
-
def both_date_columns?
|
55
|
-
start_dates_column?
|
53
|
+
def both_date_columns?
|
54
|
+
start_dates_column? && end_dates_column?
|
56
55
|
end
|
57
56
|
|
58
57
|
# Raises an error if there are more end dates than start dates, or
|
@@ -60,7 +59,7 @@ module Reading
|
|
60
59
|
# @raise [InvalidDateError]
|
61
60
|
def validate_number_of_start_dates_and_end_dates(experiences)
|
62
61
|
_both_dates, not_both_dates = experiences
|
63
|
-
.
|
62
|
+
.select { |exp| exp[:spans].first&.dig(:dates) }
|
64
63
|
.map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
|
65
64
|
.partition { |start_date, end_date| start_date && end_date }
|
66
65
|
|
@@ -76,7 +75,7 @@ module Reading
|
|
76
75
|
# @raise [InvalidDateError]
|
77
76
|
def validate_start_dates_are_in_order(experiences)
|
78
77
|
experiences
|
79
|
-
.
|
78
|
+
.select { |exp| exp[:spans].first&.dig(:dates) }
|
80
79
|
.map { |exp| exp[:spans].first[:dates].begin }
|
81
80
|
.each_cons(2) do |a, b|
|
82
81
|
if (a.nil? && b.nil?) || (a && b && a > b )
|
@@ -89,8 +88,8 @@ module Reading
|
|
89
88
|
# @raise [InvalidDateError]
|
90
89
|
def validate_end_dates_are_in_order(experiences)
|
91
90
|
experiences
|
92
|
-
.
|
93
|
-
.map { |exp| exp[:spans].last[:dates]
|
91
|
+
.select { |exp| exp[:spans].first&.dig(:dates) }
|
92
|
+
.map { |exp| exp[:spans].last[:dates]&.end }
|
94
93
|
.each_cons(2) do |a, b|
|
95
94
|
if (a.nil? && b.nil?) || (a && b && a > b )
|
96
95
|
raise InvalidDateError, "End dates are not in order"
|
@@ -104,7 +103,7 @@ module Reading
|
|
104
103
|
experiences
|
105
104
|
.group_by { |exp| exp[:variant_index] }
|
106
105
|
.each do |_variant_index, exps|
|
107
|
-
exps.
|
106
|
+
exps.select { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
|
108
107
|
a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
|
109
108
|
b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
|
110
109
|
if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
|
@@ -116,11 +115,11 @@ module Reading
|
|
116
115
|
end
|
117
116
|
|
118
117
|
# Raises an error if the spans within an experience are out of order
|
119
|
-
# or if the spans overlap.
|
118
|
+
# or if the spans overlap. Spans with nil dates are not considered.
|
120
119
|
# @raise [InvalidDateError]
|
121
120
|
def validate_spans_are_in_order_and_not_overlapping(experiences)
|
122
121
|
experiences
|
123
|
-
.
|
122
|
+
.select { |exp| exp[:spans].first&.dig(:dates) }
|
124
123
|
.each do |exp|
|
125
124
|
exp[:spans]
|
126
125
|
.map { |span| span[:dates] }
|
@@ -1,6 +1,6 @@
|
|
1
|
-
require
|
2
|
-
require_relative
|
3
|
-
require_relative
|
1
|
+
require 'date'
|
2
|
+
require_relative 'experiences/history_transformer'
|
3
|
+
require_relative 'experiences/dates_and_head_transformer'
|
4
4
|
|
5
5
|
module Reading
|
6
6
|
module Parsing
|
@@ -16,10 +16,10 @@ module Reading
|
|
16
16
|
# Config#default_config[:item][:template][:experiences]
|
17
17
|
def transform_from_parsed(parsed_row, head_index)
|
18
18
|
if !parsed_row[:history].blank?
|
19
|
-
return HistoryTransformer.new(parsed_row,
|
19
|
+
return HistoryTransformer.new(parsed_row, head_index).transform
|
20
20
|
end
|
21
21
|
|
22
|
-
DatesAndHeadTransformer.new(parsed_row, head_index
|
22
|
+
DatesAndHeadTransformer.new(parsed_row, head_index).transform
|
23
23
|
end
|
24
24
|
end
|
25
25
|
end
|
@@ -1,8 +1,11 @@
|
|
1
1
|
module Reading
|
2
2
|
module Parsing
|
3
3
|
module Attributes
|
4
|
-
#
|
4
|
+
# Sub-attributes that are shared across multiple attributes.
|
5
5
|
module Shared
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
using Util::NumericToIIfWhole
|
8
|
+
|
6
9
|
# Extracts the :progress sub-attribute (percent, pages, or time) from
|
7
10
|
# the given hash.
|
8
11
|
# @param hash [Hash] any parsed hash that contains progress.
|
@@ -10,7 +13,7 @@ module Reading
|
|
10
13
|
def self.progress(hash)
|
11
14
|
hash[:progress_percent]&.to_f&./(100) ||
|
12
15
|
hash[:progress_pages]&.to_i ||
|
13
|
-
hash[:progress_time]&.then { Item::TimeLength.parse
|
16
|
+
hash[:progress_time]&.then { Item::TimeLength.parse(_1) } ||
|
14
17
|
(0 if hash[:progress_dnf]) ||
|
15
18
|
(1.0 if hash[:progress_done]) ||
|
16
19
|
nil
|
@@ -18,6 +21,8 @@ module Reading
|
|
18
21
|
|
19
22
|
# Extracts the :length sub-attribute (pages or time) from the given hash.
|
20
23
|
# @param hash [Hash] any parsed hash that contains length.
|
24
|
+
# @param format [Symbol] the item format, which affects length in cases
|
25
|
+
# where Config.hash[:speed][:format] is customized.
|
21
26
|
# @param key_name [Symbol] the first part of the keys to be checked.
|
22
27
|
# @param episodic [Boolean] whether to look for episodic (not total) length.
|
23
28
|
# If false, returns nil if hash contains :each. If true, returns a
|
@@ -30,15 +35,15 @@ module Reading
|
|
30
35
|
# This is useful for the History column, where that 1 hour can be used
|
31
36
|
# as the default amount.
|
32
37
|
# @return [Float, Integer, Item::TimeLength]
|
33
|
-
def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
|
38
|
+
def self.length(hash, format:, key_name: :length, episodic: false, ignore_repetitions: false)
|
34
39
|
return nil unless hash
|
35
40
|
|
36
41
|
length = hash[:"#{key_name}_pages"]&.to_i ||
|
37
|
-
hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse
|
42
|
+
hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse(_1) }
|
38
43
|
|
39
44
|
return nil unless length
|
40
45
|
|
41
|
-
if hash[:each]
|
46
|
+
if hash[:each] && !hash[:repetitions]
|
42
47
|
# Length is calculated based on History column in this case.
|
43
48
|
if episodic
|
44
49
|
return length
|
@@ -54,7 +59,9 @@ module Reading
|
|
54
59
|
return nil if episodic && !hash[:each]
|
55
60
|
end
|
56
61
|
|
57
|
-
|
62
|
+
speed = Config.hash.deep_fetch(:speed, :format)[format] || 1.0
|
63
|
+
|
64
|
+
(length / speed).to_i_if_whole
|
58
65
|
end
|
59
66
|
end
|
60
67
|
end
|
@@ -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
|