reading 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/reading +5 -5
  3. data/bin/readingfile +31 -0
  4. data/lib/reading/config.rb +115 -149
  5. data/lib/reading/errors.rb +10 -66
  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 -76
  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 -99
  60. data/lib/reading/row/row.rb +0 -88
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4145847e60a6045a1381afe207be44d30636ca7b058d4c1c1e0db70009af0b4
4
- data.tar.gz: 833b042be4b575e4ed166d0269610504c031d03460a0f75c4dedc214b1eaf152
3
+ metadata.gz: 531b4f54f11eed2f638079efbc0542977287d3ca887bea04a0785b6ca10c45fe
4
+ data.tar.gz: 58881344b75fc84041275d3ad9b84955f03d53ff8ca35f09932509b584c8b218
5
5
  SHA512:
6
- metadata.gz: '09f08e0be8d6cd97481097d4193209af3cda404e076f2d5529c7c4bdee5f146afbf980a2cb05f2435c06633b89954067ee8bea9d02dae4eddd924f86626a9f63'
7
- data.tar.gz: 3a50b075c63f2a8b0082b309bef201a8351344c729e90b18482b5796a53bedee1af7fad71af3a0dd884d1245440cfd14dbf6d28aa38bdf5d09717059bd22e7f8
6
+ metadata.gz: 3539806e8c4472ba98d1ac19c862989a21d25782e4ec94ac04a3dfd4a3f30509187dce4ee6fa86fc189f742e19e2e649ffcf94f5e539cc1bc576856748b9adf6
7
+ data.tar.gz: 1be98e8aa5fc04a87aae46832cc981a49d3b725eeeaee2c6b75f1d723cf907d2e6567b9474022a5b19bc113db3555a7d358ec924c469f269d361b440d1963ca2
data/bin/reading CHANGED
@@ -4,15 +4,16 @@
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
10
  # reading '3|📕Trying|Little Library 1970147288'
11
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
@@ -22,10 +23,9 @@ end
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,182 +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
- notes
137
- history
138
- ],
139
- # Custom columns are listed in a hash with default values, like simple columns in item[:template] above.
140
- custom_numeric_columns: {}, # e.g. { family_friendliness: 5, surprise_factor: nil }
141
- custom_text_columns: {}, # e.g. { mood: nil, rec_by: nil, will_reread: "no" }
142
- comment_character: "\\",
143
- column_separator: "|",
144
- separator: ",",
145
- short_separator: " - ",
146
- long_separator: " -- ",
147
- date_separator: "/",
148
- date_range_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)}]+/
175
- date_sep = @hash.deep_fetch(:csv, :date_separator)
158
+ def regex_config
159
+ return @hash[:regex] if @hash.has_key?(:regex)
176
160
 
177
- isbn_lookbehind = "(?<=\\A|\\s|#{@hash.deep_fetch(:csv, :separator)})"
178
- isbn_lookahead = "(?=\\z|\\s|#{@hash.deep_fetch(:csv, :separator)})"
179
- isbn_bare_regex = /(?:\d{3}[-\s]?)?[A-Z\d]{10}/ # also includes ASIN
180
- isbn = /#{isbn_lookbehind}#{isbn_bare_regex.source}#{isbn_lookahead}/
161
+ formats = @hash.fetch(:formats).values.join("|")
181
162
 
182
163
  {
183
- compact_planned_row_start: /\A\s*#{comment_character}\s*(?:(?<genres>[^a-z@:\|]+)?\s*(?<sources>@[^\|]+)?\s*:)?\s*(?=#{formats})/,
184
- compact_planned_item: /\A(?<format_emoji>(?:#{formats}))(?<author_title>[^@\|]+)(?<sources>@.+)?(?:\|(?<sources_column>.+))?\z/,
185
164
  formats: /#{formats}/,
186
165
  formats_split: /\s*(?:,|--)?\s*(?=#{formats})/,
187
- compact_planned_ignored_chars: /#{compact_planned_ignored_chars}/,
188
- series_volume: /,\s*#(\d+)\z/,
189
- isbn: isbn,
190
- url: url,
191
- dnf: /\A\s*(#{dnf_string})/,
192
- progress: /(?<=#{dnf_string}|\A)\s*(?:(?<percent>\d?\d)%|#{time_length}|#{pages_length})\s+/,
193
- group_experience: /#{@hash.deep_fetch(:csv, :group_emoji)}\s*(.*)\s*\z/,
194
- variant_index: /\s+v(\d+)/,
195
- date: /\d{4}#{date_sep}\d?\d#{date_sep}\d?\d/,
196
- time_length: /\A#{time_length}(?<each>\s+each)?\z/,
197
- time_length_in_variant: time_length,
198
- pages_length: /\A#{pages_length}(?<each>\s+each)?\z/,
199
- pages_length_in_variant: /(?:\A|\s+|p)(?<pages>\d{1,9})(?:p|\s+|\z)/, # to exclude ISBN-10 and ISBN-13
200
166
  }
201
167
  end
202
168
  end
@@ -1,80 +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 # # # # # # # # # # # # # # # # # # # # # # #
10
+ # Means unexpected input was encountered during parsing.
11
+ class ParsingError < Reading::Error; end
12
+
13
+ # Means something in the Head column (author, title, etc.) is invalid.
14
+ class InvalidHeadError < Reading::Error; end
15
+
16
+ # Means something in the History column is invalid.
17
+ class InvalidHistoryError < Reading::Error; end
62
18
 
63
19
  # Means there are too many columns in a row.
64
20
  class TooManyColumnsError < Reading::Error; end
65
21
 
66
22
  # Means a date is unparsable, or a set of dates does not make logical sense.
67
23
  class InvalidDateError < Reading::Error; end
68
-
69
- # Means something in the Source column is invalid.
70
- class InvalidSourceError < Reading::Error; end
71
-
72
- # Means something in the Head column (author, title, etc.) is invalid.
73
- class InvalidHeadError < Reading::Error; end
74
-
75
- # Means the Rating column can't be parsed as a number.
76
- class InvalidRatingError < Reading::Error; end
77
-
78
- # Means a valid length is missing.
79
- class InvalidLengthError < Reading::Error; end
80
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