reading 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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