reading 0.6.1 → 0.8.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 (64) 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 +96 -108
  5. data/lib/reading/errors.rb +10 -66
  6. data/lib/reading/filter.rb +95 -0
  7. data/lib/reading/item/time_length.rb +140 -0
  8. data/lib/reading/item/view.rb +121 -0
  9. data/lib/reading/item.rb +117 -0
  10. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  11. data/lib/reading/parsing/attributes/author.rb +15 -0
  12. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  13. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  14. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  15. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  16. data/lib/reading/parsing/attributes/genres.rb +16 -0
  17. data/lib/reading/parsing/attributes/notes.rb +22 -0
  18. data/lib/reading/parsing/attributes/rating.rb +17 -0
  19. data/lib/reading/parsing/attributes/shared.rb +62 -0
  20. data/lib/reading/parsing/attributes/title.rb +21 -0
  21. data/lib/reading/parsing/attributes/variants.rb +77 -0
  22. data/lib/reading/parsing/csv.rb +112 -0
  23. data/lib/reading/parsing/parser.rb +292 -0
  24. data/lib/reading/parsing/rows/column.rb +131 -0
  25. data/lib/reading/parsing/rows/comment.rb +26 -0
  26. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  27. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  28. data/lib/reading/parsing/rows/regular.rb +33 -0
  29. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  30. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  31. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  32. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  33. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  34. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  35. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  36. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  37. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  38. data/lib/reading/parsing/transformer.rb +70 -0
  39. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  40. data/lib/reading/util/hash_deep_merge.rb +1 -1
  41. data/lib/reading/util/hash_to_data.rb +30 -0
  42. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  43. data/lib/reading/util/string_truncate.rb +13 -4
  44. data/lib/reading/version.rb +1 -1
  45. data/lib/reading.rb +49 -0
  46. metadata +76 -42
  47. data/lib/reading/attribute/all_attributes.rb +0 -83
  48. data/lib/reading/attribute/attribute.rb +0 -25
  49. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  50. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  51. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  52. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  53. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  54. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  55. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  56. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  57. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  58. data/lib/reading/csv.rb +0 -76
  59. data/lib/reading/line.rb +0 -23
  60. data/lib/reading/row/blank_row.rb +0 -23
  61. data/lib/reading/row/compact_planned_row.rb +0 -130
  62. data/lib/reading/row/regular_row.rb +0 -99
  63. data/lib/reading/row/row.rb +0 -88
  64. data/lib/reading/util/hash_to_struct.rb +0 -29
@@ -0,0 +1,121 @@
1
+ module Reading
2
+ class Item
3
+ # A view object for an Item, providing shortcuts to information that is handy
4
+ # to show (for example) on a webpage.
5
+ class View
6
+ using Util::HashArrayDeepFetch
7
+
8
+ attr_reader :name, :rating, :type_emoji, :genres, :date_or_status,
9
+ :isbn, :url, :experience_count, :groups, :blurb, :public_notes
10
+
11
+ # @param item [Item] the Item from which to extract view information.
12
+ # @param config [Hash] an entire config.
13
+ def initialize(item, config)
14
+ @genres = item.genres
15
+ @rating = extract_star_or_rating(item, config)
16
+ @isbn, @url, variant = extract_first_source_info(item, config)
17
+ @name = extract_name(item, variant, config)
18
+ @type_emoji = extract_type_emoji(variant&.format, config)
19
+ @date_or_status = extract_date_or_status(item)
20
+ @experience_count = item.experiences.count
21
+ @groups = item.experiences.map(&:group).compact
22
+ @blurb = item.notes.find(&:blurb?)&.content
23
+ @public_notes = item.notes.reject(&:private?).reject(&:blurb?).map(&:content)
24
+ end
25
+
26
+ private
27
+
28
+ # A star (or nil if the item doesn't make the cut), or the number rating if
29
+ # star ratings are disabled.
30
+ # @param item [Item]
31
+ # @param config [Hash] an entire config.
32
+ # @return [String, Integer, Float]
33
+ def extract_star_or_rating(item, config)
34
+ minimum_rating = config.deep_fetch(:item, :view, :minimum_rating_for_star)
35
+ if minimum_rating
36
+ "⭐" if item.rating && item.rating >= minimum_rating
37
+ else
38
+ item.rating
39
+ end
40
+ end
41
+
42
+
43
+ # The ISBN/ASIN, URL, format, and extra info of the first variant that has
44
+ # an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
45
+ # Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
46
+ # @param item [Item]
47
+ # @param config [Hash] an entire config.
48
+ # @return [Array(String, String, Symbol, Array<String>)]
49
+ def extract_first_source_info(item, config)
50
+ item.variants.map { |variant|
51
+ isbn = variant.isbn
52
+ if isbn
53
+ url = config.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
54
+ else
55
+ url = variant.sources.map { |source| source.url }.compact.first
56
+ end
57
+
58
+ [isbn, url, variant]
59
+ }
60
+ .select { |isbn, url, _variant| isbn || url }
61
+ .first || [nil, nil, item.variants.first]
62
+ end
63
+
64
+ # The view name of the item.
65
+ # @param item [Item]
66
+ # @param variant [Data, nil] a variant from the Item.
67
+ # @param config [Hash] an entire config.
68
+ # @return [String]
69
+ def extract_name(item, variant, config)
70
+ author_and_title = "#{item.author + " – " if item.author}#{item.title}"
71
+ return author_and_title if variant.nil?
72
+
73
+ unless variant.series.empty? && variant.extra_info.empty?
74
+ pretty_series = variant.series.map { |series|
75
+ if series.volume
76
+ "#{series.name}, ##{series.volume}"
77
+ else
78
+ "in #{series.name}"
79
+ end
80
+ }
81
+
82
+ name_separator = config.deep_fetch(:item, :view, :name_separator)
83
+ series_and_extra_info = name_separator +
84
+ (pretty_series + variant.extra_info).join(name_separator)
85
+ end
86
+
87
+ author_and_title + (series_and_extra_info || "")
88
+ end
89
+
90
+ # The emoji for the type that represents (encompasses) a given format.
91
+ # @param format [Symbol, nil]
92
+ # @param config [Hash] an entire config.
93
+ # @return [String]
94
+ def extract_type_emoji(format, config)
95
+ types = config.deep_fetch(:item, :view, :types)
96
+
97
+ return types.deep_fetch(format, :emoji) if types.has_key?(format)
98
+
99
+ type = types
100
+ .find { |type, hash| hash[:from_formats]&.include?(format) }
101
+ &.first # key
102
+
103
+ types.deep_fetch(
104
+ type || config.deep_fetch(:item, :view, :default_type),
105
+ :emoji,
106
+ )
107
+ end
108
+
109
+ # The date (if done) or status, stringified.
110
+ # @param item [Item]
111
+ # @return [String]
112
+ def extract_date_or_status(item)
113
+ if item.done?
114
+ item.last_end_date&.strftime("%Y-%m-%d")
115
+ else
116
+ item.status.to_s.gsub('_', ' ')
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,117 @@
1
+ require "forwardable"
2
+
3
+ require_relative "item/view"
4
+ require_relative "config"
5
+ require_relative "util/hash_to_data"
6
+ require_relative "util/hash_array_deep_fetch"
7
+
8
+ module Reading
9
+ # A wrapper for an item parsed from a CSV reading log, providing convenience
10
+ # methods beyond what the parser's raw Hash output can provide.
11
+ class Item
12
+ using Util::HashToData
13
+ using Util::HashArrayDeepFetch
14
+ extend Forwardable
15
+
16
+ ATTRIBUTES = %i[rating author title genres variants experiences notes]
17
+
18
+ private attr_reader :attributes, :config
19
+ attr_reader :view, :status, :last_end_date
20
+
21
+ def_delegators :attributes, *ATTRIBUTES
22
+
23
+ # @param item_hash [Hash] a parsed item like the template in
24
+ # Config#default_config[:item][:template].
25
+ # @param config [Hash] an entire config.
26
+ # @param view [Class, nil, Boolean] the class that will be used to build the
27
+ # view object, or nil/false if no view object should be built. If you use
28
+ # a custom view class, the only requirement is that its #initialize take
29
+ # an Item and a full config as arguments.
30
+ def initialize(item_hash, config: Config.new.hash, view: Item::View)
31
+ item_hash = item_hash.dup
32
+
33
+ add_missing_attributes_with_filler_values(item_hash, config)
34
+
35
+ @attributes = item_hash.to_data
36
+ @config = config
37
+
38
+ @status, @last_end_date = get_status_and_last_end_date
39
+ @view = view.new(self, config) if view
40
+ end
41
+
42
+ # Whether this item is done.
43
+ # @return [Boolean]
44
+ def done?
45
+ status == :done
46
+ end
47
+
48
+ # Whether this item has a fixed length, such as a book or audiobook (as
49
+ # opposed to an ongoing podcast).
50
+ # @return [Boolean]
51
+ def definite_length?
52
+ attributes.variants.any? { |variant| !!variant.length }
53
+ end
54
+
55
+ # Equality to another Item.
56
+ # @other [Item]
57
+ # @return [Boolean]
58
+ def ==(other)
59
+ unless other.is_a?(Item)
60
+ raise ArgumentError, "An Item can be compared only with another Item."
61
+ end
62
+
63
+ attributes == other.send(:attributes)
64
+ end
65
+
66
+ private
67
+
68
+ # For each missing item attribute (key in config[:item][:template]) in
69
+ # item_hash, adds the key and a filler value.
70
+ # @param item_hash [Hash]
71
+ # @param config [Hash] an entire config.
72
+ def add_missing_attributes_with_filler_values(item_hash, config)
73
+ config.deep_fetch(:item, :template).each do |k, v|
74
+ next if item_hash.has_key?(k)
75
+
76
+ filler = v.is_a?(Array) ? [] : nil
77
+ item_hash[k] = filler
78
+ end
79
+ end
80
+
81
+ # Determines the status and the last end date. Note: for an item of indefinite
82
+ # length (e.g. podcast) there is a grace period during which the status
83
+ # remains :in_progress after the last activity. If that grace period is over,
84
+ # the status is :done. It's :planned if there are no spans with dates.
85
+ # @return [Array(Symbol, Date)]
86
+ def get_status_and_last_end_date
87
+ return [:planned, nil] if experiences.none? || experiences.flat_map(&:spans).none?
88
+
89
+ experiences_with_spans_with_dates = experiences
90
+ .select { |experience| experience.spans.any? { |span| span.dates } }
91
+
92
+ return [:planned, nil] unless experiences_with_spans_with_dates.any?
93
+
94
+ last_end_date = experiences_with_spans_with_dates
95
+ .last
96
+ .spans
97
+ .select { |span| span.dates }
98
+ .last
99
+ .dates
100
+ .end
101
+
102
+ return [:in_progress, nil] unless last_end_date
103
+
104
+ if definite_length?
105
+ [:done, last_end_date]
106
+ else
107
+ grace_period = config.deep_fetch(:item, :indefinite_in_progress_grace_period_days)
108
+ indefinite_in_progress_grace_period_is_over =
109
+ (Date.today - grace_period) > last_end_date
110
+
111
+ return [:done, last_end_date] if indefinite_in_progress_grace_period_is_over
112
+
113
+ [:in_progress, last_end_date]
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,26 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # The base class for all the attribute in parsing/attributes, each of which
5
+ # extracts an attribute from a parsed row. Together they transform the
6
+ # parsed row (an intermediate hash) into item attributes, as in
7
+ # Config#default_config[:item][:template].
8
+ class Attribute
9
+ private attr_reader :config
10
+
11
+ # @param config [Hash] an entire config.
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Extracts this attribute's value from a parsed row.
17
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
18
+ # @param head_index [Integer] current item's position in the Head column.
19
+ # @return [Object]
20
+ def transform_from_parsed(parsed_row, head_index)
21
+ raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :author item attribute.
5
+ class Author < 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
+ parsed_row[:head][head_index][:author]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,106 @@
1
+ require_relative "spans_validator"
2
+
3
+ module Reading
4
+ module Parsing
5
+ module Attributes
6
+ class Experiences < Attribute
7
+ # Experiences#transform_from_parsed delegates to this class when the
8
+ # History column is blank (i.e. when experiences should be extracted
9
+ # from the Start Dates, End Dates, and Head columns).
10
+ class DatesAndHeadTransformer
11
+ using Util::HashArrayDeepFetch
12
+
13
+ private attr_reader :config, :parsed_row, :head_index
14
+
15
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
16
+ # @param head_index [Integer] current item's position in the Head column.
17
+ # @param config [Hash] an entire config.
18
+ def initialize(parsed_row, head_index, config)
19
+ @config = config
20
+ @parsed_row = parsed_row
21
+ @head_index = head_index
22
+ end
23
+
24
+ # Extracts experiences from the parsed row.
25
+ # @return [Array<Hash>] an array of experiences; see
26
+ # Config#default_config[:item][:template][:experiences]
27
+ def transform
28
+ size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
29
+ # Pad start dates with {} and end dates with nil up to the size of
30
+ # the larger of the two.
31
+ start_dates = Array.new(size) { |i| parsed_row[:start_dates]&.dig(i) || {} }
32
+ end_dates = Array.new(size) { |i| parsed_row[:end_dates]&.dig(i) || nil }
33
+
34
+ start_end_dates = start_dates.zip(end_dates).presence || [[{}, nil]]
35
+
36
+ experiences_with_dates = start_end_dates.map { |start_entry, end_entry|
37
+ {
38
+ spans: spans(start_entry, end_entry),
39
+ group: start_entry[:group],
40
+ variant_index: (start_entry[:variant] || 1).to_i - 1,
41
+ }.map { |k, v| [k, v || template.fetch(k)] }.to_h
42
+ }.presence
43
+
44
+ if experiences_with_dates
45
+ # Raises an error if any sequence of dates does not make sense.
46
+ Experiences::SpansValidator.validate(experiences_with_dates, config)
47
+ end
48
+
49
+ experiences_with_dates
50
+ end
51
+
52
+ private
53
+
54
+ # A shortcut to the experience template.
55
+ # @return [Hash]
56
+ def template
57
+ config.deep_fetch(:item, :template, :experiences).first
58
+ end
59
+
60
+ # A shortcut to the span template.
61
+ # @return [Hash]
62
+ def span_template
63
+ config.deep_fetch(:item, :template, :experiences, 0, :spans).first
64
+ end
65
+
66
+ # The :spans sub-attribute for the given pair of date entries.
67
+ # single span in an array.
68
+ # @param start_entry [Hash] a parsed entry in the Start Dates column.
69
+ # @param end_entry [Hash] a parsed entry in the End Dates column.
70
+ # @return [Array(Hash)] an array containing a single span representing
71
+ # the start and end date.
72
+ def spans(start_entry, end_entry)
73
+ if !start_entry&.dig(:date) && !end_entry&.dig(:date)
74
+ dates = nil
75
+ else
76
+ dates = [start_entry, end_entry].map { |date_hash|
77
+ begin
78
+ Date.parse(date_hash[:date]) if date_hash&.dig(:date)
79
+ rescue Date::Error
80
+ raise InvalidDateError, "Unparsable date \"#{date_hash[:date]}\""
81
+ end
82
+ }
83
+ dates = dates[0]..dates[1]
84
+ end
85
+
86
+ variant_index = (start_entry[:variant] || 1).to_i - 1
87
+ length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index)) ||
88
+ Attributes::Shared.length(parsed_row[:length])
89
+
90
+ [
91
+ {
92
+ dates: dates,
93
+ amount: (length if dates),
94
+ progress: Attributes::Shared.progress(start_entry) ||
95
+ Attributes::Shared.progress(parsed_row[:head][head_index]) ||
96
+ (1.0 if end_entry),
97
+ name: span_template.fetch(:name),
98
+ favorite?: span_template.fetch(:favorite?),
99
+ }.map { |k, v| [k, v || span_template.fetch(k)] }.to_h
100
+ ]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end