reading 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +80 -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 +321 -54
  8. data/lib/reading/parsing/attributes/attribute.rb +0 -7
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
  12. data/lib/reading/parsing/attributes/experiences.rb +5 -5
  13. data/lib/reading/parsing/attributes/shared.rb +13 -6
  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 +15 -9
  25. data/lib/reading/parsing/transformer.rb +13 -17
  26. data/lib/reading/stats/filter.rb +738 -0
  27. data/lib/reading/stats/grouping.rb +243 -0
  28. data/lib/reading/stats/operation.rb +313 -0
  29. data/lib/reading/stats/query.rb +37 -0
  30. data/lib/reading/stats/terminal_result_formatters.rb +91 -0
  31. data/lib/reading/util/exclude.rb +12 -0
  32. data/lib/reading/util/hash_to_data.rb +2 -2
  33. data/lib/reading/version.rb +1 -1
  34. data/lib/reading.rb +36 -21
  35. metadata +10 -6
  36. data/bin/readingfile +0 -31
  37. data/lib/reading/util/string_remove.rb +0 -28
  38. 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: e839c46251c307a65a234a9a374ad964f68d934d974005d6aa38cbb35e10f756
4
+ data.tar.gz: fa20c0b78f3fd1194495e1e604bb1d95b37e0334ac12c745d6508e026cd6fe97
5
5
  SHA512:
6
- metadata.gz: ccfaace79d20ab57ab73349732735858049f217f3d0ad86408ae49b0f0f20591032dffd30e30d3f64d8c2644561d0b68e609e07398f3923fc85f4d25421504a6
7
- data.tar.gz: 578c7f8389d12eda16a30f2d702a944a92dd931e62fa4db4908521ca1d1a01ca0fa0100d96b32f6e624fec1e7abf0f907417fc3043ce0aabe605fdde5b2c181c
6
+ metadata.gz: 2bbca1450b2f6ae4c0d2b6d4cfeec495bf30b20639fcb3a5b121711e1d995a8b13a5ca64100d7a5e11d00d60c3808d61d25583510e3cd19902d412eca216154f
7
+ data.tar.gz: 74a676238941cdd5a0a7ecf8b2a4ed3c705883daadc25b3710a17eb9e325ec294d8b9fad71e653779af1ea9e01d0e8fed6423592dbe750b4f37c79f0f690387d
data/bin/reading CHANGED
@@ -1,31 +1,101 @@
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 'debug'
17
+ require_relative '../lib/reading'
18
+ require_relative '../lib/reading/stats/terminal_result_formatters'
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.empty?
42
+ puts " " * indent_level + PASTEL.bright_black("none") + "\n"
43
+ return
44
+ elsif !grouped_results.is_a? Hash
45
+ puts " " * indent_level + grouped_results.gsub("\n", "\n" + " " * indent_level) + "\n"
46
+ return
47
+ end
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
+ end
17
54
 
18
55
  input = ARGV[0]
19
56
  unless input
20
- raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Little Library 1970147288'"
57
+ raise ArgumentError,
58
+ "Argument required, either a CSV file path or a CSV string. Examples:\n" \
59
+ "parsereading /home/felipe/reading.csv\n" \
60
+ "parsereading '3|📕Trying|Little Library 1970147288'"
21
61
  end
22
62
 
23
- config = {}
24
63
  if ARGV[1]
25
64
  enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
26
- config = { enabled_columns: }
65
+ Reading::Config.build(enabled_columns:)
27
66
  end
28
67
 
29
- items = Reading.parse(stream: input, config:, hash_output: true)
68
+ input_is_csv_path = input.end_with?('.csv')
69
+
70
+ if input_is_csv_path
71
+ error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
72
+
73
+ items = Reading.parse(path: input, item_view: false, error_handler:)
30
74
 
31
- ap items
75
+ loop do
76
+ raw_input = Readline.readline(PASTEL.bright_cyan("> "), true)
77
+
78
+ exit if EXIT_COMMANDS.include?(raw_input)
79
+
80
+ input = raw_input.presence
81
+ next if raw_input.blank?
82
+
83
+ results = Reading.stats(
84
+ input:,
85
+ items:,
86
+ result_formatters: Reading::Stats::ResultFormatters::TERMINAL,
87
+ )
88
+
89
+ print_grouped_results(results, GROUP_HEADING_FORMATTERS)
90
+ rescue Reading::Error => e
91
+ puts e
92
+ end
93
+ else # CSV string arg
94
+ begin
95
+ item_hashes = Reading.parse(lines: input, hash_output: true, item_view: false)
96
+ rescue Reading::Error => e
97
+ puts "Skipped a row due to a parsing error: #{e}"
98
+ end
99
+
100
+ ap item_hashes
101
+ 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",
@@ -156,8 +178,8 @@ module Reading
156
178
  spans:
157
179
  [{
158
180
  dates: nil,
181
+ progress: 1.0,
159
182
  amount: 0,
160
- progress: nil,
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