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 +7 -0
- data/bin/reading +31 -0
- data/lib/reading/attribute/all_attributes.rb +83 -0
- data/lib/reading/attribute/attribute.rb +25 -0
- data/lib/reading/attribute/experiences/dates_validator.rb +94 -0
- data/lib/reading/attribute/experiences/experiences_attribute.rb +74 -0
- data/lib/reading/attribute/experiences/progress_subattribute.rb +48 -0
- data/lib/reading/attribute/experiences/spans_subattribute.rb +82 -0
- data/lib/reading/attribute/variants/extra_info_subattribute.rb +44 -0
- data/lib/reading/attribute/variants/length_subattribute.rb +45 -0
- data/lib/reading/attribute/variants/series_subattribute.rb +57 -0
- data/lib/reading/attribute/variants/sources_subattribute.rb +78 -0
- data/lib/reading/attribute/variants/variants_attribute.rb +69 -0
- data/lib/reading/config.rb +202 -0
- data/lib/reading/csv.rb +67 -0
- data/lib/reading/errors.rb +77 -0
- data/lib/reading/line.rb +23 -0
- data/lib/reading/row/blank_row.rb +23 -0
- data/lib/reading/row/compact_planned_row.rb +130 -0
- data/lib/reading/row/regular_row.rb +94 -0
- data/lib/reading/row/row.rb +88 -0
- data/lib/reading/util/blank.rb +146 -0
- data/lib/reading/util/hash_array_deep_fetch.rb +40 -0
- data/lib/reading/util/hash_compact_by_template.rb +38 -0
- data/lib/reading/util/hash_deep_merge.rb +44 -0
- data/lib/reading/util/hash_to_struct.rb +29 -0
- data/lib/reading/util/string_remove.rb +28 -0
- data/lib/reading/util/string_truncate.rb +13 -0
- data/lib/reading/version.rb +3 -0
- metadata +174 -0
    
        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
         |