reading 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +8 -8
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +115 -148
  5. data/lib/reading/errors.rb +11 -64
  6. data/lib/reading/item/time_length.rb +138 -0
  7. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  8. data/lib/reading/parsing/attributes/author.rb +15 -0
  9. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  10. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  11. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  12. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  13. data/lib/reading/parsing/attributes/genres.rb +16 -0
  14. data/lib/reading/parsing/attributes/notes.rb +22 -0
  15. data/lib/reading/parsing/attributes/rating.rb +17 -0
  16. data/lib/reading/parsing/attributes/shared.rb +62 -0
  17. data/lib/reading/parsing/attributes/title.rb +21 -0
  18. data/lib/reading/parsing/attributes/variants.rb +77 -0
  19. data/lib/reading/parsing/csv.rb +101 -0
  20. data/lib/reading/parsing/parser.rb +292 -0
  21. data/lib/reading/parsing/rows/column.rb +131 -0
  22. data/lib/reading/parsing/rows/comment.rb +26 -0
  23. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  24. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  25. data/lib/reading/parsing/rows/regular.rb +33 -0
  26. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  27. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  28. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  29. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  30. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  31. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  32. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  33. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  34. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  35. data/lib/reading/parsing/transformer.rb +70 -0
  36. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  37. data/lib/reading/util/hash_deep_merge.rb +1 -1
  38. data/lib/reading/util/hash_to_struct.rb +1 -0
  39. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  40. data/lib/reading/util/string_truncate.rb +13 -4
  41. data/lib/reading/version.rb +1 -1
  42. data/lib/reading.rb +18 -0
  43. metadata +58 -41
  44. data/lib/reading/attribute/all_attributes.rb +0 -83
  45. data/lib/reading/attribute/attribute.rb +0 -25
  46. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  47. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  48. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  49. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  50. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  51. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  52. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  53. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  54. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  55. data/lib/reading/csv.rb +0 -67
  56. data/lib/reading/line.rb +0 -23
  57. data/lib/reading/row/blank_row.rb +0 -23
  58. data/lib/reading/row/compact_planned_row.rb +0 -130
  59. data/lib/reading/row/regular_row.rb +0 -94
  60. data/lib/reading/row/row.rb +0 -88
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70825fd8a882da595b06db4f8a0766962dd71b8149248ada3ed55280d68b2975
4
- data.tar.gz: 124c660e9888c712a8bedc05156700b3e233300a8fe9806dd36ec168e910bfea
3
+ metadata.gz: 531b4f54f11eed2f638079efbc0542977287d3ca887bea04a0785b6ca10c45fe
4
+ data.tar.gz: 58881344b75fc84041275d3ad9b84955f03d53ff8ca35f09932509b584c8b218
5
5
  SHA512:
6
- metadata.gz: 174a7a35761e5a26569651bee4e7ddefd0bbe8592b3828df34cb9f465d0a734876a57bb0fecb04683c470567a0df151289c375213cdf7ad67d632d8d06d02d10
7
- data.tar.gz: 0cec333e90f2a701521d06c4924083e137268112c1a2749effb17b952b3028a4f4deae9ababd9ce1592eb0a21ba60afac75c0e18f81762af936306d86f2adb82
6
+ metadata.gz: 3539806e8c4472ba98d1ac19c862989a21d25782e4ec94ac04a3dfd4a3f30509187dce4ee6fa86fc189f742e19e2e649ffcf94f5e539cc1bc576856748b9adf6
7
+ data.tar.gz: 1be98e8aa5fc04a87aae46832cc981a49d3b725eeeaee2c6b75f1d723cf907d2e6567b9474022a5b19bc113db3555a7d358ec924c469f269d361b440d1963ca2
data/bin/reading CHANGED
@@ -4,28 +4,28 @@
4
4
  #
5
5
  # Usage:
6
6
  # Run on the command line:
7
- # reading "<CSV string>" "<optional comma-separated names of enabled columns>`
7
+ # reading "<CSV string>" "<optional comma-separated names of enabled columns>"
8
8
  #
9
9
  # Examples:
10
- # reading '3|📕Trying|Lexpub 1970147288'
11
- # reading '📕Trying|Lexpub 1970147288' 'head, sources'
10
+ # reading '3|📕Trying|Little Library 1970147288'
11
+ # reading '📕Trying|Little Library 1970147288' 'head, sources'
12
12
 
13
13
 
14
- require_relative "../lib/reading/csv"
14
+ require_relative "../lib/reading"
15
15
  require "amazing_print"
16
+ require "debug"
16
17
 
17
18
  input = ARGV[0]
18
19
  unless input
19
- raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Lexpub 1970147288'"
20
+ raise ArgumentError, "CSV string argument required, such as '3|📕Trying|Little Library 1970147288'"
20
21
  end
21
22
 
22
23
  config = {}
23
24
  if ARGV[1]
24
25
  enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
25
- config[:csv] = { enabled_columns: }
26
+ config = { enabled_columns: }
26
27
  end
27
28
 
28
- csv = Reading::CSV.new(input, config:)
29
- items = csv.parse
29
+ items = Reading.parse(stream: input, config:)
30
30
 
31
31
  ap items
data/bin/readingfile ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # A script that provides a quick way to see the output of a CSV file.
4
+ #
5
+ # Usage:
6
+ # Run on the command line:
7
+ # reading "<file path>" "<optional comma-separated names of enabled columns>"
8
+ #
9
+ # Examples:
10
+ # reading '/home/alex/reading.csv'
11
+ # reading '/home/alex/reading.csv' 'head, sources'
12
+
13
+
14
+ require_relative "../lib/reading"
15
+ require "amazing_print"
16
+ require "debug"
17
+
18
+ path = ARGV[0]
19
+ unless path
20
+ raise ArgumentError, "CSV path argument required, such as '/home/alex/reading.csv'"
21
+ end
22
+
23
+ config = {}
24
+ if ARGV[1]
25
+ enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
26
+ config = { enabled_columns: }
27
+ end
28
+
29
+ items = Reading.parse(path, config:)
30
+
31
+ ap items
@@ -1,3 +1,7 @@
1
+ require_relative "util/hash_deep_merge"
2
+ require_relative "util/hash_array_deep_fetch"
3
+ require_relative "errors"
4
+
1
5
  module Reading
2
6
  # Builds a hash config.
3
7
  class Config
@@ -7,7 +11,7 @@ module Reading
7
11
  attr_reader :hash
8
12
 
9
13
  # @param custom_config [Hash] a custom config which overrides the defaults,
10
- # e.g. { errors: { styling: :html } }
14
+ # e.g. { enabled_columns: [:head, :end_dates] }
11
15
  def initialize(custom_config = {})
12
16
  @custom_config = custom_config
13
17
 
@@ -21,181 +25,144 @@ module Reading
21
25
  def build_hash
22
26
  @hash = default_config.deep_merge(@custom_config)
23
27
 
24
- # If custom formats are given, use only the custom formats. #dig is used here
25
- # (not #deep_fetch as most elsewhere) because custom_config may not include this data.
26
- if @custom_config[:item] && @custom_config.dig(:item, :formats)
27
- @hash[:item][:formats] = @custom_config.dig(:item, :formats)
28
+ # If custom formats are given, use only the custom formats.
29
+ if @custom_config.has_key?(:formats)
30
+ @hash[:formats] = @custom_config[:formats]
28
31
  end
29
32
 
30
- # Validate enabled_columns
31
- enabled_columns = @hash.deep_fetch(:csv, :enabled_columns)
32
- enabled_columns << :head
33
- enabled_columns.uniq!
34
- enabled_columns.sort_by! { |col| default_config.deep_fetch(:csv, :enabled_columns).index(col) }
33
+ # Ensure enabled_columns includes :head, and sort them.
34
+ enabled_columns =
35
+ (@hash.fetch(:enabled_columns) + [:head])
36
+ .uniq
37
+ .sort_by { |col| default_config[:enabled_columns].index(col) || 0 }
38
+
39
+ invalid_columns = enabled_columns - default_config[:enabled_columns]
40
+ if invalid_columns.any?
41
+ raise ConfigError, "Invalid columns in custom config: #{invalid_columns.join(", ")}"
42
+ end
35
43
 
36
- # Add the Regex config, which is built based on the config so far.
37
- @hash[:csv][:regex] = build_regex_config
44
+ @hash[:enabled_columns] = enabled_columns
45
+
46
+ # Add the regex config, which is built based on the config so far.
47
+ @hash[:regex] = regex_config
38
48
  end
39
49
 
40
50
  # The default config, excluding Regex config (see further down).
41
51
  # @return [Hash]
42
52
  def default_config
43
53
  {
44
- errors:
54
+ comment_character: "\\",
55
+ column_separator: "|",
56
+ ignored_characters: "✅❌💲❓⏳",
57
+ skip_compact_planned: false,
58
+ # The Head column is always enabled; the others can be disabled by
59
+ # using a custom config that omits columns from this array.
60
+ enabled_columns:
61
+ %i[
62
+ rating
63
+ head
64
+ sources
65
+ start_dates
66
+ end_dates
67
+ genres
68
+ length
69
+ notes
70
+ history
71
+ ],
72
+ # If your custom config includes formats, they will replace the defaults
73
+ # (unlike the rest of the config, to which custom config is deep merged).
74
+ # So if you want to keep any of these defaults, include them in your config.
75
+ formats:
45
76
  {
46
- handle_error: -> (error) { puts error },
47
- max_length: 100, # or require "io/console", then IO.console.winsize[1]
48
- catch_all_errors: false, # set this to false during development.
49
- styling: :terminal, # or :html
77
+ print: "📕",
78
+ ebook: "",
79
+ audiobook: "🔊",
80
+ pdf: "📄",
81
+ audio: "🎤",
82
+ video: "🎞️",
83
+ course: "🏫",
84
+ piece: "✏️",
85
+ website: "🌐",
50
86
  },
51
- item:
87
+ source_names_from_urls:
52
88
  {
53
- formats:
54
- {
55
- print: "📕",
56
- ebook: "",
57
- audiobook: "🔊",
58
- pdf: "📄",
59
- audio: "🎤",
60
- video: "🎞️",
61
- course: "🏫",
62
- piece: "✏️",
63
- website: "🌐",
64
- },
65
- sources:
66
- {
67
- names_from_urls:
68
- {
69
- "youtube.com" => "YouTube",
70
- "youtu.be" => "YouTube",
71
- "books.google.com" => "Google Books",
72
- "archive.org" => "Internet Archive",
73
- "thegreatcourses.com" => "The Great Courses",
74
- "librivox.org" => "LibriVox",
75
- "tv.apple.com" => "Apple TV",
76
- },
77
- default_name_for_url: "site",
78
- },
79
- template:
80
- {
81
- rating: nil,
82
- author: nil,
83
- title: nil,
84
- genres: [],
85
- variants:
89
+ "youtube.com" => "YouTube",
90
+ "youtu.be" => "YouTube",
91
+ "books.google.com" => "Google Books",
92
+ "archive.org" => "Internet Archive",
93
+ "thegreatcourses.com" => "The Great Courses",
94
+ "librivox.org" => "LibriVox",
95
+ "tv.apple.com" => "Apple TV",
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:
111
+ {
112
+ rating: nil,
113
+ author: nil,
114
+ title: nil,
115
+ genres: [],
116
+ variants:
117
+ [{
118
+ format: nil,
119
+ series:
86
120
  [{
87
- format: nil,
88
- series:
89
- [{
90
- name: nil,
91
- volume: nil,
92
- }],
93
- sources:
94
- [{
95
- name: nil,
96
- url: nil,
97
- }],
98
- isbn: nil,
99
- length: nil,
100
- extra_info: [],
121
+ name: nil,
122
+ volume: nil,
101
123
  }],
102
- experiences:
124
+ sources:
103
125
  [{
104
- spans:
105
- [{
106
- dates: nil,
107
- amount: nil,
108
- progress: nil,
109
- name: nil,
110
- favorite?: false,
111
- }],
112
- group: nil,
113
- variant_index: 0,
126
+ name: nil,
127
+ url: nil,
114
128
  }],
115
- notes:
129
+ isbn: nil,
130
+ length: nil,
131
+ extra_info: [],
132
+ }],
133
+ experiences:
134
+ [{
135
+ spans:
116
136
  [{
117
- blurb?: false,
118
- private?: false,
119
- content: nil,
137
+ dates: nil,
138
+ amount: 0,
139
+ progress: nil,
140
+ name: nil,
141
+ favorite?: false,
120
142
  }],
121
- },
122
- },
123
- csv:
124
- {
125
- # The Head column is always enabled; the others can be disabled by
126
- # using a custom config that omits columns from this array.
127
- enabled_columns:
128
- %i[
129
- rating
130
- head
131
- sources
132
- dates_started
133
- dates_finished
134
- genres
135
- length
136
- public_notes
137
- blurb
138
- private_notes
139
- history
140
- ],
141
- # Custom columns are listed in a hash with default values, like simple columns in item[:template] above.
142
- custom_numeric_columns: {}, # e.g. { family_friendliness: 5, surprise_factor: nil }
143
- custom_text_columns: {}, # e.g. { mood: nil, rec_by: nil, will_reread: "no" }
144
- comment_character: "\\",
145
- column_separator: "|",
146
- separator: ",",
147
- short_separator: " - ",
148
- long_separator: " -- ",
149
- dnf_string: "DNF",
150
- series_prefix: "in",
151
- group_emoji: "🤝🏼",
152
- blurb_emoji: "💬",
153
- private_emoji: "🔒",
154
- compact_planned_source_prefix: "@",
155
- compact_planned_ignored_chars: "✅💲❓⏳⭐",
156
- skip_compact_planned: false,
143
+ group: nil,
144
+ variant_index: 0,
145
+ }],
146
+ notes:
147
+ [{
148
+ blurb?: false,
149
+ private?: false,
150
+ content: nil,
151
+ }],
157
152
  },
158
153
  }
159
154
  end
160
155
 
161
- # Builds the Regex portion of the config, based on the given config.
156
+ # Builds the regex portion of the config, based on the config so far.
162
157
  # @return [Hash]
163
- def build_regex_config
164
- return @hash[:csv][:regex] if @hash.dig(:csv, :regex)
165
-
166
- comment_character = Regexp.escape(@hash.deep_fetch(:csv, :comment_character))
167
- formats = @hash.deep_fetch(:item, :formats).values.join("|")
168
- dnf_string = Regexp.escape(@hash.deep_fetch(:csv, :dnf_string))
169
- compact_planned_ignored_chars = (
170
- @hash.deep_fetch(:csv, :compact_planned_ignored_chars).chars - [" "]
171
- ).join("|")
172
- time_length = /(?<time>\d+:\d\d)/
173
- pages_length = /p?(?<pages>\d+)p?/
174
- url = /https?:\/\/[^\s#{@hash.deep_fetch(:csv, :separator)}]+/
158
+ def regex_config
159
+ return @hash[:regex] if @hash.has_key?(:regex)
175
160
 
176
- isbn_lookbehind = "(?<=\\A|\\s|#{@hash.deep_fetch(:csv, :separator)})"
177
- isbn_lookahead = "(?=\\z|\\s|#{@hash.deep_fetch(:csv, :separator)})"
178
- isbn_bare_regex = /(?:\d{3}[-\s]?)?[A-Z\d]{10}/ # also includes ASIN
179
- isbn = /#{isbn_lookbehind}#{isbn_bare_regex.source}#{isbn_lookahead}/
161
+ formats = @hash.fetch(:formats).values.join("|")
180
162
 
181
163
  {
182
- compact_planned_row_start: /\A\s*#{comment_character}\s*(?:(?<genres>[^a-z@:\|]+)?\s*(?<sources>@[^\|]+)?\s*:)?\s*(?=#{formats})/,
183
- compact_planned_item: /\A(?<format_emoji>(?:#{formats}))(?<author_title>[^@\|]+)(?<sources>@.+)?(?:\|(?<sources_column>.+))?\z/,
184
164
  formats: /#{formats}/,
185
165
  formats_split: /\s*(?:,|--)?\s*(?=#{formats})/,
186
- compact_planned_ignored_chars: /#{compact_planned_ignored_chars}/,
187
- series_volume: /,\s*#(\d+)\z/,
188
- isbn: isbn,
189
- url: url,
190
- dnf: /\A\s*(#{dnf_string})/,
191
- progress: /(?<=#{dnf_string}|\A)\s*(?:(?<percent>\d?\d)%|#{time_length}|#{pages_length})\s+/,
192
- group_experience: /#{@hash.deep_fetch(:csv, :group_emoji)}\s*(.*)\s*\z/,
193
- variant_index: /\s+v(\d+)/,
194
- date: /\d{4}\/\d?\d\/\d?\d/,
195
- time_length: /\A#{time_length}(?<each>\s+each)?\z/,
196
- time_length_in_variant: time_length,
197
- pages_length: /\A#{pages_length}(?<each>\s+each)?\z/,
198
- pages_length_in_variant: /(?:\A|\s+|p)(?<pages>\d{1,9})(?:p|\s+|\z)/, # to exclude ISBN-10 and ISBN-13
199
166
  }
200
167
  end
201
168
  end
@@ -1,77 +1,24 @@
1
- require "pastel"
2
-
3
1
  module Reading
4
- # The base error class, which provides flexible error handling.
5
- class Error < StandardError
6
- using Util::StringTruncate
7
-
8
- # Handles this error based on config settings, and augments the error message
9
- # with styling and the line from the file. All this is handled here so that
10
- # the parser doesn't have to know all these things at the error's point of origin.
11
- # @param line [Reading::Line] the CSV line, through which the CSV config and
12
- # line string are accessed.
13
- def handle(line:)
14
- errors_config = line.csv.config.fetch(:errors)
15
- styled_error = styled_with_line(line.string, errors_config)
16
-
17
- handle = errors_config.fetch(:handle_error)
18
- handle.call(styled_error)
19
- end
20
-
21
- protected
22
-
23
- # Can be overridden in subclasses, e.g. yellow for a warning.
24
- def color
25
- :red
26
- end
27
-
28
- # Creates a new error having a message augmented with styling and the line string.
29
- # @return [AppError]
30
- def styled_with_line(line_string, errors_config)
31
- truncated_line = line_string.truncate(
32
- errors_config.fetch(:max_length),
33
- padding: message.length,
34
- )
35
-
36
- styled_message = case errors_config.fetch(:styling)
37
- when :terminal
38
- COLORS.send("bright_#{color}").bold(message)
39
- when :html
40
- "<rl-error class=\"#{color}\">#{message}</rl-error>"
41
- end
42
-
43
- self.class.new("#{styled_message}: #{truncated_line}")
44
- end
45
-
46
- private
47
-
48
- COLORS = Pastel.new
49
- end
50
-
51
- # FILE # # # # # # # # # # # # # # # # # # # # # # # # # #
2
+ class Error < StandardError; end
52
3
 
53
4
  # Means there was a problem accessing a file.
54
5
  class FileError < Reading::Error; end
55
6
 
56
- # MISC # # # # # # # # # # # # # # # # # # # # # # # # # #
57
-
58
- # Means the user-supplied custom config is invalid.
7
+ # Means there is something wrong with the user-supplied custom config.
59
8
  class ConfigError < Reading::Error; end
60
9
 
61
- # VALIDATION # # # # # # # # # # # # # # # # # # # # # # #
62
-
63
- # Means a date is unparsable, or a set of dates does not make logical sense.
64
- class InvalidDateError < Reading::Error; end
65
-
66
- # Means something in the Source column is invalid.
67
- class InvalidSourceError < Reading::Error; end
10
+ # Means unexpected input was encountered during parsing.
11
+ class ParsingError < Reading::Error; end
68
12
 
69
13
  # Means something in the Head column (author, title, etc.) is invalid.
70
14
  class InvalidHeadError < Reading::Error; end
71
15
 
72
- # Means the Rating column can't be parsed as a number.
73
- class InvalidRatingError < Reading::Error; end
16
+ # Means something in the History column is invalid.
17
+ class InvalidHistoryError < Reading::Error; end
18
+
19
+ # Means there are too many columns in a row.
20
+ class TooManyColumnsError < Reading::Error; end
74
21
 
75
- # Means a valid length is missing.
76
- class InvalidLengthError < Reading::Error; end
22
+ # Means a date is unparsable, or a set of dates does not make logical sense.
23
+ class InvalidDateError < Reading::Error; end
77
24
  end
@@ -0,0 +1,138 @@
1
+ module Reading
2
+ module Item
3
+ # For coercion, see https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
4
+ class TimeLength
5
+ include Comparable
6
+
7
+ attr_reader :value # in total minutes
8
+
9
+ # @param value [Numeric] the total minutes
10
+ def initialize(value)
11
+ @value = value
12
+ end
13
+
14
+ # Builds a TimeLength from a string.
15
+ # @param string [String] a time duration in "h:mm" format.
16
+ # @return [TimeLength]
17
+ def self.parse(string)
18
+ hours, minutes = string.split(':').map(&:to_i)
19
+ new((hours * 60) + minutes)
20
+ end
21
+
22
+ # Only the hours, e.g. the "h" value in "h:mm".
23
+ # @return [Numeric]
24
+ def hours
25
+ value / 60
26
+ end
27
+
28
+ # Only the hours, e.g. the "mm" value in "h:mm".
29
+ # @return [Numeric]
30
+ def minutes
31
+ value % 60
32
+ end
33
+
34
+ # A string in "h:mm" format.
35
+ # @return [String]
36
+ def to_s
37
+ "#{hours}:#{minutes}"
38
+ end
39
+
40
+ # @return [Boolean]
41
+ def zero?
42
+ value.zero?
43
+ end
44
+
45
+ # Converts @value to an Integer if it's a whole number, and returns self.
46
+ # @return [TimeLength]
47
+ def to_i_if_whole!
48
+ if @value.to_i == @value
49
+ @value = @value.to_i
50
+ end
51
+
52
+ self
53
+ end
54
+
55
+ # A non-mutating version of #to_i_if_whole! for compatibility with the
56
+ # refinement Numeric#to_i_if_whole.
57
+ # @return [TimeLength]
58
+ def to_i_if_whole
59
+ return self if @value.is_a?(Integer) || @value.to_i != @value
60
+
61
+ self.class.new(@value.to_i)
62
+ end
63
+
64
+ # TODO: addition with pages (nonzero Integer)
65
+ # @param other [TimeLength, Integer] must be zero if it's an Integer.
66
+ # @return [TimeLength]
67
+ def +(other)
68
+ if other.is_a? TimeLength
69
+ self.class.new(value + other.value)
70
+ elsif other.zero?
71
+ self
72
+ else
73
+ raise TypeError, "#{other.class} can't be added to TimeLength."
74
+ end
75
+ end
76
+
77
+ # TODO: subtraction with pages (nonzero Integer)
78
+ # @param other [TimeLength, Integer] must be zero if it's an Integer.
79
+ # @return [TimeLength]
80
+ def -(other)
81
+ if other.is_a? TimeLength
82
+ self.class.new(value - other.value)
83
+ elsif other.zero?
84
+ self
85
+ else
86
+ raise TypeError, "#{other.class} can't be subtracted from TimeLength."
87
+ end
88
+ end
89
+
90
+ # @param other [TimeLength, Numeric]
91
+ # @return [TimeLength]
92
+ def *(other)
93
+ if other.is_a? Numeric
94
+ self.class.new(value * other).to_i_if_whole!
95
+ else
96
+ raise TypeError, "TimeLength can't be multiplied by #{other.class}."
97
+ end
98
+ end
99
+
100
+ # @param other [TimeLength, Numeric]
101
+ # @return [TimeLength]
102
+ def /(other)
103
+ if other.is_a? Numeric
104
+ self.class.new(value / other).to_i_if_whole!
105
+ else
106
+ raise TypeError, "TimeLength can't be divided by #{other.class}."
107
+ end
108
+ end
109
+
110
+ # TODO: add coercion for pages (nonzero Integer)
111
+ # @param other [Integer] must be zero.
112
+ def coerce(other)
113
+ if other.zero?
114
+ [self.class.new(other), self]
115
+ else
116
+ raise TypeError, "TimeLength can't be coerced into #{other.class}."
117
+ end
118
+ end
119
+
120
+ # TODO: add comparison to pages (nonzero Integer)
121
+ # @param other [TimeLength, Integer] if Integer, must be zero.
122
+ def <=>(other)
123
+ return 1 if other.nil?
124
+
125
+ if other.zero?
126
+ return 0 if value.zero?
127
+ return 1
128
+ end
129
+
130
+ unless other.is_a? TimeLength
131
+ raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
132
+ end
133
+
134
+ value <=> other.value
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,26 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # The base class for all the attribute in parsing/attributes, each of which
5
+ # extracts an attribute from a parsed row. Together they transform the
6
+ # parsed row (an intermediate hash) into item attributes, as in
7
+ # Config#default_config[:item_template].
8
+ class Attribute
9
+ private attr_reader :config
10
+
11
+ # @param config [Hash] an entire config.
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Extracts this attribute's value from a parsed row.
17
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
18
+ # @param head_index [Integer] current item's position in the Head column.
19
+ # @return [Object]
20
+ def transform_from_parsed(parsed_row, head_index)
21
+ raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ module Reading
2
+ module Parsing
3
+ module Attributes
4
+ # Transformer for the :author item attribute.
5
+ class Author < Attribute
6
+ # @param parsed_row [Hash] a parsed row (the intermediate hash).
7
+ # @param head_index [Integer] current item's position in the Head column.
8
+ # @return [String]
9
+ def transform_from_parsed(parsed_row, head_index)
10
+ parsed_row[:head][head_index][:author]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end