reading 0.7.0 โ†’ 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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