reading 0.6.0 → 0.7.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +8 -8
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +115 -148
  5. data/lib/reading/errors.rb +11 -64
  6. data/lib/reading/item/time_length.rb +138 -0
  7. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  8. data/lib/reading/parsing/attributes/author.rb +15 -0
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  12. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  13. data/lib/reading/parsing/attributes/genres.rb +16 -0
  14. data/lib/reading/parsing/attributes/notes.rb +22 -0
  15. data/lib/reading/parsing/attributes/rating.rb +17 -0
  16. data/lib/reading/parsing/attributes/shared.rb +62 -0
  17. data/lib/reading/parsing/attributes/title.rb +21 -0
  18. data/lib/reading/parsing/attributes/variants.rb +77 -0
  19. data/lib/reading/parsing/csv.rb +101 -0
  20. data/lib/reading/parsing/parser.rb +292 -0
  21. data/lib/reading/parsing/rows/column.rb +131 -0
  22. data/lib/reading/parsing/rows/comment.rb +26 -0
  23. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  24. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  25. data/lib/reading/parsing/rows/regular.rb +33 -0
  26. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  27. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  28. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  29. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  30. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  31. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  32. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  33. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  34. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  35. data/lib/reading/parsing/transformer.rb +70 -0
  36. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  37. data/lib/reading/util/hash_deep_merge.rb +1 -1
  38. data/lib/reading/util/hash_to_struct.rb +1 -0
  39. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  40. data/lib/reading/util/string_truncate.rb +13 -4
  41. data/lib/reading/version.rb +1 -1
  42. data/lib/reading.rb +18 -0
  43. metadata +58 -41
  44. data/lib/reading/attribute/all_attributes.rb +0 -83
  45. data/lib/reading/attribute/attribute.rb +0 -25
  46. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  47. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  48. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  49. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  50. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  51. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  52. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  53. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  54. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  55. data/lib/reading/csv.rb +0 -67
  56. data/lib/reading/line.rb +0 -23
  57. data/lib/reading/row/blank_row.rb +0 -23
  58. data/lib/reading/row/compact_planned_row.rb +0 -130
  59. data/lib/reading/row/regular_row.rb +0 -94
  60. data/lib/reading/row/row.rb +0 -88
@@ -0,0 +1,149 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ class Experiences < Attribute
5
+ # Methods to validate dates in spans. This does not cover all the ways
6
+ # dates can be invalid, just the ones not caught during parsing.
7
+ module SpansValidator
8
+ using Util::HashArrayDeepFetch
9
+
10
+ class << self
11
+ # Checks the dates in the given experiences hash, and raises an error
12
+ # at the first invalid date found.
13
+ # @param experiences [Array<Hash>] experience hashes.
14
+ # @param config [Hash] an entire config.
15
+ # @param history_column [Boolean] whether this validation is for
16
+ # experiences from the History column.
17
+ # @raise [InvalidDateError] if any date is invalid.
18
+ def validate(experiences, config, history_column: false)
19
+ if both_date_columns?(config)
20
+ validate_number_of_start_dates_and_end_dates(experiences)
21
+ end
22
+
23
+ if start_dates_column?(config) || history_column
24
+ validate_start_dates_are_in_order(experiences)
25
+ end
26
+
27
+ if end_dates_column?(config) || history_column
28
+ validate_end_dates_are_in_order(experiences)
29
+ end
30
+
31
+ if both_date_columns?(config) || history_column
32
+ validate_experiences_of_same_variant_do_not_overlap(experiences)
33
+ end
34
+
35
+ validate_spans_are_in_order_and_not_overlapping(experiences)
36
+ end
37
+
38
+ private
39
+
40
+ # Whether the Start Dates column is enabled.
41
+ # @return [Boolean]
42
+ def start_dates_column?(config)
43
+ config.fetch(:enabled_columns).include?(:start_dates)
44
+ end
45
+
46
+ # Whether the End Dates column is enabled.
47
+ # @return [Boolean]
48
+ def end_dates_column?(config)
49
+ config.fetch(:enabled_columns).include?(:end_dates)
50
+ end
51
+
52
+ # Whether both the Start Dates and End Dates columns are enabled.
53
+ # @return [Boolean]
54
+ def both_date_columns?(config)
55
+ start_dates_column?(config) && end_dates_column?(config)
56
+ end
57
+
58
+ # Raises an error if there are more end dates than start dates, or
59
+ # if there is more than one more start date than end dates.
60
+ # @raise [InvalidDateError]
61
+ def validate_number_of_start_dates_and_end_dates(experiences)
62
+ both_dates, not_both_dates = experiences
63
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
64
+ .map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
65
+ .partition { |start_date, end_date| start_date && end_date }
66
+
67
+ all_dates_paired = not_both_dates.empty?
68
+ last_date_started_present = not_both_dates.count == 1 && not_both_dates.first
69
+
70
+ unless all_dates_paired || last_date_started_present
71
+ raise InvalidDateError, "Start dates or end dates are missing"
72
+ end
73
+ end
74
+
75
+ # Raises an error if the spans' first start dates are not in order.
76
+ # @raise [InvalidDateError]
77
+ def validate_start_dates_are_in_order(experiences)
78
+ experiences
79
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
80
+ .map { |exp| exp[:spans].first[:dates].begin }
81
+ .each_cons(2) do |a, b|
82
+ if (a.nil? && b.nil?) || (a && b && a > b )
83
+ raise InvalidDateError, "Start dates are not in order"
84
+ end
85
+ end
86
+ end
87
+
88
+ # Raises an error if the spans' last end dates are not in order.
89
+ # @raise [InvalidDateError]
90
+ def validate_end_dates_are_in_order(experiences)
91
+ experiences
92
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
93
+ .map { |exp| exp[:spans].last[:dates].end }
94
+ .each_cons(2) do |a, b|
95
+ if (a.nil? && b.nil?) || (a && b && a > b )
96
+ raise InvalidDateError, "End dates are not in order"
97
+ end
98
+ end
99
+ end
100
+
101
+ # Raises an error if two experiences of the same variant overlap.
102
+ # @raise [InvalidDateError]
103
+ def validate_experiences_of_same_variant_do_not_overlap(experiences)
104
+ experiences
105
+ .group_by { |exp| exp[:variant_index] }
106
+ .each do |_variant_index, exps|
107
+ exps.filter { |exp| exp[:spans].any? }.each_cons(2) do |a, b|
108
+ a_metaspan = a[:spans].first[:dates].begin..a[:spans].last[:dates].end
109
+ b_metaspan = b[:spans].first[:dates].begin..b[:spans].last[:dates].end
110
+ if a_metaspan.cover?(b_metaspan.begin || a_metaspan.begin || a_metaspan.end) ||
111
+ b_metaspan.cover?(a_metaspan.begin || b_metaspan.begin || b_metaspan.end)
112
+ raise InvalidDateError, "Experiences are overlapping"
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Raises an error if the spans within an experience are out of order
119
+ # or if the spans overlap.
120
+ # @raise [InvalidDateError]
121
+ def validate_spans_are_in_order_and_not_overlapping(experiences)
122
+ experiences
123
+ .filter { |exp| exp[:spans].first&.dig(:dates) }
124
+ .each do |exp|
125
+ exp[:spans]
126
+ .map { |span| span[:dates] }
127
+ # Exclude nil dates (planned entries in History).
128
+ .reject { |dates| dates.nil? }
129
+ .each do |dates|
130
+ if dates.begin && dates.end && dates.begin > dates.end
131
+ raise InvalidDateError, "A date range is backward"
132
+ end
133
+ end
134
+ .each_cons(2) do |a, b|
135
+ if a.begin > b.begin || a.end > b.end
136
+ raise InvalidDateError, "Dates are not in order"
137
+ end
138
+ if a.cover?(b.begin + 1)
139
+ raise InvalidDateError, "Dates are overlapping"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,27 @@
1
+ require "date"
2
+ require_relative "experiences/history_transformer"
3
+ require_relative "experiences/dates_and_head_transformer"
4
+
5
+ module Reading
6
+ module Parsing
7
+ module Attributes
8
+ # Transformer for the :experiences item attribute.
9
+ class Experiences < Attribute
10
+ using Util::HashArrayDeepFetch
11
+ using Util::HashDeepMerge
12
+
13
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
14
+ # @param head_index [Integer] current item's position in the Head column.
15
+ # @return [Array<Hash>] an array of experiences; see
16
+ # Config#default_config[:item_template][:experiences]
17
+ def transform_from_parsed(parsed_row, head_index)
18
+ if !parsed_row[:history].blank?
19
+ return HistoryTransformer.new(parsed_row, config).transform
20
+ end
21
+
22
+ DatesAndHeadTransformer.new(parsed_row, head_index, config).transform
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :genres item attribute.
5
+ class Genres < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param head_index [Integer] current item's position in the Head column.
8
+ # @return [Array<String>]
9
+ def transform_from_parsed(parsed_row, head_index)
10
+ (parsed_row[:genres] || parsed_row[:head][head_index][:genres])
11
+ &.map { _1.is_a?(Hash) ? _1[:genre] : _1 }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :notes item attribute.
5
+ class Notes < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param _head_index [Integer] current item's position in the Head column.
8
+ # @return [Array<Hash>] an array of notes; see
9
+ # Config#default_config[:item_template][:notes]
10
+ def transform_from_parsed(parsed_row, _head_index)
11
+ parsed_row[:notes]&.map { |note|
12
+ {
13
+ blurb?: note.has_key?(:note_blurb),
14
+ private?: note.has_key?(:note_private),
15
+ content: note[:note_regular] || note[:note_blurb] || note[:note_private],
16
+ }
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :rating item attribute.
5
+ class Rating < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param _head_index [Integer] current item's position in the Head column.
8
+ # @return [Integer, Float]
9
+ def transform_from_parsed(parsed_row, _head_index)
10
+ rating = parsed_row[:rating]&.dig(:number)
11
+
12
+ Integer(rating, exception: false) || Float(rating, exception: false)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,62 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Shared
5
+ module Shared
6
+ # Extracts the :progress sub-attribute (percent, pages, or time) from
7
+ # the given hash.
8
+ # @param hash [Hash] any parsed hash that contains progress.
9
+ # @return [Float, Integer, Reading::Item::TimeLength]
10
+ def self.progress(hash)
11
+ hash[:progress_percent]&.to_f&./(100) ||
12
+ hash[:progress_pages]&.to_i ||
13
+ hash[:progress_time]&.then { Item::TimeLength.parse _1 } ||
14
+ (0 if hash[:progress_dnf]) ||
15
+ (1.0 if hash[:progress_done]) ||
16
+ nil
17
+ end
18
+
19
+ # Extracts the :length sub-attribute (pages or time) from the given hash.
20
+ # @param hash [Hash] any parsed hash that contains length.
21
+ # @param key_name [Symbol] the first part of the keys to be checked.
22
+ # @param episodic [Boolean] whether to look for episodic (not total) length.
23
+ # If false, returns nil if hash contains :each. If true, returns a
24
+ # length only if hash contains :each or if it has repetitions, in
25
+ # which case repetitions are ignored. Examples of episodic lengths
26
+ # (before parsing) are "0:30 each" and "1:00 x14" (where the episodic
27
+ # length is 1:00). Examples of non-episodic lengths are "0:30" and "14:00".
28
+ # @param ignore_repetitions [Boolean] if true, ignores repetitions so
29
+ # that e.g. "1:00 x14" gives a length of 1 hour instead of 14 hours.
30
+ # This is useful for the History column, where that 1 hour can be used
31
+ # as the default amount.
32
+ # @return [Float, Integer, Reading::Item::TimeLength]
33
+ def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
34
+ return nil unless hash
35
+
36
+ length = hash[:"#{key_name}_pages"]&.to_i ||
37
+ hash[:"#{key_name}_time"]&.then { Item::TimeLength.parse _1 }
38
+
39
+ return nil unless length
40
+
41
+ if hash[:each]
42
+ # Length is calculated based on History column in this case.
43
+ if episodic
44
+ return length
45
+ else
46
+ return nil
47
+ end
48
+ end
49
+
50
+ if hash[:repetitions]
51
+ return length if episodic
52
+ length *= hash[:repetitions].to_i unless ignore_repetitions
53
+ else
54
+ return nil if episodic && !hash[:each]
55
+ end
56
+
57
+ length
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,21 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :title item attribute.
5
+ class Title < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param head_index [Integer] current item's position in the Head column.
8
+ # @return [String]
9
+ def transform_from_parsed(parsed_row, head_index)
10
+ title = parsed_row[:head][head_index][:title]
11
+
12
+ if title.nil? || title.end_with?(" -")
13
+ raise InvalidHeadError, "Missing title in the head #{parsed_row[:head][head_index]}"
14
+ end
15
+
16
+ title
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :variant item attribute.
5
+ class Variants < Attribute
6
+ using Util::HashArrayDeepFetch
7
+
8
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
9
+ # @param head_index [Integer] current item's position in the Head column.
10
+ # @return [Array<Hash>] an array of variants; see
11
+ # Config#default_config[:item_template][:variants]
12
+ def transform_from_parsed(parsed_row, head_index)
13
+ head = parsed_row[:head][head_index]
14
+
15
+ # || [{}] in case there is no Sources column.
16
+ (parsed_row[:sources].presence || [{}])&.map { |variant|
17
+ {
18
+ format: variant[:format] || head[:format],
19
+ series: (series(head) + series(variant)).presence,
20
+ sources: sources(variant) || sources(head),
21
+ isbn: variant[:isbn] || variant[:asin],
22
+ length: Attributes::Shared.length(variant) ||
23
+ Attributes::Shared.length(parsed_row[:length]),
24
+ extra_info: Array(head[:extra_info]) + Array(variant[:extra_info]),
25
+ }.map { |k, v| [k, v || template.fetch(k)] }.to_h
26
+ }&.compact&.presence
27
+ end
28
+
29
+ # A shortcut to the variant template.
30
+ # @return [Hash]
31
+ def template
32
+ config.deep_fetch(:item_template, :variants).first
33
+ end
34
+
35
+ # The :series sub-attribute for the given parsed hash.
36
+ # @param hash [Hash] any parsed hash that contains :series_names and :series_volumes.
37
+ # @return [Array<Hash>]
38
+ def series(hash)
39
+ (hash[:series_names] || [])
40
+ .zip(hash[:series_volumes] || [])
41
+ .map { |name, volume|
42
+ { name:, volume: Integer(volume, exception: false) }
43
+ }
44
+ end
45
+
46
+ # The :sources sub-attribute for the given parsed hash.
47
+ # @param hash [Hash] any parsed hash that contains :sources.
48
+ # @return [Array<Hash>]
49
+ def sources(hash)
50
+ hash[:sources]&.map { |source|
51
+ if source.match?(/\Ahttps?:\/\//)
52
+ { name: url_name(source), url: source }
53
+ else
54
+ { name: source, url: nil }
55
+ end
56
+ }
57
+ end
58
+
59
+ # The name for the given URL string, according to
60
+ # config[:source_names_from_urls], or nil.
61
+ # @param url [String] a URL.
62
+ # @return [String, nil]
63
+ def url_name(url)
64
+ config
65
+ .fetch(:source_names_from_urls)
66
+ .each do |url_part, name|
67
+ if url.include?(url_part)
68
+ return name
69
+ end
70
+ end
71
+
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,101 @@
1
+ # Used throughout, in other files.
2
+ require_relative "../util/blank"
3
+ require_relative "../util/string_remove"
4
+ require_relative "../util/string_truncate"
5
+ require_relative "../util/numeric_to_i_if_whole"
6
+ require_relative "../util/hash_to_struct"
7
+ require_relative "../util/hash_deep_merge"
8
+ require_relative "../util/hash_array_deep_fetch"
9
+ require_relative "../util/hash_compact_by_template"
10
+ require_relative "../errors"
11
+
12
+ # Used just here.
13
+ require_relative "../config"
14
+ require_relative "parser"
15
+ require_relative "transformer"
16
+
17
+ module Reading
18
+ module Parsing
19
+ #
20
+ # Validates a path or stream (string, file, etc.) of a CSV reading log, then
21
+ # parses it into item data (an array of Structs).
22
+ #
23
+ # Parsing happens in two steps:
24
+ # (1) Parse a row string into an intermediate hash representing the columns.
25
+ # - See parsing/parser.rb, which uses parsing/rows/*
26
+ # (2) Transform the intermediate hash into an array of hashes structured
27
+ # around item attributes rather than CSV columns.
28
+ # - See parsing/transformer.rb, which uses parsing/attributes/*
29
+ #
30
+ # Keeping these steps separate makes the code easier to understand. It was
31
+ # inspired by the Parslet gem: https://kschiess.github.io/parslet/transform.html
32
+ #
33
+ class CSV
34
+ using Util::HashToStruct
35
+
36
+ private attr_reader :parser, :transformer
37
+
38
+ # Validates a path or stream (string, file, etc.) of a CSV reading log,
39
+ # builds the config, and initializes the parser and transformer.
40
+ # @param path [String] path to the CSV file; if nil, stream is used instead.
41
+ # @param stream [Object] an object responding to #each_linewith CSV row(s);
42
+ # used if no path is given.
43
+ # @param config [Hash] a custom config which overrides the defaults,
44
+ # e.g. { errors: { styling: :html } }
45
+ def initialize(path = nil, stream: nil, config: {})
46
+ validate_path_or_stream(path, stream)
47
+ full_config = Config.new(config).hash
48
+
49
+ @path = path
50
+ @stream = stream
51
+ @parser = Parser.new(full_config)
52
+ @transformer = Transformer.new(full_config)
53
+ end
54
+
55
+ # Parses and transforms the reading log into item data.
56
+ # @return [Array<Struct>] an array of Structs like the template in
57
+ # Config#default_config[:item_template]. The Structs are identical in
58
+ # structure to that Hash (with every inner Hash replaced by a Struct).
59
+ def parse
60
+ input = @path ? File.open(@path) : @stream
61
+ items = []
62
+
63
+ input.each_line do |line|
64
+ begin
65
+ intermediate = parser.parse_row_to_intermediate_hash(line)
66
+ next if intermediate.empty? # When the row is blank or a comment.
67
+ row_items = transformer.transform_intermediate_hash_to_item_hashes(intermediate)
68
+ rescue Reading::Error => e
69
+ raise e.class, "#{e.message} in the row \"#{line}\""
70
+ end
71
+
72
+ items += row_items
73
+ end
74
+
75
+ items.map(&:to_struct)
76
+ ensure
77
+ input&.close if input.respond_to?(:close)
78
+ end
79
+
80
+ private
81
+
82
+ # Checks on the given stream and path (arguments to #initialize).
83
+ # @raise [FileError] if the given path is invalid.
84
+ # @raise [ArgumentError] if both stream and path are nil.
85
+ def validate_path_or_stream(path, stream)
86
+ if path
87
+ if !File.exist?(path)
88
+ raise FileError, "File not found! #{path}"
89
+ elsif File.directory?(path)
90
+ raise FileError, "A file is expected, but the path given is a directory: #{path}"
91
+ end
92
+ elsif stream && stream.respond_to?(:each_line)
93
+ return true
94
+ else
95
+ raise ArgumentError,
96
+ "Either a file path or a stream (string, file, etc.) must be provided."
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end