reading 0.7.0 โ†’ 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 531b4f54f11eed2f638079efbc0542977287d3ca887bea04a0785b6ca10c45fe
4
- data.tar.gz: 58881344b75fc84041275d3ad9b84955f03d53ff8ca35f09932509b584c8b218
3
+ metadata.gz: ecba33e4bbdb2dd11482113bf46d6196b17c89e2274e574b2286818f309c9ccb
4
+ data.tar.gz: 95d094b19fd4509e8608f5db414062e633ba6665c949526cd34c271314db6845
5
5
  SHA512:
6
- metadata.gz: 3539806e8c4472ba98d1ac19c862989a21d25782e4ec94ac04a3dfd4a3f30509187dce4ee6fa86fc189f742e19e2e649ffcf94f5e539cc1bc576856748b9adf6
7
- data.tar.gz: 1be98e8aa5fc04a87aae46832cc981a49d3b725eeeaee2c6b75f1d723cf907d2e6567b9474022a5b19bc113db3555a7d358ec924c469f269d361b440d1963ca2
6
+ metadata.gz: ccfaace79d20ab57ab73349732735858049f217f3d0ad86408ae49b0f0f20591032dffd30e30d3f64d8c2644561d0b68e609e07398f3923fc85f4d25421504a6
7
+ data.tar.gz: 578c7f8389d12eda16a30f2d702a944a92dd931e62fa4db4908521ca1d1a01ca0fa0100d96b32f6e624fec1e7abf0f907417fc3043ce0aabe605fdde5b2c181c
data/bin/reading CHANGED
@@ -26,6 +26,6 @@ if ARGV[1]
26
26
  config = { enabled_columns: }
27
27
  end
28
28
 
29
- items = Reading.parse(stream: input, config:)
29
+ items = Reading.parse(stream: input, config:, hash_output: true)
30
30
 
31
31
  ap items
data/bin/readingfile CHANGED
@@ -7,8 +7,8 @@
7
7
  # reading "<file path>" "<optional comma-separated names of enabled columns>"
8
8
  #
9
9
  # Examples:
10
- # reading '/home/alex/reading.csv'
11
- # reading '/home/alex/reading.csv' 'head, sources'
10
+ # reading '/home/felipe/reading.csv'
11
+ # reading '/home/felipe/reading.csv' 'head, sources'
12
12
 
13
13
 
14
14
  require_relative "../lib/reading"
@@ -17,7 +17,7 @@ require "debug"
17
17
 
18
18
  path = ARGV[0]
19
19
  unless path
20
- raise ArgumentError, "CSV path argument required, such as '/home/alex/reading.csv'"
20
+ raise ArgumentError, "CSV path argument required, such as '/home/felipe/reading.csv'"
21
21
  end
22
22
 
23
23
  config = {}
@@ -26,6 +26,6 @@ if ARGV[1]
26
26
  config = { enabled_columns: }
27
27
  end
28
28
 
29
- items = Reading.parse(path, config:)
29
+ items = Reading.parse(path, config:, hash_output: true)
30
30
 
31
31
  ap items
@@ -94,61 +94,83 @@ module Reading
94
94
  "librivox.org" => "LibriVox",
95
95
  "tv.apple.com" => "Apple TV",
96
96
  },
97
- # The structure of an item, along with default values.
98
- # Wherever an array of hashes ends up with no data (i.e. equal to the
99
- # value in the template), it is collapsed into an empty array.
100
- # E.g. the row "|Dracula||๐Ÿค๐Ÿผbook club" is parsed to a Struct analogous to:
101
- # {
102
- # rating: nil,
103
- # author: nil,
104
- # title: "Dracula",
105
- # genres: [],
106
- # variants: [],
107
- # experiences: [{ spans: [], group: "book club", variant_index: 0 }],
108
- # notes: [],
109
- # }
110
- item_template:
97
+ item:
111
98
  {
112
- rating: nil,
113
- author: nil,
114
- title: nil,
115
- genres: [],
116
- variants:
117
- [{
118
- format: nil,
119
- series:
99
+ # After how many days of no activity an item of indefinite length
100
+ # (e.g. a podcast) should change its status from :in_progress to :done.
101
+ indefinite_in_progress_grace_period_days: 30,
102
+ view:
103
+ {
104
+ name_separator: " ใ€œ ",
105
+ url_from_isbn: "https://www.goodreads.com/book/isbn?isbn=%{isbn}",
106
+ # Items rated this or above get a star. If nil, number ratings are shown instead.
107
+ minimum_rating_for_star: 5,
108
+ types:
109
+ {
110
+ book: { emoji: "๐Ÿ“•", from_formats: %i[print ebook audiobook pdf] },
111
+ course: { emoji: "๐Ÿซ", from_formats: %i[website] },
112
+ piece: { emoji: "โœ๏ธ" },
113
+ video: { emoji: "๐ŸŽž๏ธ" },
114
+ audio: { emoji: "๐ŸŽค" },
115
+ },
116
+ default_type: :book,
117
+ },
118
+ # The structure of an item, along with default values.
119
+ # Wherever an array of hashes ends up with no data (i.e. equal to the
120
+ # value in the template), it is collapsed into an empty array.
121
+ # E.g. the row "|Dracula||๐Ÿค๐Ÿผbook club" is parsed to an Item analogous to:
122
+ # {
123
+ # rating: nil,
124
+ # author: nil,
125
+ # title: "Dracula",
126
+ # genres: [],
127
+ # variants: [],
128
+ # experiences: [{ spans: [], group: "book club", variant_index: 0 }],
129
+ # notes: [],
130
+ # }
131
+ template:
132
+ {
133
+ rating: nil,
134
+ author: nil,
135
+ title: nil,
136
+ genres: [],
137
+ variants:
120
138
  [{
121
- name: nil,
122
- volume: nil,
139
+ format: nil,
140
+ series:
141
+ [{
142
+ name: nil,
143
+ volume: nil,
144
+ }],
145
+ sources:
146
+ [{
147
+ name: nil,
148
+ url: nil,
149
+ }],
150
+ isbn: nil,
151
+ length: nil,
152
+ extra_info: [],
123
153
  }],
124
- sources:
154
+ experiences:
125
155
  [{
126
- name: nil,
127
- url: nil,
156
+ spans:
157
+ [{
158
+ dates: nil,
159
+ amount: 0,
160
+ progress: nil,
161
+ name: nil,
162
+ favorite?: false,
163
+ }],
164
+ group: nil,
165
+ variant_index: 0,
128
166
  }],
129
- isbn: nil,
130
- length: nil,
131
- extra_info: [],
132
- }],
133
- experiences:
134
- [{
135
- spans:
167
+ notes:
136
168
  [{
137
- dates: nil,
138
- amount: 0,
139
- progress: nil,
140
- name: nil,
141
- favorite?: false,
169
+ blurb?: false,
170
+ private?: false,
171
+ content: nil,
142
172
  }],
143
- group: nil,
144
- variant_index: 0,
145
- }],
146
- notes:
147
- [{
148
- blurb?: false,
149
- private?: false,
150
- content: nil,
151
- }],
173
+ },
152
174
  },
153
175
  }
154
176
  end
@@ -0,0 +1,95 @@
1
+ module Reading
2
+ # Filters Items based on given criteria.
3
+ class Filter
4
+ class << self
5
+ # Filters Items based on given criteria, and returns them sorted by last
6
+ # end date or (where there is none) status, where :planned Items are
7
+ # placed last, and :in_progress just before those.
8
+ # @param items [Array<Item>]
9
+ # @param no_sort [Boolean] to preserve the original ordering of the Items.
10
+ # @param criteria [Hash] one or more of the filters defined in by_x methods below.
11
+ # @return [Array<Item>]
12
+ # @raise [ArgumentError] if criteria are invalid or missing.
13
+ def by(items:, no_sort: false, **criteria)
14
+ validate_criteria(**criteria)
15
+
16
+ filtered = criteria.each.with_object(items.dup) { |(criterion, arg), filtered_items|
17
+ send("#{CRITERIA_PREFIX}#{criterion}#{CRITERIA_SUFFIX}", filtered_items, arg)
18
+ }
19
+
20
+ return filtered if no_sort
21
+
22
+ filtered.sort_by { |item|
23
+ if item.done?
24
+ item.last_end_date.strftime("%Y-%m-%d")
25
+ else
26
+ item.status.to_s
27
+ end
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ CRITERIA_PREFIX = "by_".freeze
34
+ CRITERIA_SUFFIX = "!".freeze
35
+
36
+ # Checks that the args match real Filter criteria.
37
+ # @param criteria [Hash] must include only one or more of the criteria
38
+ # defined in by_x methods below.
39
+ # @raise [ArgumentError] if criteria are empty or invalid.
40
+ def validate_criteria(**criteria)
41
+ available_criteria = private_methods(false)
42
+ .select { _1.to_s.start_with?(CRITERIA_PREFIX) }
43
+ .map { _1.to_s.delete_prefix(CRITERIA_PREFIX).delete_suffix(CRITERIA_SUFFIX).to_sym }
44
+
45
+ if criteria.empty?
46
+ raise ArgumentError, "Filter requires at least one of these criteria: #{available_criteria}"
47
+ end
48
+
49
+ unrecognized_criteria = criteria.keys - available_criteria
50
+ if unrecognized_criteria.any?
51
+ raise ArgumentError, "Unrecognized criteria passed to Filter: #{unrecognized_criteria}"
52
+ end
53
+ end
54
+
55
+ # Mutates the given array of Items to select only Items with a rating
56
+ # greater than or equal to the given minimum.
57
+ # @param items [Array<Item>]
58
+ # @param minimum_rating [Integer]
59
+ def by_minimum_rating!(items, minimum_rating)
60
+ return items unless minimum_rating
61
+
62
+ items.select! do |item|
63
+ if item.rating
64
+ item.rating >= minimum_rating
65
+ end
66
+ end
67
+ end
68
+
69
+ # Mutates the given array of Items to exclude Items with genres including
70
+ # any of the given genres.
71
+ # @param items [Array<Item>]
72
+ # @param excluded_genres [Array<String>]
73
+ def by_excluded_genres!(items, excluded_genres)
74
+ return items unless excluded_genres&.any?
75
+
76
+ items.select! do |item|
77
+ overlapping = item.genres & excluded_genres
78
+ overlapping.empty?
79
+ end
80
+ end
81
+
82
+ # Mutates the given array of Items to select only Items with a status
83
+ # equal to the given status (or one of the given statuses).
84
+ # @param items [Array<Item>]
85
+ # @param statuses [Symbol, Array<Symbol>]
86
+ def by_status!(items, statuses)
87
+ statuses = Array(statuses)
88
+
89
+ items.select! do |item|
90
+ statuses.include? item.status
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,7 +1,8 @@
1
1
  module Reading
2
- module Item
3
- # For coercion, see https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
4
- class TimeLength
2
+ class Item
3
+ # The length of an item when it is a time, as opposed to pages. (Pages are
4
+ # represented simply with an Integer.)
5
+ class Item::TimeLength
5
6
  include Comparable
6
7
 
7
8
  attr_reader :value # in total minutes
@@ -11,7 +12,7 @@ module Reading
11
12
  @value = value
12
13
  end
13
14
 
14
- # Builds a TimeLength from a string.
15
+ # Builds an Item::TimeLength from a string.
15
16
  # @param string [String] a time duration in "h:mm" format.
16
17
  # @return [TimeLength]
17
18
  def self.parse(string)
@@ -65,12 +66,12 @@ module Reading
65
66
  # @param other [TimeLength, Integer] must be zero if it's an Integer.
66
67
  # @return [TimeLength]
67
68
  def +(other)
68
- if other.is_a? TimeLength
69
+ if other.is_a? Item::TimeLength
69
70
  self.class.new(value + other.value)
70
71
  elsif other.zero?
71
72
  self
72
73
  else
73
- raise TypeError, "#{other.class} can't be added to TimeLength."
74
+ raise TypeError, "#{other.class} can't be added to Item::TimeLength."
74
75
  end
75
76
  end
76
77
 
@@ -78,12 +79,12 @@ module Reading
78
79
  # @param other [TimeLength, Integer] must be zero if it's an Integer.
79
80
  # @return [TimeLength]
80
81
  def -(other)
81
- if other.is_a? TimeLength
82
+ if other.is_a? Item::TimeLength
82
83
  self.class.new(value - other.value)
83
84
  elsif other.zero?
84
85
  self
85
86
  else
86
- raise TypeError, "#{other.class} can't be subtracted from TimeLength."
87
+ raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
87
88
  end
88
89
  end
89
90
 
@@ -108,6 +109,7 @@ module Reading
108
109
  end
109
110
 
110
111
  # TODO: add coercion for pages (nonzero Integer)
112
+ # See https://www.mutuallyhuman.com/blog/class-coercion-in-ruby
111
113
  # @param other [Integer] must be zero.
112
114
  def coerce(other)
113
115
  if other.zero?
@@ -127,7 +129,7 @@ module Reading
127
129
  return 1
128
130
  end
129
131
 
130
- unless other.is_a? TimeLength
132
+ unless other.is_a? Item::TimeLength
131
133
  raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
132
134
  end
133
135
 
@@ -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
@@ -4,7 +4,7 @@ module Reading
4
4
  # The base class for all the attribute in parsing/attributes, each of which
5
5
  # extracts an attribute from a parsed row. Together they transform the
6
6
  # parsed row (an intermediate hash) into item attributes, as in
7
- # Config#default_config[:item_template].
7
+ # Config#default_config[:item][:template].
8
8
  class Attribute
9
9
  private attr_reader :config
10
10
 
@@ -23,7 +23,7 @@ module Reading
23
23
 
24
24
  # Extracts experiences from the parsed row.
25
25
  # @return [Array<Hash>] an array of experiences; see
26
- # Config#default_config[:item_template][:experiences]
26
+ # Config#default_config[:item][:template][:experiences]
27
27
  def transform
28
28
  size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
29
29
  # Pad start dates with {} and end dates with nil up to the size of
@@ -54,13 +54,13 @@ module Reading
54
54
  # A shortcut to the experience template.
55
55
  # @return [Hash]
56
56
  def template
57
- config.deep_fetch(:item_template, :experiences).first
57
+ config.deep_fetch(:item, :template, :experiences).first
58
58
  end
59
59
 
60
60
  # A shortcut to the span template.
61
61
  # @return [Hash]
62
62
  def span_template
63
- config.deep_fetch(:item_template, :experiences, 0, :spans).first
63
+ config.deep_fetch(:item, :template, :experiences, 0, :spans).first
64
64
  end
65
65
 
66
66
  # The :spans sub-attribute for the given pair of date entries.
@@ -27,7 +27,7 @@ module Reading
27
27
 
28
28
  # Extracts experiences from the parsed row.
29
29
  # @return [Array<Hash>] an array of experiences; see
30
- # Config#default_config[:item_template][:experiences]
30
+ # Config#default_config[:item][:template][:experiences]
31
31
  def transform
32
32
  experiences = parsed_row[:history].map { |entries|
33
33
  {
@@ -48,13 +48,13 @@ module Reading
48
48
  # A shortcut to the span template.
49
49
  # @return [Hash]
50
50
  def span_template
51
- @span_template ||= config.deep_fetch(:item_template, :experiences, 0, :spans).first
51
+ @span_template ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
52
52
  end
53
53
 
54
54
  # The :spans sub-attribute for the given History column entries.
55
55
  # @param entries [Array<Hash>] History entries for one experience.
56
56
  # @return [Array<Hash>] an array of spans; see
57
- # Config#default_config[:item_template][:experiences].first[:spans]
57
+ # Config#default_config[:item][:template][:experiences].first[:spans]
58
58
  def spans_from_history_entries(entries)
59
59
  daily_spans = {}
60
60
  active = {
@@ -304,11 +304,11 @@ module Reading
304
304
  # Distributes an amount across the given date(s).
305
305
  # @param date_or_range [Date, Range<Date>] the date or range across
306
306
  # which the amount will be split up.
307
- # @param amount [Float, Integer, Reading::Item::TimeLength] amount in
307
+ # @param amount [Float, Integer, Item::TimeLength] amount in
308
308
  # pages or time.
309
309
  # @param repetitions [Integer] e.g. "x4" in a History entry.
310
310
  # @param frequency [Integer] e.g. "/week" in a History entry.
311
- # @return [Hash{Date => Float, Integer, Reading::Item::TimeLength}]
311
+ # @return [Hash{Date => Float, Integer, Item::TimeLength}]
312
312
  def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
313
313
  unless amount
314
314
  raise InvalidHistoryError, "Missing length or amount"
@@ -59,7 +59,7 @@ module Reading
59
59
  # if there is more than one more start date than end dates.
60
60
  # @raise [InvalidDateError]
61
61
  def validate_number_of_start_dates_and_end_dates(experiences)
62
- both_dates, not_both_dates = experiences
62
+ _both_dates, not_both_dates = experiences
63
63
  .filter { |exp| exp[:spans].first&.dig(:dates) }
64
64
  .map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
65
65
  .partition { |start_date, end_date| start_date && end_date }
@@ -13,7 +13,7 @@ module Reading
13
13
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
14
14
  # @param head_index [Integer] current item's position in the Head column.
15
15
  # @return [Array<Hash>] an array of experiences; see
16
- # Config#default_config[:item_template][:experiences]
16
+ # Config#default_config[:item][:template][:experiences]
17
17
  def transform_from_parsed(parsed_row, head_index)
18
18
  if !parsed_row[:history].blank?
19
19
  return HistoryTransformer.new(parsed_row, config).transform
@@ -6,7 +6,7 @@ module Reading
6
6
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
7
  # @param _head_index [Integer] current item's position in the Head column.
8
8
  # @return [Array<Hash>] an array of notes; see
9
- # Config#default_config[:item_template][:notes]
9
+ # Config#default_config[:item][:template][:notes]
10
10
  def transform_from_parsed(parsed_row, _head_index)
11
11
  parsed_row[:notes]&.map { |note|
12
12
  {
@@ -6,7 +6,7 @@ module Reading
6
6
  # Extracts the :progress sub-attribute (percent, pages, or time) from
7
7
  # the given hash.
8
8
  # @param hash [Hash] any parsed hash that contains progress.
9
- # @return [Float, Integer, Reading::Item::TimeLength]
9
+ # @return [Float, Integer, Item::TimeLength]
10
10
  def self.progress(hash)
11
11
  hash[:progress_percent]&.to_f&./(100) ||
12
12
  hash[:progress_pages]&.to_i ||
@@ -29,7 +29,7 @@ module Reading
29
29
  # that e.g. "1:00 x14" gives a length of 1 hour instead of 14 hours.
30
30
  # This is useful for the History column, where that 1 hour can be used
31
31
  # as the default amount.
32
- # @return [Float, Integer, Reading::Item::TimeLength]
32
+ # @return [Float, Integer, Item::TimeLength]
33
33
  def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
34
34
  return nil unless hash
35
35
 
@@ -8,7 +8,7 @@ module Reading
8
8
  # @param parsed_row [Hash] a parsed row (the intermediate hash).
9
9
  # @param head_index [Integer] current item's position in the Head column.
10
10
  # @return [Array<Hash>] an array of variants; see
11
- # Config#default_config[:item_template][:variants]
11
+ # Config#default_config[:item][:template][:variants]
12
12
  def transform_from_parsed(parsed_row, head_index)
13
13
  head = parsed_row[:head][head_index]
14
14
 
@@ -29,7 +29,7 @@ module Reading
29
29
  # A shortcut to the variant template.
30
30
  # @return [Hash]
31
31
  def template
32
- config.deep_fetch(:item_template, :variants).first
32
+ config.deep_fetch(:item, :template, :variants).first
33
33
  end
34
34
 
35
35
  # The :series sub-attribute for the given parsed hash.
@@ -3,7 +3,6 @@ require_relative "../util/blank"
3
3
  require_relative "../util/string_remove"
4
4
  require_relative "../util/string_truncate"
5
5
  require_relative "../util/numeric_to_i_if_whole"
6
- require_relative "../util/hash_to_struct"
7
6
  require_relative "../util/hash_deep_merge"
8
7
  require_relative "../util/hash_array_deep_fetch"
9
8
  require_relative "../util/hash_compact_by_template"
@@ -11,6 +10,7 @@ require_relative "../errors"
11
10
 
12
11
  # Used just here.
13
12
  require_relative "../config"
13
+ require_relative "../item"
14
14
  require_relative "parser"
15
15
  require_relative "transformer"
16
16
 
@@ -18,7 +18,7 @@ module Reading
18
18
  module Parsing
19
19
  #
20
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).
21
+ # parses it into an array of Items.
22
22
  #
23
23
  # Parsing happens in two steps:
24
24
  # (1) Parse a row string into an intermediate hash representing the columns.
@@ -31,33 +31,40 @@ module Reading
31
31
  # inspired by the Parslet gem: https://kschiess.github.io/parslet/transform.html
32
32
  #
33
33
  class CSV
34
- using Util::HashToStruct
35
-
36
- private attr_reader :parser, :transformer
34
+ private attr_reader :parser, :transformer, :hash_output, :item_view
37
35
 
38
36
  # Validates a path or stream (string, file, etc.) of a CSV reading log,
39
37
  # builds the config, and initializes the parser and transformer.
40
- # @param path [String] path to the CSV file; if nil, stream is used instead.
38
+ # @param path [String] path to the CSV file; used if no stream is given.
41
39
  # @param stream [Object] an object responding to #each_linewith CSV row(s);
42
- # used if no path is given.
40
+ # if nil, path is used instead.
43
41
  # @param config [Hash] a custom config which overrides the defaults,
44
42
  # e.g. { errors: { styling: :html } }
45
- def initialize(path = nil, stream: nil, config: {})
43
+ # @param hash_output [Boolean] whether an array of raw Hashes should be
44
+ # returned, without Items being created from them.
45
+ # @param view [Class, nil, Boolean] the class that will be used to build
46
+ # each Item's view object, or nil/false if no view object should be built.
47
+ # If you use a custom view class, the only requirement is that its
48
+ # #initialize take an Item and a full config as arguments.
49
+ def initialize(path = nil, stream: nil, config: {}, hash_output: false, item_view: Item::View)
46
50
  validate_path_or_stream(path, stream)
47
51
  full_config = Config.new(config).hash
48
52
 
49
53
  @path = path
50
54
  @stream = stream
55
+ @hash_output = hash_output
56
+ @item_view = item_view
51
57
  @parser = Parser.new(full_config)
52
58
  @transformer = Transformer.new(full_config)
53
59
  end
54
60
 
55
61
  # 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).
62
+ # @return [Array<Item>] an array of Items like the template in
63
+ # Config#default_config[:item][:template]. The Items are identical in
64
+ # structure to that Hash (with every inner Hash replaced by a Data for
65
+ # dot access).
59
66
  def parse
60
- input = @path ? File.open(@path) : @stream
67
+ input = @stream || File.open(@path)
61
68
  items = []
62
69
 
63
70
  input.each_line do |line|
@@ -72,7 +79,11 @@ module Reading
72
79
  items += row_items
73
80
  end
74
81
 
75
- items.map(&:to_struct)
82
+ if hash_output
83
+ items
84
+ else
85
+ items.map { |item_hash| Item.new(item_hash, view: item_view) }
86
+ end
76
87
  ensure
77
88
  input&.close if input.respond_to?(:close)
78
89
  end
@@ -83,14 +94,14 @@ module Reading
83
94
  # @raise [FileError] if the given path is invalid.
84
95
  # @raise [ArgumentError] if both stream and path are nil.
85
96
  def validate_path_or_stream(path, stream)
86
- if path
97
+ if stream && stream.respond_to?(:each_line)
98
+ return true
99
+ elsif path
87
100
  if !File.exist?(path)
88
101
  raise FileError, "File not found! #{path}"
89
102
  elsif File.directory?(path)
90
103
  raise FileError, "A file is expected, but the path given is a directory: #{path}"
91
104
  end
92
- elsif stream && stream.respond_to?(:each_line)
93
- return true
94
105
  else
95
106
  raise ArgumentError,
96
107
  "Either a file path or a stream (string, file, etc.) must be provided."
@@ -75,8 +75,8 @@ module Reading
75
75
  # @return [Hash{Class => String}] a hash whose keys are classes inheriting
76
76
  # Parsing::Rows::Column.
77
77
  def extract_columns(string)
78
- clean_string = string.dup.force_encoding(Encoding::UTF_8)
79
- column_strings = clean_string.split(config.fetch(:column_separator))
78
+ string = string.dup.force_encoding(Encoding::UTF_8)
79
+ column_strings = string.split(config.fetch(:column_separator))
80
80
 
81
81
  row_types = [Rows::Regular, Rows::CompactPlanned, Rows::Comment]
82
82
  column_classes = row_types
@@ -14,7 +14,7 @@ module Reading
14
14
  # Transforms an intermediate hash (parsed from a CSV row) into item data.
15
15
  # While the intermediate hash mirrors the structure of a row, the output of
16
16
  # Transformer is based around item attributes, which are listed in
17
- # Config#default_config[:item_template] and in the files in parsing/attributes.
17
+ # Config#default_config[:item][:template] and in the files in parsing/attributes.
18
18
  #
19
19
  class Transformer
20
20
  using Util::HashArrayDeepFetch
@@ -34,13 +34,13 @@ module Reading
34
34
  # @param parsed_row [Hash{Symbol => Hash, Array}] output from
35
35
  # Parsing::Parser#parse_row_to_intermediate_hash.
36
36
  # @return [Array<Hash>] an array of Hashes like the template in
37
- # Config#default_config[:item_template].
37
+ # Config#default_config[:item][:template].
38
38
  def transform_intermediate_hash_to_item_hashes(parsed_row)
39
39
  if parsed_row[:head].blank?
40
40
  raise InvalidHeadError, "Blank or missing Head column"
41
41
  end
42
42
 
43
- template = config.fetch(:item_template)
43
+ template = config.deep_fetch(:item, :template)
44
44
 
45
45
  parsed_row[:head].map.with_index { |_head, head_index|
46
46
  template.map { |attribute_name, default_value|
@@ -58,7 +58,7 @@ module Reading
58
58
  # Sets the attributes classes which do all the transforming work.
59
59
  # See parsing/attributes/*.
60
60
  def set_attributes
61
- @attributes ||= config.fetch(:item_template).map { |attribute_name, _default|
61
+ @attributes ||= config.deep_fetch(:item, :template).map { |attribute_name, _default|
62
62
  attribute_name_camelcase = attribute_name.to_s.split("_").map(&:capitalize).join
63
63
  attribute_class = Attributes.const_get(attribute_name_camelcase)
64
64
 
@@ -0,0 +1,30 @@
1
+ module Reading
2
+ module Util
3
+ # Converts a Hash to a Data. Converts inner hashes (and inner arrays of hashes) as well.
4
+ module HashToData
5
+ refine Hash do
6
+ # @return [Data]
7
+ def to_data
8
+ MEMOIZED_DATAS[keys] ||= Data.define(*keys)
9
+ data_class = MEMOIZED_DATAS[keys]
10
+
11
+ data_values = transform_values { |v|
12
+ if v.is_a?(Hash)
13
+ v.to_data
14
+ elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
15
+ v.map(&:to_data)
16
+ else
17
+ v
18
+ end
19
+ }.values
20
+
21
+ data_class.new(*data_values)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ MEMOIZED_DATAS = {}
28
+ end
29
+ end
30
+ end
@@ -1,3 +1,3 @@
1
1
  module Reading
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/reading.rb CHANGED
@@ -1,8 +1,27 @@
1
1
  require_relative "reading/parsing/csv"
2
+ require_relative "reading/filter"
3
+ require_relative "reading/config"
2
4
  require_relative "reading/item/time_length.rb"
3
5
 
4
6
  # The gem's public API. See https://github.com/fpsvogel/reading#usage
5
-
7
+ #
8
+ # Architectural overview:
9
+ #
10
+ # (CSV input) (Items) (filtered Items)
11
+ # | ฮ› | ฮ›
12
+ # | | ยท---. |
13
+ # | | | |
14
+ # V | V |
15
+ # ::parse | ::filter |
16
+ # | | | |
17
+ # | .----------> Item Filter
18
+ # Config, | / / \
19
+ # errors.rb ----- Parsing::CSV --ยท Item::View Item::TimeLength
20
+ # / \
21
+ # Parsing::Parser Parsing::Transformer
22
+ # | |
23
+ # parsing/attributes/* parsing/rows/*
24
+ #
6
25
  module Reading
7
26
  # Parses a CSV file or string. See Parsing::CSV#initialize and #parse for details.
8
27
  def self.parse(...)
@@ -10,9 +29,21 @@ module Reading
10
29
  csv.parse
11
30
  end
12
31
 
32
+ # Filters an array of Items. See Filter::by for details.
33
+ def self.filter(...)
34
+ Filter.by(...)
35
+ end
36
+
37
+ # The default config.
38
+ # @return [Hash]
39
+ def self.default_config
40
+ Config.new.hash
41
+ end
42
+
43
+ # A shortcut for getting a time from a string.
13
44
  # @param string [String] a time duration in "h:mm" format.
14
- # @return [Reading::Item::TimeLength]
45
+ # @return [Item::TimeLength]
15
46
  def self.time(string)
16
- Reading::Item::TimeLength.parse(string)
47
+ Item::TimeLength.parse(string)
17
48
  end
18
49
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reading
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Felipe Vogel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-05 00:00:00.000000000 Z
11
+ date: 2023-04-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pastel
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '1.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: shoulda-context
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: pretty-diffs
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -122,7 +136,10 @@ files:
122
136
  - lib/reading.rb
123
137
  - lib/reading/config.rb
124
138
  - lib/reading/errors.rb
139
+ - lib/reading/filter.rb
140
+ - lib/reading/item.rb
125
141
  - lib/reading/item/time_length.rb
142
+ - lib/reading/item/view.rb
126
143
  - lib/reading/parsing/attributes/attribute.rb
127
144
  - lib/reading/parsing/attributes/author.rb
128
145
  - lib/reading/parsing/attributes/experiences.rb
@@ -156,7 +173,7 @@ files:
156
173
  - lib/reading/util/hash_array_deep_fetch.rb
157
174
  - lib/reading/util/hash_compact_by_template.rb
158
175
  - lib/reading/util/hash_deep_merge.rb
159
- - lib/reading/util/hash_to_struct.rb
176
+ - lib/reading/util/hash_to_data.rb
160
177
  - lib/reading/util/numeric_to_i_if_whole.rb
161
178
  - lib/reading/util/string_remove.rb
162
179
  - lib/reading/util/string_truncate.rb
@@ -1,30 +0,0 @@
1
- module Reading
2
- module Util
3
- # Converts a Hash to a Struct. Converts inner hashes (and inner arrays of hashes) as well.
4
- module HashToStruct
5
- refine Hash do
6
- # @return [Struct]
7
- def to_struct
8
- MEMOIZED_STRUCTS[keys] ||= Struct.new(*keys)
9
- struct_class = MEMOIZED_STRUCTS[keys]
10
-
11
- struct_values = transform_values { |v|
12
- if v.is_a?(Hash)
13
- v.to_struct
14
- elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
15
- v.map(&:to_struct)
16
- else
17
- v
18
- end
19
- }.values
20
-
21
- struct_class.new(*struct_values)
22
- end
23
- end
24
-
25
- private
26
-
27
- MEMOIZED_STRUCTS = {}
28
- end
29
- end
30
- end