reading 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 70825fd8a882da595b06db4f8a0766962dd71b8149248ada3ed55280d68b2975
4
+ data.tar.gz: 124c660e9888c712a8bedc05156700b3e233300a8fe9806dd36ec168e910bfea
5
+ SHA512:
6
+ metadata.gz: 174a7a35761e5a26569651bee4e7ddefd0bbe8592b3828df34cb9f465d0a734876a57bb0fecb04683c470567a0df151289c375213cdf7ad67d632d8d06d02d10
7
+ data.tar.gz: 0cec333e90f2a701521d06c4924083e137268112c1a2749effb17b952b3028a4f4deae9ababd9ce1592eb0a21ba60afac75c0e18f81762af936306d86f2adb82
data/bin/reading ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # A script that provides a quick way to see the output of a CSV string.
4
+ #
5
+ # Usage:
6
+ # Run on the command line:
7
+ # reading "<CSV string>" "<optional comma-separated names of enabled columns>`
8
+ #
9
+ # Examples:
10
+ # reading '3|📕Trying|Lexpub 1970147288'
11
+ # reading '📕Trying|Lexpub 1970147288' 'head, sources'
12
+
13
+
14
+ require_relative "../lib/reading/csv"
15
+ require "amazing_print"
16
+
17
+ input = ARGV[0]
18
+ unless input
19
+ raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Lexpub 1970147288'"
20
+ end
21
+
22
+ config = {}
23
+ if ARGV[1]
24
+ enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
25
+ config[:csv] = { enabled_columns: }
26
+ end
27
+
28
+ csv = Reading::CSV.new(input, config:)
29
+ items = csv.parse
30
+
31
+ ap items
@@ -0,0 +1,83 @@
1
+ require_relative "attribute"
2
+ require_relative "variants/variants_attribute"
3
+ require_relative "experiences/experiences_attribute"
4
+
5
+ module Reading
6
+ class Row
7
+ using Util::StringRemove
8
+ using Util::HashArrayDeepFetch
9
+
10
+ # The simpler attributes are collected below. The more complex attributes
11
+ # are separated into their own files.
12
+
13
+ class RatingAttribute < Attribute
14
+ def parse
15
+ return nil unless columns[:rating]
16
+
17
+ rating = columns[:rating].strip
18
+ return nil if rating.empty?
19
+
20
+ Integer(rating, exception: false) ||
21
+ Float(rating, exception: false) ||
22
+ (raise InvalidRatingError, "Invalid rating")
23
+ end
24
+ end
25
+
26
+ class AuthorAttribute < Attribute
27
+ def parse
28
+ item_head
29
+ .remove(/\A#{config.deep_fetch(:csv, :regex, :formats)}/)
30
+ .match(/.+(?=#{config.deep_fetch(:csv, :short_separator)})/)
31
+ &.to_s
32
+ &.strip
33
+ end
34
+ end
35
+
36
+ class TitleAttribute < Attribute
37
+ def parse
38
+ if item_head.end_with?(config.deep_fetch(:csv, :short_separator).rstrip)
39
+ raise InvalidHeadError, "Missing title? Head column ends in a separator"
40
+ end
41
+
42
+ item_head
43
+ .remove(/\A#{config.deep_fetch(:csv, :regex, :formats)}/)
44
+ .remove(/.+#{config.deep_fetch(:csv, :short_separator)}/)
45
+ .remove(/#{config.deep_fetch(:csv, :long_separator)}.+\z/)
46
+ .strip
47
+ .presence || (raise InvalidHeadError, "Missing title")
48
+ end
49
+ end
50
+
51
+ class GenresAttribute < Attribute
52
+ def parse
53
+ return nil unless columns[:genres]
54
+
55
+ columns[:genres]
56
+ .split(config.deep_fetch(:csv, :separator))
57
+ .map(&:strip)
58
+ .map(&:downcase)
59
+ .map(&:presence)
60
+ .compact.presence
61
+ end
62
+ end
63
+
64
+ class NotesAttribute < Attribute
65
+ def parse
66
+ return nil unless columns[:public_notes]
67
+
68
+ columns[:public_notes]
69
+ .presence
70
+ &.chomp
71
+ &.remove(/#{config.deep_fetch(:csv, :long_separator).rstrip}\s*\z/)
72
+ &.split(config.deep_fetch(:csv, :long_separator))
73
+ &.map { |string|
74
+ {
75
+ blurb?: !!string.delete!(config.deep_fetch(:csv, :blurb_emoji)),
76
+ private?: !!string.delete!(config.deep_fetch(:csv, :private_emoji)),
77
+ content: string.strip,
78
+ }
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,25 @@
1
+ module Reading
2
+ class Row
3
+ # A base class that contains behaviors common to ___Attribute classes.
4
+ class Attribute
5
+ private attr_reader :item_head, :columns, :config
6
+
7
+ # @param item_head [String] see Row#item_heads for a definition.
8
+ # @param columns [Array<String>] the CSV row split into columns.
9
+ # @param config [Hash]
10
+ def initialize(item_head: nil, columns: nil, config:)
11
+ unless item_head || columns
12
+ raise ArgumentError, "Either item_head or columns must be given to an Attribute."
13
+ end
14
+
15
+ @item_head = item_head
16
+ @columns = columns
17
+ @config = config
18
+ end
19
+
20
+ def parse
21
+ raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,94 @@
1
+ module Reading
2
+ # Methods to validate dates. This does not cover all the ways dates can be
3
+ # invalid, just the ones not covered by ExperiencesAttribute during parsing.
4
+ module DatesValidator
5
+ using Util::HashArrayDeepFetch
6
+
7
+ class << self
8
+ # Checks the dates in the given experiences hash, and raises an error at
9
+ # the first invalid date found.
10
+ # @param experiences [Array<Hash>]
11
+ # @param config [Hash]
12
+ def validate(experiences, config)
13
+ validate_dates_started_are_in_order(experiences) if dates_started_column?(config)
14
+ validate_dates_finished_are_in_order(experiences) if dates_finished_column?(config)
15
+ validate_experiences_of_same_variant_do_not_overlap(experiences) if both_date_columns?(config)
16
+ validate_spans_are_in_order_and_not_overlapping(experiences)
17
+ end
18
+
19
+ private
20
+
21
+ def dates_started_column?(config)
22
+ config.deep_fetch(:csv, :enabled_columns).include?(:dates_started)
23
+ end
24
+
25
+ def dates_finished_column?(config)
26
+ config.deep_fetch(:csv, :enabled_columns).include?(:dates_finished)
27
+ end
28
+
29
+ def both_date_columns?(config)
30
+ dates_started_column?(config) && dates_finished_column?(config)
31
+ end
32
+
33
+ def validate_dates_started_are_in_order(experiences)
34
+ experiences
35
+ .filter { |exp| exp[:spans].any? }
36
+ .map { |exp| exp[:spans].first[:dates].begin }
37
+ .each_cons(2) do |a, b|
38
+ if (a.nil? && b.nil?) || (a && b && a > b )
39
+ raise InvalidDateError, "Dates started are not in order"
40
+ end
41
+ end
42
+ end
43
+
44
+ def validate_dates_finished_are_in_order(experiences)
45
+ experiences
46
+ .filter { |exp| exp[:spans].any? }
47
+ .map { |exp| exp[:spans].last[:dates].end }
48
+ .each_cons(2) do |a, b|
49
+ if (a.nil? && b.nil?) || (a && b && a > b )
50
+ raise InvalidDateError, "Dates finished are not in order"
51
+ end
52
+ end
53
+ end
54
+
55
+ def validate_experiences_of_same_variant_do_not_overlap(experiences)
56
+ experiences
57
+ .group_by { |exp| exp[:variant_index] }
58
+ .each do |_variant_index, exps|
59
+ exps.filter { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
60
+ a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
61
+ b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
62
+ if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
63
+ b_metaspan.cover?(a_metaspan.begin || b_metaspan.begin || b_metaspan.end)
64
+ raise InvalidDateError, "Experiences are overlapping"
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def validate_spans_are_in_order_and_not_overlapping(experiences)
71
+ experiences
72
+ .filter { |exp| exp[:spans].any? }
73
+ .each do |exp|
74
+ exp[:spans]
75
+ .map { |span| span[:dates] }
76
+ .each do |dates|
77
+ if dates.begin && dates.end && dates.begin > dates.end
78
+ raise InvalidDateError, "A date range is backward"
79
+ end
80
+ end
81
+ .each_cons(2) do |a, b|
82
+ if a.begin > b.begin || a.end > b.end
83
+ raise InvalidDateError, "Dates are not in order"
84
+ end
85
+ if a.cover?(b.begin || a.begin || a.end) ||
86
+ b.cover?(a.begin || b.begin || b.end)
87
+ raise InvalidDateError, "Dates are overlapping"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "spans_subattribute"
2
+ require_relative "progress_subattribute"
3
+ require_relative "dates_validator"
4
+ require "date"
5
+
6
+ module Reading
7
+ class Row
8
+ class ExperiencesAttribute < Attribute
9
+ using Util::HashArrayDeepFetch
10
+ using Util::HashDeepMerge
11
+
12
+ def parse
13
+ started, finished = dates_split(columns)
14
+
15
+ experiences_with_dates = started.map.with_index { |entry, i|
16
+ variant_index = variant_index(entry)
17
+ spans_attr = SpansSubattribute.new(date_entry: entry, dates_finished: finished, date_index: i, variant_index:, columns:, config:)
18
+
19
+ {
20
+ spans: spans_attr.parse || template.fetch(:spans),
21
+ group: group(entry) || template.fetch(:group),
22
+ variant_index: variant_index || template.fetch(:variant_index)
23
+ }
24
+ }.presence
25
+
26
+ if experiences_with_dates
27
+ # Raises an error if any sequence of dates does not make sense.
28
+ DatesValidator.validate(experiences_with_dates, config)
29
+
30
+ return experiences_with_dates
31
+ else
32
+ if prog = ProgressSubattribute.new(columns:, config:).parse_head
33
+ return [template.deep_merge(spans: [{ progress: prog }] )]
34
+ else
35
+ return nil
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def template
43
+ @template ||= config.deep_fetch(:item, :template, :experiences).first
44
+ end
45
+
46
+ def dates_split(columns)
47
+ dates_finished = columns[:dates_finished]&.presence
48
+ &.split(config.deep_fetch(:csv, :separator))&.map(&:strip) || []
49
+ # Don't use #has_key? because simply checking for nil covers the
50
+ # case where dates_started is the last column and omitted.
51
+ started_column_exists = columns[:dates_started]&.presence
52
+
53
+ dates_started =
54
+ if started_column_exists
55
+ columns[:dates_started]&.presence&.split(config.deep_fetch(:csv, :separator))&.map(&:strip)
56
+ else
57
+ [""] * dates_finished.count
58
+ end
59
+
60
+ [dates_started, dates_finished]
61
+ end
62
+
63
+ def group(entry)
64
+ entry.match(config.deep_fetch(:csv, :regex, :group_experience))&.captures&.first
65
+ end
66
+
67
+ def variant_index(date_entry)
68
+ match = date_entry.match(config.deep_fetch(:csv, :regex, :variant_index))
69
+
70
+ (match&.captures&.first&.to_i || 1) - 1
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ module Reading
2
+ class Row
3
+ class ProgressSubattribute
4
+ using Util::HashArrayDeepFetch
5
+
6
+ private attr_reader :date_entry, :variant_index, :columns, :config
7
+
8
+ # @param date_entry [String] the entry in Dates Started.
9
+ # @param variant_index [Integer] the variant index, for getting length for default amount.
10
+ # @param columns [Array<String>]
11
+ # @param config [Hash]
12
+ def initialize(date_entry: nil, variant_index: nil, columns:, config:)
13
+ @date_entry = date_entry
14
+ @variant_index = variant_index
15
+ @columns = columns
16
+ @config = config
17
+ end
18
+
19
+ def parse
20
+ progress(date_entry) || progress(columns[:head])
21
+ end
22
+
23
+ def parse_head
24
+ progress(columns[:head])
25
+ end
26
+
27
+ private
28
+
29
+ def progress(str)
30
+ prog = str.match(config.deep_fetch(:csv, :regex, :progress))
31
+
32
+ if prog
33
+ if prog_percent = prog[:percent]&.to_i
34
+ return prog_percent / 100.0
35
+ elsif prog_time = prog[:time]
36
+ return prog_time
37
+ elsif prog_pages = prog[:pages]&.to_i
38
+ return prog_pages
39
+ end
40
+ end
41
+
42
+ dnf = str.match(config.deep_fetch(:csv, :regex, :dnf))&.captures&.first
43
+ return 0 if dnf
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,82 @@
1
+ module Reading
2
+ class Row
3
+ class SpansSubattribute
4
+ using Util::HashArrayDeepFetch
5
+
6
+ private attr_reader :date_entry, :dates_finished, :date_index, :variant_index, :columns, :config
7
+
8
+ # @param date_entry [String] the entry in Dates Started.
9
+ # @param dates_finished [Array<String>] the entries in Dates Finished.
10
+ # @param date_index [Integer] the index of the entry.
11
+ # @param variant_index [Integer] the variant index, for getting length for default amount.
12
+ # @param columns [Array<String>]
13
+ # @param config [Hash]
14
+ def initialize(date_entry:, dates_finished:, date_index:, variant_index:, columns:, config:)
15
+ @date_entry = date_entry
16
+ @dates_finished = dates_finished
17
+ @date_index = date_index
18
+ @variant_index = variant_index
19
+ @columns = columns
20
+ @config = config
21
+ end
22
+
23
+ def parse
24
+ started = date_started(date_entry)
25
+ finished = date_finished(dates_finished, date_index)
26
+ return [] if started.nil? && finished.nil?
27
+
28
+ progress_attr = ProgressSubattribute.new(date_entry:, variant_index:, columns:, config:)
29
+ progress = progress_attr.parse
30
+
31
+ [{
32
+ dates: started..finished || template.fetch(:dates),
33
+ amount: length || template.fetch(:amount),
34
+ progress: progress || (1.0 if finished) || template.fetch(:progress),
35
+ name: template.fetch(:name),
36
+ favorite?: template.fetch(:favorite?),
37
+ }]
38
+ end
39
+
40
+ private
41
+
42
+ def template
43
+ @template ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
44
+ end
45
+
46
+ def date_started(date_entry)
47
+ dates = date_entry.scan(config.deep_fetch(:csv, :regex, :date))
48
+ raise InvalidDateError, "Conjoined dates" if dates.count > 1
49
+ raise InvalidDateError, "Missing or incomplete date" if date_entry.present? && dates.empty?
50
+
51
+ date_str = dates.first
52
+ Date.parse(date_str) if date_str
53
+ rescue Date::Error
54
+ raise InvalidDateError, "Unparsable date"
55
+ end
56
+
57
+ def date_finished(dates_finished, date_index)
58
+ return nil if dates_finished.nil?
59
+
60
+ date_str = dates_finished[date_index]&.presence
61
+ Date.parse(date_str) if date_str
62
+ rescue Date::Error
63
+ if date_str.match?(config.deep_fetch(:csv, :regex, :date))
64
+ raise InvalidDateError, "Unparsable date"
65
+ else
66
+ raise InvalidDateError, "Missing or incomplete date"
67
+ end
68
+ end
69
+
70
+ def length
71
+ sources_str = columns[:sources]&.presence || " "
72
+ bare_variant = sources_str
73
+ .split(config.deep_fetch(:csv, :regex, :formats_split))
74
+ .dig(variant_index)
75
+ &.split(config.deep_fetch(:csv, :long_separator))
76
+ &.first
77
+ length_attr = LengthSubattribute.new(bare_variant:, columns:, config:)
78
+ length_attr.parse
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,44 @@
1
+ module Reading
2
+ class Row
3
+ class ExtraInfoSubattribute
4
+ using Util::HashArrayDeepFetch
5
+
6
+ private attr_reader :item_head, :variant_with_extras, :config
7
+
8
+ # @param item_head [String] see Row#item_heads for a definition.
9
+ # @param variant_with_extras [String] the full variant string.
10
+ # @param config [Hash]
11
+ def initialize(item_head:, variant_with_extras: nil, config:)
12
+ @item_head = item_head
13
+ @variant_with_extras = variant_with_extras
14
+ @config = config
15
+ end
16
+
17
+ def parse
18
+ (
19
+ Array(extra_info(item_head)) +
20
+ Array(extra_info(variant_with_extras))
21
+ ).presence
22
+ end
23
+
24
+ def parse_head
25
+ extra_info(item_head)
26
+ end
27
+
28
+ private
29
+
30
+ def template
31
+ config.deep_fetch(:item, :template, :variants, 0, :series).first
32
+ end
33
+
34
+ def extra_info(str)
35
+ separated = str.split(config.deep_fetch(:csv, :long_separator))
36
+ separated.delete_at(0) # everything before the extra info
37
+ separated.reject { |str|
38
+ str.start_with?("#{config.deep_fetch(:csv, :series_prefix)} ") ||
39
+ str.match(config.deep_fetch(:csv, :regex, :series_volume))
40
+ }.presence
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ module Reading
2
+ class Row
3
+ class LengthSubattribute
4
+ using Util::HashArrayDeepFetch
5
+
6
+ private attr_reader :item_head, :bare_variant, :columns, :config
7
+
8
+ # @param bare_variant [String] the variant string before series / extra info.
9
+ # @param columns [Array<String>]
10
+ # @param config [Hash]
11
+ def initialize(bare_variant:, columns:, config:)
12
+ @bare_variant = bare_variant
13
+ @columns = columns
14
+ @config = config
15
+ end
16
+
17
+ def parse
18
+ in_variant = length_in(
19
+ bare_variant,
20
+ time_regex: config.deep_fetch(:csv, :regex, :time_length_in_variant),
21
+ pages_regex: config.deep_fetch(:csv, :regex, :pages_length_in_variant),
22
+ )
23
+ in_length = length_in(
24
+ columns[:length],
25
+ time_regex: config.deep_fetch(:csv, :regex, :time_length),
26
+ pages_regex: config.deep_fetch(:csv, :regex, :pages_length),
27
+ )
28
+
29
+ in_variant || in_length ||
30
+ (raise InvalidLengthError, "Missing length" unless columns[:length].blank?)
31
+ end
32
+
33
+ private
34
+
35
+ def length_in(str, time_regex:, pages_regex:)
36
+ return nil if str.blank?
37
+
38
+ time_length = str.strip.match(time_regex)&.captures&.first
39
+ return time_length unless time_length.nil?
40
+
41
+ str.strip.match(pages_regex)&.captures&.first&.to_i
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ module Reading
2
+ class Row
3
+ class SeriesSubattribute
4
+ using Util::HashArrayDeepFetch
5
+
6
+ private attr_reader :item_head, :variant_with_extras, :config
7
+
8
+ # @param item_head [String] see Row#item_heads for a definition.
9
+ # @param variant_with_extras [String] the full variant string.
10
+ # @param config [Hash]
11
+ def initialize(item_head:, variant_with_extras: nil, config:)
12
+ @item_head = item_head
13
+ @variant_with_extras = variant_with_extras
14
+ @config = config
15
+ end
16
+
17
+ def parse
18
+ (
19
+ Array(series(item_head)) +
20
+ Array(series(variant_with_extras))
21
+ ).presence
22
+ end
23
+
24
+ def parse_head
25
+ series(item_head)
26
+ end
27
+
28
+ private
29
+
30
+ def template
31
+ config.deep_fetch(:item, :template, :variants, 0, :series).first
32
+ end
33
+
34
+ def series(str)
35
+ separated = str
36
+ .split(config.deep_fetch(:csv, :long_separator))
37
+ .map(&:strip)
38
+ .map(&:presence)
39
+ .compact
40
+
41
+ separated.delete_at(0) # everything before the series/extra info
42
+
43
+ separated.map { |str|
44
+ volume = str.match(config.deep_fetch(:csv, :regex, :series_volume))
45
+ prefix = "#{config.deep_fetch(:csv, :series_prefix)} "
46
+
47
+ if volume || str.start_with?(prefix)
48
+ {
49
+ name: str.delete_suffix(volume.to_s).delete_prefix(prefix) || template[:name],
50
+ volume: volume&.captures&.first&.to_i || template[:volume],
51
+ }
52
+ end
53
+ }.compact.presence
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,78 @@
1
+ module Reading
2
+ class Row
3
+ class SourcesSubattribute
4
+ using Util::StringRemove
5
+ using Util::HashArrayDeepFetch
6
+
7
+ private attr_reader :item_head, :bare_variant, :config
8
+
9
+ # @param bare_variant [String] the variant string before series / extra info.
10
+ # @param config [Hash]
11
+ def initialize(bare_variant:, config:)
12
+ @bare_variant = bare_variant
13
+ @config = config
14
+ end
15
+
16
+ def parse
17
+ urls = sources_urls(bare_variant).map { |url|
18
+ {
19
+ name: url_name(url) || template.deep_fetch(:sources, 0, :name),
20
+ url: url,
21
+ }
22
+ }
23
+
24
+ names = sources_names(bare_variant).map { |name|
25
+ {
26
+ name: name,
27
+ url: template.deep_fetch(:sources, 0, :url),
28
+ }
29
+ }
30
+
31
+ (urls + names).presence
32
+ end
33
+
34
+ private
35
+
36
+ def template
37
+ @template ||= config.deep_fetch(:item, :template, :variants).first
38
+ end
39
+
40
+ def sources_urls(str)
41
+ str.scan(config.deep_fetch(:csv, :regex, :url))
42
+ end
43
+
44
+ # Turns everything that is not a source name (ISBN, source URL, length) into
45
+ # a separator, then splits by that separator and removes empty elements
46
+ # and format emojis. What's left is source names.
47
+ def sources_names(str)
48
+ not_names = [:isbn, :url, :time_length_in_variant, :pages_length_in_variant]
49
+ names_and_separators = str
50
+
51
+ not_names.each do |regex_type|
52
+ names_and_separators = names_and_separators.gsub(
53
+ config.deep_fetch(:csv, :regex, regex_type),
54
+ config.deep_fetch(:csv, :separator),
55
+ )
56
+ end
57
+
58
+ names_and_separators
59
+ .split(config.deep_fetch(:csv, :separator))
60
+ .map { |name| name.remove(/\A\s*#{config.deep_fetch(:csv, :regex, :formats)}\s*/) }
61
+ .map(&:strip)
62
+ .reject(&:empty?)
63
+ end
64
+
65
+ def url_name(url)
66
+ config
67
+ .deep_fetch(:item, :sources, :names_from_urls)
68
+ .each do |url_part, name|
69
+ if url.include?(url_part)
70
+ return name
71
+ end
72
+ end
73
+
74
+ config.deep_fetch(:item, :sources, :default_name_for_url)
75
+ end
76
+ end
77
+ end
78
+ end