reading 0.8.0 → 0.9.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +95 -10
  3. data/lib/reading/config.rb +27 -5
  4. data/lib/reading/errors.rb +4 -1
  5. data/lib/reading/item/time_length.rb +60 -23
  6. data/lib/reading/item/view.rb +14 -19
  7. data/lib/reading/item.rb +324 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +17 -13
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +172 -60
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +19 -20
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +17 -7
  14. data/lib/reading/parsing/attributes/variants.rb +9 -6
  15. data/lib/reading/parsing/csv.rb +38 -35
  16. data/lib/reading/parsing/parser.rb +23 -24
  17. data/lib/reading/parsing/rows/blank.rb +23 -0
  18. data/lib/reading/parsing/rows/comment.rb +6 -7
  19. data/lib/reading/parsing/rows/compact_planned.rb +9 -9
  20. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
  21. data/lib/reading/parsing/rows/custom_config.rb +42 -0
  22. data/lib/reading/parsing/rows/regular.rb +15 -14
  23. data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
  24. data/lib/reading/parsing/rows/regular_columns/sources.rb +16 -10
  25. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +5 -1
  26. data/lib/reading/parsing/transformer.rb +13 -17
  27. data/lib/reading/stats/filter.rb +738 -0
  28. data/lib/reading/stats/grouping.rb +257 -0
  29. data/lib/reading/stats/operation.rb +345 -0
  30. data/lib/reading/stats/query.rb +37 -0
  31. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  32. data/lib/reading/util/exclude.rb +12 -0
  33. data/lib/reading/util/hash_array_deep_fetch.rb +1 -23
  34. data/lib/reading/util/hash_to_data.rb +2 -2
  35. data/lib/reading/version.rb +1 -1
  36. data/lib/reading.rb +36 -21
  37. metadata +28 -24
  38. data/bin/readingfile +0 -31
  39. data/lib/reading/util/string_remove.rb +0 -28
  40. data/lib/reading/util/string_truncate.rb +0 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecba33e4bbdb2dd11482113bf46d6196b17c89e2274e574b2286818f309c9ccb
4
- data.tar.gz: 95d094b19fd4509e8608f5db414062e633ba6665c949526cd34c271314db6845
3
+ metadata.gz: e30d2e08f85d4ecb58352862165f63b62dbcbeb436103cf0761f318592359571
4
+ data.tar.gz: 7991e2a241345bd8f5b8fe268c462b721d610157d2c2652aec16882d3833c65f
5
5
  SHA512:
6
- metadata.gz: ccfaace79d20ab57ab73349732735858049f217f3d0ad86408ae49b0f0f20591032dffd30e30d3f64d8c2644561d0b68e609e07398f3923fc85f4d25421504a6
7
- data.tar.gz: 578c7f8389d12eda16a30f2d702a944a92dd931e62fa4db4908521ca1d1a01ca0fa0100d96b32f6e624fec1e7abf0f907417fc3043ce0aabe605fdde5b2c181c
6
+ metadata.gz: 1357b2ecd226209ff58e5fa7215ca073bfda99225a9d1623b111417d9bf01ecc9ec61406b2bde9fb3a26f1c7ce6a24e9495eb2bedca403a45d029d9e94e83c0e
7
+ data.tar.gz: 10285aa58757b8fa07e94945811a9195f6e5015e06ace666c963885b5a5eb19839bae855013a54a76fb4d2a038a9dd1fc53611b2ea0d28a4385073811064cb46
data/bin/reading CHANGED
@@ -1,31 +1,116 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # A script that provides a quick way to see the output of a CSV string.
3
+ # Starts the reading statistics interactive CLI, if a CSV file path arg is given.
4
+ # If a CSV string is given instead, then parsing output (item hashes) is displayed.
4
5
  #
5
6
  # Usage:
6
7
  # Run on the command line:
7
- # reading "<CSV string>" "<optional comma-separated names of enabled columns>"
8
+ # reading "<CSV file path or string>" "<optional comma-separated names of enabled columns>"
8
9
  #
9
10
  # Examples:
11
+ # reading /home/felipe/reading.csv
12
+ # reading /home/felipe/reading.csv 'head, sources'
10
13
  # reading '3|📕Trying|Little Library 1970147288'
11
14
  # reading '📕Trying|Little Library 1970147288' 'head, sources'
12
15
 
16
+ require_relative '../lib/reading'
17
+ require_relative '../lib/reading/stats/terminal_result_formatters'
18
+ require 'debug'
19
+ require 'amazing_print'
20
+ require 'readline'
21
+ require 'pastel'
13
22
 
14
- require_relative "../lib/reading"
15
- require "amazing_print"
16
- require "debug"
23
+ EXIT_COMMANDS = %w[exit e quit q]
24
+ PASTEL = Pastel.new
25
+ GROUP_HEADING_FORMATTERS = [
26
+ -> { PASTEL.magenta.bold.underline(_1) },
27
+ -> { PASTEL.green.bold.underline(_1) },
28
+ -> { PASTEL.yellow.bold.underline(_1) },
29
+ -> { PASTEL.cyan.bold.underline(_1) },
30
+ -> { PASTEL.magenta.on_white(_1) },
31
+ -> { PASTEL.green.on_white(_1) },
32
+ -> { PASTEL.yellow.on_white(_1) },
33
+ ]
34
+
35
+ # Recursively prints a hash of results (possibly grouped).
36
+ # @param grouped_results [Hash, Array]
37
+ # @param group_heading_formatters [Array<Proc>] a subset of GROUP_HEADING_FORMATTERS
38
+ def print_grouped_results(grouped_results, group_heading_formatters)
39
+ indent_level = GROUP_HEADING_FORMATTERS.count - group_heading_formatters.count
40
+
41
+ if grouped_results.nil? || (grouped_results.respond_to?(:empty?) && grouped_results.empty?)
42
+ puts " " * indent_level + PASTEL.bright_black("none") + "\n"
43
+ return
44
+ end
45
+
46
+ if grouped_results.is_a?(Hash) ||
47
+ (grouped_results.is_a?(Array) && grouped_results.first.length == 2)
48
+
49
+ grouped_results.each do |group_name, grouped|
50
+ puts " " * indent_level + group_heading_formatters.first.call(group_name)
51
+ print_grouped_results(grouped, group_heading_formatters[1..])
52
+ end
53
+ elsif grouped_results.is_a?(Array)
54
+ numbered_results = grouped_results.map.with_index { |v, i| "#{i + 1}. #{v}" }
55
+
56
+ puts " " * indent_level + numbered_results.join("\n" + " " * indent_level) + "\n"
57
+ else
58
+ puts " " * indent_level + grouped_results.to_s + "\n"
59
+ end
60
+ end
17
61
 
18
62
  input = ARGV[0]
19
63
  unless input
20
- raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Little Library 1970147288'"
64
+ raise ArgumentError,
65
+ "Argument required, either a CSV file path or a CSV string.\nExamples:\n" \
66
+ "reading /home/felipe/reading.csv\n" \
67
+ "reading '3|📕Trying|Little Library 1970147288'"
21
68
  end
22
69
 
23
- config = {}
24
70
  if ARGV[1]
25
71
  enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
26
- config = { enabled_columns: }
72
+ Reading::Config.build(enabled_columns:)
27
73
  end
28
74
 
29
- items = Reading.parse(stream: input, config:, hash_output: true)
75
+ input_is_csv_path = input.end_with?('.csv')
76
+
77
+ if input_is_csv_path
78
+ error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
79
+
80
+ items = Reading.parse(path: input, item_view: false, error_handler:)
30
81
 
31
- ap items
82
+ loop do
83
+ raw_input = Readline.readline(PASTEL.bright_cyan("> "), true)
84
+
85
+ exit if EXIT_COMMANDS.include?(raw_input)
86
+
87
+ input = raw_input.presence
88
+ next if raw_input.blank?
89
+
90
+ results = Reading.stats(
91
+ input:,
92
+ items:,
93
+ result_formatters: Reading::Stats::ResultFormatters::TERMINAL,
94
+ )
95
+
96
+ if results.is_a?(Array) && results.first.is_a?(Reading::Item) # `debug` operation
97
+ r = results
98
+ puts PASTEL.red.bold("Enter 'c' to leave the debugger.")
99
+ debugger
100
+ else
101
+ print_grouped_results(results, GROUP_HEADING_FORMATTERS)
102
+ end
103
+ rescue Reading::Error => e
104
+ puts e
105
+ end
106
+ else # CSV string arg
107
+ input = input.gsub("\\|", "|") # because some pipes are escaped when pasting into the terminal
108
+
109
+ begin
110
+ item_hashes = Reading.parse(lines: input, hash_output: true, item_view: false)
111
+ rescue Reading::Error => e
112
+ puts "Skipped a row due to a parsing error: #{e}"
113
+ end
114
+
115
+ ap item_hashes
116
+ end
@@ -1,15 +1,26 @@
1
- require_relative "util/hash_deep_merge"
2
- require_relative "util/hash_array_deep_fetch"
3
- require_relative "errors"
1
+ require_relative 'errors'
4
2
 
5
3
  module Reading
6
- # Builds a hash config.
4
+ # Builds a singleton hash config.
7
5
  class Config
8
6
  using Util::HashDeepMerge
9
7
  using Util::HashArrayDeepFetch
10
8
 
11
9
  attr_reader :hash
12
10
 
11
+ # Builds an entire config hash from a custom config hash (which is typically
12
+ # not an entire config, but it can be, in which case a copy is returned).
13
+ # @param custom_config [Hash, Config]
14
+ # @return [Hash]
15
+ def self.build(custom_config = {})
16
+ @hash = new(custom_config).hash
17
+ end
18
+
19
+ # @return [Hash]
20
+ def self.hash
21
+ @hash ||= build
22
+ end
23
+
13
24
  # @param custom_config [Hash] a custom config which overrides the defaults,
14
25
  # e.g. { enabled_columns: [:head, :end_dates] }
15
26
  def initialize(custom_config = {})
@@ -55,6 +66,16 @@ module Reading
55
66
  column_separator: "|",
56
67
  ignored_characters: "✅❌💲❓⏳",
57
68
  skip_compact_planned: false,
69
+ pages_per_hour: 35,
70
+ length_group_boundaries: [200, 400, 600, 1000, 2000],
71
+ speed: # e.g. listening speed for audiobooks and podcasts.
72
+ {
73
+ format:
74
+ {
75
+ audiobook: 1.0,
76
+ audio: 1.0,
77
+ },
78
+ },
58
79
  # The Head column is always enabled; the others can be disabled by
59
80
  # using a custom config that omits columns from this array.
60
81
  enabled_columns:
@@ -86,6 +107,7 @@ module Reading
86
107
  },
87
108
  source_names_from_urls:
88
109
  {
110
+ "audible.com" => "Audible",
89
111
  "youtube.com" => "YouTube",
90
112
  "youtu.be" => "YouTube",
91
113
  "books.google.com" => "Google Books",
@@ -157,7 +179,7 @@ module Reading
157
179
  [{
158
180
  dates: nil,
159
181
  amount: 0,
160
- progress: nil,
182
+ progress: 1.0,
161
183
  name: nil,
162
184
  favorite?: false,
163
185
  }],
@@ -7,7 +7,7 @@ module Reading
7
7
  # Means there is something wrong with the user-supplied custom config.
8
8
  class ConfigError < Reading::Error; end
9
9
 
10
- # Means unexpected input was encountered during parsing.
10
+ # Means unexpected input was encountered during CSV parsing.
11
11
  class ParsingError < Reading::Error; end
12
12
 
13
13
  # Means something in the Head column (author, title, etc.) is invalid.
@@ -21,4 +21,7 @@ module Reading
21
21
 
22
22
  # Means a date is unparsable, or a set of dates does not make logical sense.
23
23
  class InvalidDateError < Reading::Error; end
24
+
25
+ # Means a stats command was entered incorrectly.
26
+ class InputError < Reading::Error; end
24
27
  end
@@ -1,7 +1,7 @@
1
1
  module Reading
2
2
  class Item
3
3
  # The length of an item when it is a time, as opposed to pages. (Pages are
4
- # represented simply with an Integer.)
4
+ # represented simply with an Integer or Float.)
5
5
  class Item::TimeLength
6
6
  include Comparable
7
7
 
@@ -14,16 +14,32 @@ module Reading
14
14
 
15
15
  # Builds an Item::TimeLength from a string.
16
16
  # @param string [String] a time duration in "h:mm" format.
17
- # @return [TimeLength]
17
+ # @return [TimeLength, nil]
18
18
  def self.parse(string)
19
+ return nil unless string.match? /\A\d+:\d\d\z/
20
+
19
21
  hours, minutes = string.split(':').map(&:to_i)
20
22
  new((hours * 60) + minutes)
21
23
  end
22
24
 
25
+ # Builds an Item::TimeLength based on a page count.
26
+ # @param pages [Integer, Float]
27
+ # @return [TimeLength]
28
+ def self.from_pages(pages)
29
+ new(pages_to_minutes(pages))
30
+ end
31
+
32
+ # Converts a page count to minutes.
33
+ # @param pages [Integer, Float]
34
+ # @return [Integer]
35
+ def self.pages_to_minutes(pages)
36
+ (pages.to_f / Config.hash.fetch(:pages_per_hour) * 60)
37
+ end
38
+
23
39
  # Only the hours, e.g. the "h" value in "h:mm".
24
40
  # @return [Numeric]
25
41
  def hours
26
- value / 60
42
+ value.to_i / 60
27
43
  end
28
44
 
29
45
  # Only the hours, e.g. the "mm" value in "h:mm".
@@ -35,7 +51,13 @@ module Reading
35
51
  # A string in "h:mm" format.
36
52
  # @return [String]
37
53
  def to_s
38
- "#{hours}:#{minutes}"
54
+ "#{hours}:#{minutes.round.to_s.rjust(2, '0')} or #{(value / 60.0 * Config.hash.fetch(:pages_per_hour)).round} pages"
55
+ end
56
+
57
+ # To pages.
58
+ # @return [Integer]
59
+ def to_i
60
+ ((value / 60.0) * Config.hash.fetch(:pages_per_hour)).to_i
39
61
  end
40
62
 
41
63
  # @return [Boolean]
@@ -43,6 +65,12 @@ module Reading
43
65
  value.zero?
44
66
  end
45
67
 
68
+ # A copy of self with a rounded @value.
69
+ # @return [TimeLength]
70
+ def round
71
+ self.class.new(@value.round)
72
+ end
73
+
46
74
  # Converts @value to an Integer if it's a whole number, and returns self.
47
75
  # @return [TimeLength]
48
76
  def to_i_if_whole!
@@ -62,27 +90,25 @@ module Reading
62
90
  self.class.new(@value.to_i)
63
91
  end
64
92
 
65
- # TODO: addition with pages (nonzero Integer)
66
- # @param other [TimeLength, Integer] must be zero if it's an Integer.
93
+ # @param other [TimeLength, Numeric]
67
94
  # @return [TimeLength]
68
95
  def +(other)
69
96
  if other.is_a? Item::TimeLength
70
97
  self.class.new(value + other.value)
71
- elsif other.zero?
72
- self
98
+ elsif other.is_a? Numeric
99
+ self.class.new(value + self.class.pages_to_minutes(other))
73
100
  else
74
101
  raise TypeError, "#{other.class} can't be added to Item::TimeLength."
75
102
  end
76
103
  end
77
104
 
78
- # TODO: subtraction with pages (nonzero Integer)
79
- # @param other [TimeLength, Integer] must be zero if it's an Integer.
105
+ # @param other [TimeLength, Numeric]
80
106
  # @return [TimeLength]
81
107
  def -(other)
82
108
  if other.is_a? Item::TimeLength
83
109
  self.class.new(value - other.value)
84
- elsif other.zero?
85
- self
110
+ elsif other.is_a? Numeric
111
+ self.class.new(value - self.class.pages_to_minutes(other))
86
112
  else
87
113
  raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
88
114
  end
@@ -108,25 +134,23 @@ module Reading
108
134
  end
109
135
  end
110
136
 
111
- # TODO: add coercion for pages (nonzero Integer)
112
- # See https://www.mutuallyhuman.com/blog/class-coercion-in-ruby
113
- # @param other [Integer] must be zero.
137
+ # See https://web.archive.org/web/20221206095821/https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
138
+ # @param other [Numeric]
114
139
  def coerce(other)
115
- if other.zero?
116
- [self.class.new(other), self]
140
+ if other.is_a? Numeric
141
+ [self.class.from_pages(other), self]
117
142
  else
118
- raise TypeError, "TimeLength can't be coerced into #{other.class}."
143
+ raise TypeError, "#{other.class} can't be coerced into a TimeLength."
119
144
  end
120
145
  end
121
146
 
122
- # TODO: add comparison to pages (nonzero Integer)
123
- # @param other [TimeLength, Integer] if Integer, must be zero.
147
+ # @param other [TimeLength, Numeric]
148
+ # @return [Integer]
124
149
  def <=>(other)
125
150
  return 1 if other.nil?
126
151
 
127
- if other.zero?
128
- return 0 if value.zero?
129
- return 1
152
+ if other.is_a? Numeric
153
+ other = self.class.from_pages(other)
130
154
  end
131
155
 
132
156
  unless other.is_a? Item::TimeLength
@@ -135,6 +159,19 @@ module Reading
135
159
 
136
160
  value <=> other.value
137
161
  end
162
+
163
+ # Must be implemented for hash key equality checks.
164
+ # @param other [TimeLength, Numeric]
165
+ # @return [Boolean]
166
+ def eql?(other)
167
+ hash == other.hash
168
+ end
169
+
170
+ # Must be implemented along with #eql? for hash key equality checks.
171
+ # @return [Integer]
172
+ def hash
173
+ value
174
+ end
138
175
  end
139
176
  end
140
177
  end
@@ -9,13 +9,12 @@ module Reading
9
9
  :isbn, :url, :experience_count, :groups, :blurb, :public_notes
10
10
 
11
11
  # @param item [Item] the Item from which to extract view information.
12
- # @param config [Hash] an entire config.
13
- def initialize(item, config)
12
+ def initialize(item)
14
13
  @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)
14
+ @rating = extract_star_or_rating(item)
15
+ @isbn, @url, variant = extract_first_source_info(item)
16
+ @name = extract_name(item, variant)
17
+ @type_emoji = extract_type_emoji(variant&.format)
19
18
  @date_or_status = extract_date_or_status(item)
20
19
  @experience_count = item.experiences.count
21
20
  @groups = item.experiences.map(&:group).compact
@@ -28,10 +27,9 @@ module Reading
28
27
  # A star (or nil if the item doesn't make the cut), or the number rating if
29
28
  # star ratings are disabled.
30
29
  # @param item [Item]
31
- # @param config [Hash] an entire config.
32
30
  # @return [String, Integer, Float]
33
- def extract_star_or_rating(item, config)
34
- minimum_rating = config.deep_fetch(:item, :view, :minimum_rating_for_star)
31
+ def extract_star_or_rating(item)
32
+ minimum_rating = Config.hash.deep_fetch(:item, :view, :minimum_rating_for_star)
35
33
  if minimum_rating
36
34
  "⭐" if item.rating && item.rating >= minimum_rating
37
35
  else
@@ -44,13 +42,12 @@ module Reading
44
42
  # an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
45
43
  # Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
46
44
  # @param item [Item]
47
- # @param config [Hash] an entire config.
48
45
  # @return [Array(String, String, Symbol, Array<String>)]
49
- def extract_first_source_info(item, config)
46
+ def extract_first_source_info(item)
50
47
  item.variants.map { |variant|
51
48
  isbn = variant.isbn
52
49
  if isbn
53
- url = config.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
50
+ url = Config.hash.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
54
51
  else
55
52
  url = variant.sources.map { |source| source.url }.compact.first
56
53
  end
@@ -64,9 +61,8 @@ module Reading
64
61
  # The view name of the item.
65
62
  # @param item [Item]
66
63
  # @param variant [Data, nil] a variant from the Item.
67
- # @param config [Hash] an entire config.
68
64
  # @return [String]
69
- def extract_name(item, variant, config)
65
+ def extract_name(item, variant)
70
66
  author_and_title = "#{item.author + " – " if item.author}#{item.title}"
71
67
  return author_and_title if variant.nil?
72
68
 
@@ -79,7 +75,7 @@ module Reading
79
75
  end
80
76
  }
81
77
 
82
- name_separator = config.deep_fetch(:item, :view, :name_separator)
78
+ name_separator = Config.hash.deep_fetch(:item, :view, :name_separator)
83
79
  series_and_extra_info = name_separator +
84
80
  (pretty_series + variant.extra_info).join(name_separator)
85
81
  end
@@ -89,10 +85,9 @@ module Reading
89
85
 
90
86
  # The emoji for the type that represents (encompasses) a given format.
91
87
  # @param format [Symbol, nil]
92
- # @param config [Hash] an entire config.
93
88
  # @return [String]
94
- def extract_type_emoji(format, config)
95
- types = config.deep_fetch(:item, :view, :types)
89
+ def extract_type_emoji(format)
90
+ types = Config.hash.deep_fetch(:item, :view, :types)
96
91
 
97
92
  return types.deep_fetch(format, :emoji) if types.has_key?(format)
98
93
 
@@ -101,7 +96,7 @@ module Reading
101
96
  &.first # key
102
97
 
103
98
  types.deep_fetch(
104
- type || config.deep_fetch(:item, :view, :default_type),
99
+ type || Config.hash.deep_fetch(:item, :view, :default_type),
105
100
  :emoji,
106
101
  )
107
102
  end