reading 0.6.1 → 0.8.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 (64) 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 +96 -108
  5. data/lib/reading/errors.rb +10 -66
  6. data/lib/reading/filter.rb +95 -0
  7. data/lib/reading/item/time_length.rb +140 -0
  8. data/lib/reading/item/view.rb +121 -0
  9. data/lib/reading/item.rb +117 -0
  10. data/lib/reading/parsing/attributes/attribute.rb +26 -0
  11. data/lib/reading/parsing/attributes/author.rb +15 -0
  12. data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
  13. data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
  14. data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
  15. data/lib/reading/parsing/attributes/experiences.rb +27 -0
  16. data/lib/reading/parsing/attributes/genres.rb +16 -0
  17. data/lib/reading/parsing/attributes/notes.rb +22 -0
  18. data/lib/reading/parsing/attributes/rating.rb +17 -0
  19. data/lib/reading/parsing/attributes/shared.rb +62 -0
  20. data/lib/reading/parsing/attributes/title.rb +21 -0
  21. data/lib/reading/parsing/attributes/variants.rb +77 -0
  22. data/lib/reading/parsing/csv.rb +112 -0
  23. data/lib/reading/parsing/parser.rb +292 -0
  24. data/lib/reading/parsing/rows/column.rb +131 -0
  25. data/lib/reading/parsing/rows/comment.rb +26 -0
  26. data/lib/reading/parsing/rows/compact_planned.rb +30 -0
  27. data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
  28. data/lib/reading/parsing/rows/regular.rb +33 -0
  29. data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
  30. data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
  31. data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
  32. data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
  33. data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
  34. data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
  35. data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
  36. data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
  37. data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
  38. data/lib/reading/parsing/transformer.rb +70 -0
  39. data/lib/reading/util/hash_compact_by_template.rb +1 -0
  40. data/lib/reading/util/hash_deep_merge.rb +1 -1
  41. data/lib/reading/util/hash_to_data.rb +30 -0
  42. data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
  43. data/lib/reading/util/string_truncate.rb +13 -4
  44. data/lib/reading/version.rb +1 -1
  45. data/lib/reading.rb +49 -0
  46. metadata +76 -42
  47. data/lib/reading/attribute/all_attributes.rb +0 -83
  48. data/lib/reading/attribute/attribute.rb +0 -25
  49. data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
  50. data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
  51. data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
  52. data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
  53. data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
  54. data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
  55. data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
  56. data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
  57. data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
  58. data/lib/reading/csv.rb +0 -76
  59. data/lib/reading/line.rb +0 -23
  60. data/lib/reading/row/blank_row.rb +0 -23
  61. data/lib/reading/row/compact_planned_row.rb +0 -130
  62. data/lib/reading/row/regular_row.rb +0 -99
  63. data/lib/reading/row/row.rb +0 -88
  64. data/lib/reading/util/hash_to_struct.rb +0 -29
@@ -1,130 +0,0 @@
1
- require_relative "row"
2
- require "debug"
3
-
4
- module Reading
5
- # Parses a row of compactly listed planned items into an array of hashes of
6
- # item data.
7
- class CompactPlannedRow < Row
8
- using Util::StringRemove
9
- using Util::HashDeepMerge
10
- using Util::HashArrayDeepFetch
11
-
12
- # Whether the given CSV line is a compact planned row.
13
- # @param line [Reading::Line]
14
- # @return [Boolean]
15
- def self.match?(line)
16
- comment_char = line.csv.config.deep_fetch(:csv, :comment_character)
17
-
18
- line.string.strip.start_with?(comment_char) &&
19
- line.string.match?(line.csv.config.deep_fetch(:csv, :regex, :compact_planned_row_start))
20
- end
21
-
22
- private
23
-
24
- def skip?
25
- config.deep_fetch(:csv, :skip_compact_planned)
26
- end
27
-
28
- def before_parse
29
- to_ignore = config.deep_fetch(:csv, :regex, :compact_planned_ignored_chars)
30
- start_regex = config.deep_fetch(:csv, :regex, :compact_planned_row_start)
31
-
32
- string_without_ignored_chars = string.remove_all(to_ignore)
33
- start = string_without_ignored_chars.match(start_regex)
34
-
35
- @genres = Array(start[:genres]&.downcase&.strip&.split(",")&.map(&:strip))
36
- @sources = sources(start[:sources])
37
- @row_without_genre = string_without_ignored_chars.remove(start.to_s)
38
- end
39
-
40
- def string_to_be_split_by_format_emojis
41
- @row_without_genre
42
- end
43
-
44
- def item_hash(item_head)
45
- item_match = item_head.match(config.deep_fetch(:csv, :regex, :compact_planned_item))
46
- unless item_match
47
- raise InvalidHeadError, "Title missing after #{item_head} in compact planned row"
48
- end
49
-
50
- author = AuthorAttribute.new(item_head: item_match[:author_title], config:).parse
51
-
52
- begin
53
- title = TitleAttribute.new(item_head: item_match[:author_title], config:).parse
54
- rescue InvalidHeadError
55
- raise InvalidHeadError, "Title missing after #{item_head} in compact planned row"
56
- end
57
-
58
- if item_match[:sources_column]
59
- if item_match[:sources_column].include?(config.deep_fetch(:csv, :column_separator))
60
- raise TooManyColumnsError, "Too many columns (only Sources allowed) " \
61
- "after #{item_head} in compact planned row"
62
- end
63
-
64
- variants_attr = VariantsAttribute.new(
65
- item_head: item_match[:format_emoji] + item_match[:author_title],
66
- columns: { sources: item_match[:sources_column], length: nil },
67
- config:,
68
- )
69
- variants = variants_attr.parse
70
- else
71
- variants = [parse_variant(item_match)]
72
- end
73
-
74
- template.deep_merge(
75
- author: author || template.fetch(:author),
76
- title: title,
77
- genres: @genres.presence || template.fetch(:genres),
78
- variants:,
79
- )
80
- end
81
-
82
- def template
83
- @template ||= config.deep_fetch(:item, :template)
84
- end
85
-
86
- def parse_variant(item_match)
87
- item_head = item_match[:format_emoji] + item_match[:author_title]
88
- series_attr = SeriesSubattribute.new(item_head:, config:)
89
- extra_info_attr = ExtraInfoSubattribute.new(item_head:, config:)
90
- sources = (@sources + sources(item_match[:sources])).uniq.presence
91
-
92
- {
93
- format: format(item_match[:format_emoji]),
94
- series: series_attr.parse_head || template.deep_fetch(:variants, 0, :series),
95
- sources: sources || template.deep_fetch(:variants, 0, :sources),
96
- isbn: template.deep_fetch(:variants, 0, :isbn),
97
- length: template.deep_fetch(:variants, 0, :length),
98
- extra_info: extra_info_attr.parse_head || template.deep_fetch(:variants, 0, :extra_info),
99
- }
100
- end
101
-
102
- def format(format_emoji)
103
- config.deep_fetch(:item, :formats).key(format_emoji)
104
- end
105
-
106
- def sources(sources_str)
107
- return [] if sources_str.nil?
108
-
109
- sources_str
110
- .split(config.deep_fetch(:csv, :compact_planned_source_prefix))
111
- .map { |source| source.remove(/\s*,\s*/) }
112
- .map(&:strip)
113
- .reject(&:empty?)
114
- .map { |source_name|
115
- if valid_url?(source_name)
116
- source_name = source_name.chop if source_name.chars.last == "/"
117
- { name: config.deep_fetch(:item, :sources, :default_name_for_url),
118
- url: source_name }
119
- else
120
- { name: source_name,
121
- url: nil }
122
- end
123
- }
124
- end
125
-
126
- def valid_url?(str)
127
- str&.match?(/http[^\s,]+/)
128
- end
129
- end
130
- end
@@ -1,99 +0,0 @@
1
- require_relative "row"
2
- require_relative "../attribute/all_attributes"
3
-
4
- module Reading
5
- # Parses a normal CSV row into an array of hashes of item data. Typically
6
- # a normal row describes one item and so it's parsed into an array containing
7
- # a single hash, but it's also possible for a row to describe multiple items.
8
- class RegularRow < Row
9
- using Util::HashArrayDeepFetch
10
-
11
- private attr_reader :columns, :attribute_classes
12
-
13
- private
14
-
15
- def after_initialize
16
- set_attribute_classes
17
- end
18
-
19
- def before_parse
20
- set_columns
21
- ensure_head_column_present
22
- end
23
-
24
- def string_to_be_split_by_format_emojis
25
- columns[:head]
26
- end
27
-
28
- def set_attribute_classes
29
- @attribute_classes ||= config.deep_fetch(:item, :template).map { |attribute_name, _default|
30
- attribute_name_camelcase = attribute_name.to_s.split("_").map(&:capitalize).join
31
- attribute_class_name = "#{attribute_name_camelcase}Attribute"
32
- attribute_class = self.class.const_get(attribute_class_name)
33
-
34
- [attribute_name, attribute_class]
35
- }.to_h
36
- .merge(custom_attribute_classes)
37
- end
38
-
39
- def custom_attribute_classes
40
- numeric = custom_attribute_classes_of_type(:numeric) do |value|
41
- Float(value, exception: false)
42
- end
43
-
44
- text = custom_attribute_classes_of_type(:text) do |value|
45
- value
46
- end
47
-
48
- (numeric + text).to_h
49
- end
50
-
51
- def custom_attribute_classes_of_type(type, &process_value)
52
- config.deep_fetch(:csv, :"custom_#{type}_columns").map { |attribute, _default_value|
53
- custom_class = Class.new(Attribute)
54
-
55
- custom_class.define_method(:parse) do
56
- value = columns[attribute.to_sym]&.strip&.presence
57
- process_value.call(value)
58
- end
59
-
60
- [attribute.to_sym, custom_class]
61
- }
62
- end
63
-
64
- def set_columns
65
- column_names = config.deep_fetch(:csv, :enabled_columns) +
66
- config.deep_fetch(:csv, :custom_numeric_columns).keys +
67
- config.deep_fetch(:csv, :custom_text_columns).keys
68
-
69
- columns_count = string.count(config.deep_fetch(:csv, :column_separator))
70
- if columns_count >= column_names.count
71
- raise TooManyColumnsError, "Too many columns"
72
- end
73
-
74
- column_contents = string.split(config.deep_fetch(:csv, :column_separator))
75
-
76
- @columns = column_names.zip(column_contents).to_h
77
- end
78
-
79
- def ensure_head_column_present
80
- if columns[:head].nil? || columns[:head].strip.empty?
81
- raise InvalidHeadError, "The Head column must not be blank"
82
- end
83
- end
84
-
85
- def item_hash(item_head)
86
- config
87
- .deep_fetch(:item, :template)
88
- .merge(config.deep_fetch(:csv, :custom_numeric_columns))
89
- .merge(config.deep_fetch(:csv, :custom_text_columns))
90
- .map { |attribute_name, default_value|
91
- attribute_class = attribute_classes.fetch(attribute_name)
92
- attribute_parser = attribute_class.new(item_head:, columns:, config:)
93
- parsed = attribute_parser.parse
94
-
95
- [attribute_name, parsed || default_value]
96
- }.to_h
97
- end
98
- end
99
- end
@@ -1,88 +0,0 @@
1
- module Reading
2
- # A base class that contains behaviors common to ___Row classes.
3
- class Row
4
- using Util::StringRemove
5
- using Util::HashArrayDeepFetch
6
- using Util::HashCompactByTemplate
7
-
8
- private attr_reader :line
9
-
10
- # @param line [Reading::Line] the Line that this Row represents.
11
- def initialize(line)
12
- @line = line
13
-
14
- after_initialize
15
- end
16
-
17
- # Parses a CSV row into an array of hashes of item data. How this is done
18
- # depends on how the template methods (further below) are implemented in
19
- # subclasses of Row.
20
- # @return [Array<Hash>] an array of hashes like the template in config.rb
21
- def parse
22
- return [] if skip?
23
-
24
- before_parse
25
-
26
- items = item_heads.map { |item_head|
27
- item_hash(item_head)
28
- .compact_by(template: config.deep_fetch(:item, :template))
29
- }.compact
30
-
31
- items
32
-
33
- rescue Reading::Error => e
34
- e.handle(line:)
35
- []
36
- end
37
-
38
- private
39
-
40
- def string
41
- @line.string
42
- end
43
-
44
- def config
45
- @line.csv.config
46
- end
47
-
48
- # A "head" is a string in the Head column containing a chunk of item
49
- # information, starting with a format emoji. A typical row describes one
50
- # item and so contains one head, but a row describing multiple items (with
51
- # multiple heads in the Head column) is possible. Also, a row of compact
52
- # planned items is essentially a list of heads, though with different
53
- # elements than a normal row's head.
54
- # @return [Array<String>]
55
- def item_heads
56
- string_to_be_split_by_format_emojis
57
- .split(config.deep_fetch(:csv, :regex, :formats_split))
58
- .tap { |item_heads|
59
- item_heads.first.remove!(config.deep_fetch(:csv, :regex, :dnf))
60
- item_heads.first.remove!(config.deep_fetch(:csv, :regex, :progress))
61
- }
62
- .map { |item_head| item_head.strip }
63
- .partition { |item_head| item_head.match?(/\A#{config.deep_fetch(:csv, :regex, :formats)}/) }
64
- .reject(&:empty?)
65
- .first
66
- end
67
-
68
- # Below: template methods that can (or must) be overridden.
69
-
70
- def after_initialize
71
- end
72
-
73
- def before_parse
74
- end
75
-
76
- def skip?
77
- false
78
- end
79
-
80
- def string_to_be_split_by_format_emojis
81
- raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
82
- end
83
-
84
- def item_hash(item_head)
85
- raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
86
- end
87
- end
88
- end
@@ -1,29 +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
- def to_struct
7
- MEMOIZED_STRUCTS[keys] ||= Struct.new(*keys)
8
- struct_class = MEMOIZED_STRUCTS[keys]
9
-
10
- struct_values = transform_values { |v|
11
- if v.is_a?(Hash)
12
- v.to_struct
13
- elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
14
- v.map(&:to_struct)
15
- else
16
- v
17
- end
18
- }.values
19
-
20
- struct_class.new(*struct_values)
21
- end
22
- end
23
-
24
- private
25
-
26
- MEMOIZED_STRUCTS = {}
27
- end
28
- end
29
- end