reading 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +5 -5
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +115 -149
  5. data/lib/reading/errors.rb +10 -66
  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 -76
  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 -99
  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