reading 0.7.0 โ 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bin/reading +1 -1
- data/bin/readingfile +4 -4
- data/lib/reading/config.rb +70 -48
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +11 -9
- data/lib/reading/item/view.rb +121 -0
- data/lib/reading/item.rb +117 -0
- data/lib/reading/parsing/attributes/attribute.rb +1 -1
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +3 -3
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +5 -5
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +1 -1
- data/lib/reading/parsing/attributes/experiences.rb +1 -1
- data/lib/reading/parsing/attributes/notes.rb +1 -1
- data/lib/reading/parsing/attributes/shared.rb +2 -2
- data/lib/reading/parsing/attributes/variants.rb +2 -2
- data/lib/reading/parsing/csv.rb +27 -16
- data/lib/reading/parsing/parser.rb +2 -2
- data/lib/reading/parsing/transformer.rb +4 -4
- data/lib/reading/util/hash_to_data.rb +30 -0
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +34 -3
- metadata +20 -3
- data/lib/reading/util/hash_to_struct.rb +0 -30
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ecba33e4bbdb2dd11482113bf46d6196b17c89e2274e574b2286818f309c9ccb
|
4
|
+
data.tar.gz: 95d094b19fd4509e8608f5db414062e633ba6665c949526cd34c271314db6845
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ccfaace79d20ab57ab73349732735858049f217f3d0ad86408ae49b0f0f20591032dffd30e30d3f64d8c2644561d0b68e609e07398f3923fc85f4d25421504a6
|
7
|
+
data.tar.gz: 578c7f8389d12eda16a30f2d702a944a92dd931e62fa4db4908521ca1d1a01ca0fa0100d96b32f6e624fec1e7abf0f907417fc3043ce0aabe605fdde5b2c181c
|
data/bin/reading
CHANGED
data/bin/readingfile
CHANGED
@@ -7,8 +7,8 @@
|
|
7
7
|
# reading "<file path>" "<optional comma-separated names of enabled columns>"
|
8
8
|
#
|
9
9
|
# Examples:
|
10
|
-
# reading '/home/
|
11
|
-
# reading '/home/
|
10
|
+
# reading '/home/felipe/reading.csv'
|
11
|
+
# reading '/home/felipe/reading.csv' 'head, sources'
|
12
12
|
|
13
13
|
|
14
14
|
require_relative "../lib/reading"
|
@@ -17,7 +17,7 @@ require "debug"
|
|
17
17
|
|
18
18
|
path = ARGV[0]
|
19
19
|
unless path
|
20
|
-
raise ArgumentError, "CSV path argument required, such as '/home/
|
20
|
+
raise ArgumentError, "CSV path argument required, such as '/home/felipe/reading.csv'"
|
21
21
|
end
|
22
22
|
|
23
23
|
config = {}
|
@@ -26,6 +26,6 @@ if ARGV[1]
|
|
26
26
|
config = { enabled_columns: }
|
27
27
|
end
|
28
28
|
|
29
|
-
items = Reading.parse(path, config:)
|
29
|
+
items = Reading.parse(path, config:, hash_output: true)
|
30
30
|
|
31
31
|
ap items
|
data/lib/reading/config.rb
CHANGED
@@ -94,61 +94,83 @@ module Reading
|
|
94
94
|
"librivox.org" => "LibriVox",
|
95
95
|
"tv.apple.com" => "Apple TV",
|
96
96
|
},
|
97
|
-
|
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:
|
97
|
+
item:
|
111
98
|
{
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
99
|
+
# After how many days of no activity an item of indefinite length
|
100
|
+
# (e.g. a podcast) should change its status from :in_progress to :done.
|
101
|
+
indefinite_in_progress_grace_period_days: 30,
|
102
|
+
view:
|
103
|
+
{
|
104
|
+
name_separator: " ใ ",
|
105
|
+
url_from_isbn: "https://www.goodreads.com/book/isbn?isbn=%{isbn}",
|
106
|
+
# Items rated this or above get a star. If nil, number ratings are shown instead.
|
107
|
+
minimum_rating_for_star: 5,
|
108
|
+
types:
|
109
|
+
{
|
110
|
+
book: { emoji: "๐", from_formats: %i[print ebook audiobook pdf] },
|
111
|
+
course: { emoji: "๐ซ", from_formats: %i[website] },
|
112
|
+
piece: { emoji: "โ๏ธ" },
|
113
|
+
video: { emoji: "๐๏ธ" },
|
114
|
+
audio: { emoji: "๐ค" },
|
115
|
+
},
|
116
|
+
default_type: :book,
|
117
|
+
},
|
118
|
+
# The structure of an item, along with default values.
|
119
|
+
# Wherever an array of hashes ends up with no data (i.e. equal to the
|
120
|
+
# value in the template), it is collapsed into an empty array.
|
121
|
+
# E.g. the row "|Dracula||๐ค๐ผbook club" is parsed to an Item analogous to:
|
122
|
+
# {
|
123
|
+
# rating: nil,
|
124
|
+
# author: nil,
|
125
|
+
# title: "Dracula",
|
126
|
+
# genres: [],
|
127
|
+
# variants: [],
|
128
|
+
# experiences: [{ spans: [], group: "book club", variant_index: 0 }],
|
129
|
+
# notes: [],
|
130
|
+
# }
|
131
|
+
template:
|
132
|
+
{
|
133
|
+
rating: nil,
|
134
|
+
author: nil,
|
135
|
+
title: nil,
|
136
|
+
genres: [],
|
137
|
+
variants:
|
120
138
|
[{
|
121
|
-
|
122
|
-
|
139
|
+
format: nil,
|
140
|
+
series:
|
141
|
+
[{
|
142
|
+
name: nil,
|
143
|
+
volume: nil,
|
144
|
+
}],
|
145
|
+
sources:
|
146
|
+
[{
|
147
|
+
name: nil,
|
148
|
+
url: nil,
|
149
|
+
}],
|
150
|
+
isbn: nil,
|
151
|
+
length: nil,
|
152
|
+
extra_info: [],
|
123
153
|
}],
|
124
|
-
|
154
|
+
experiences:
|
125
155
|
[{
|
126
|
-
|
127
|
-
|
156
|
+
spans:
|
157
|
+
[{
|
158
|
+
dates: nil,
|
159
|
+
amount: 0,
|
160
|
+
progress: nil,
|
161
|
+
name: nil,
|
162
|
+
favorite?: false,
|
163
|
+
}],
|
164
|
+
group: nil,
|
165
|
+
variant_index: 0,
|
128
166
|
}],
|
129
|
-
|
130
|
-
length: nil,
|
131
|
-
extra_info: [],
|
132
|
-
}],
|
133
|
-
experiences:
|
134
|
-
[{
|
135
|
-
spans:
|
167
|
+
notes:
|
136
168
|
[{
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
name: nil,
|
141
|
-
favorite?: false,
|
169
|
+
blurb?: false,
|
170
|
+
private?: false,
|
171
|
+
content: nil,
|
142
172
|
}],
|
143
|
-
|
144
|
-
variant_index: 0,
|
145
|
-
}],
|
146
|
-
notes:
|
147
|
-
[{
|
148
|
-
blurb?: false,
|
149
|
-
private?: false,
|
150
|
-
content: nil,
|
151
|
-
}],
|
173
|
+
},
|
152
174
|
},
|
153
175
|
}
|
154
176
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Reading
|
2
|
+
# Filters Items based on given criteria.
|
3
|
+
class Filter
|
4
|
+
class << self
|
5
|
+
# Filters Items based on given criteria, and returns them sorted by last
|
6
|
+
# end date or (where there is none) status, where :planned Items are
|
7
|
+
# placed last, and :in_progress just before those.
|
8
|
+
# @param items [Array<Item>]
|
9
|
+
# @param no_sort [Boolean] to preserve the original ordering of the Items.
|
10
|
+
# @param criteria [Hash] one or more of the filters defined in by_x methods below.
|
11
|
+
# @return [Array<Item>]
|
12
|
+
# @raise [ArgumentError] if criteria are invalid or missing.
|
13
|
+
def by(items:, no_sort: false, **criteria)
|
14
|
+
validate_criteria(**criteria)
|
15
|
+
|
16
|
+
filtered = criteria.each.with_object(items.dup) { |(criterion, arg), filtered_items|
|
17
|
+
send("#{CRITERIA_PREFIX}#{criterion}#{CRITERIA_SUFFIX}", filtered_items, arg)
|
18
|
+
}
|
19
|
+
|
20
|
+
return filtered if no_sort
|
21
|
+
|
22
|
+
filtered.sort_by { |item|
|
23
|
+
if item.done?
|
24
|
+
item.last_end_date.strftime("%Y-%m-%d")
|
25
|
+
else
|
26
|
+
item.status.to_s
|
27
|
+
end
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
CRITERIA_PREFIX = "by_".freeze
|
34
|
+
CRITERIA_SUFFIX = "!".freeze
|
35
|
+
|
36
|
+
# Checks that the args match real Filter criteria.
|
37
|
+
# @param criteria [Hash] must include only one or more of the criteria
|
38
|
+
# defined in by_x methods below.
|
39
|
+
# @raise [ArgumentError] if criteria are empty or invalid.
|
40
|
+
def validate_criteria(**criteria)
|
41
|
+
available_criteria = private_methods(false)
|
42
|
+
.select { _1.to_s.start_with?(CRITERIA_PREFIX) }
|
43
|
+
.map { _1.to_s.delete_prefix(CRITERIA_PREFIX).delete_suffix(CRITERIA_SUFFIX).to_sym }
|
44
|
+
|
45
|
+
if criteria.empty?
|
46
|
+
raise ArgumentError, "Filter requires at least one of these criteria: #{available_criteria}"
|
47
|
+
end
|
48
|
+
|
49
|
+
unrecognized_criteria = criteria.keys - available_criteria
|
50
|
+
if unrecognized_criteria.any?
|
51
|
+
raise ArgumentError, "Unrecognized criteria passed to Filter: #{unrecognized_criteria}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Mutates the given array of Items to select only Items with a rating
|
56
|
+
# greater than or equal to the given minimum.
|
57
|
+
# @param items [Array<Item>]
|
58
|
+
# @param minimum_rating [Integer]
|
59
|
+
def by_minimum_rating!(items, minimum_rating)
|
60
|
+
return items unless minimum_rating
|
61
|
+
|
62
|
+
items.select! do |item|
|
63
|
+
if item.rating
|
64
|
+
item.rating >= minimum_rating
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Mutates the given array of Items to exclude Items with genres including
|
70
|
+
# any of the given genres.
|
71
|
+
# @param items [Array<Item>]
|
72
|
+
# @param excluded_genres [Array<String>]
|
73
|
+
def by_excluded_genres!(items, excluded_genres)
|
74
|
+
return items unless excluded_genres&.any?
|
75
|
+
|
76
|
+
items.select! do |item|
|
77
|
+
overlapping = item.genres & excluded_genres
|
78
|
+
overlapping.empty?
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Mutates the given array of Items to select only Items with a status
|
83
|
+
# equal to the given status (or one of the given statuses).
|
84
|
+
# @param items [Array<Item>]
|
85
|
+
# @param statuses [Symbol, Array<Symbol>]
|
86
|
+
def by_status!(items, statuses)
|
87
|
+
statuses = Array(statuses)
|
88
|
+
|
89
|
+
items.select! do |item|
|
90
|
+
statuses.include? item.status
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -1,7 +1,8 @@
|
|
1
1
|
module Reading
|
2
|
-
|
3
|
-
#
|
4
|
-
|
2
|
+
class Item
|
3
|
+
# The length of an item when it is a time, as opposed to pages. (Pages are
|
4
|
+
# represented simply with an Integer.)
|
5
|
+
class Item::TimeLength
|
5
6
|
include Comparable
|
6
7
|
|
7
8
|
attr_reader :value # in total minutes
|
@@ -11,7 +12,7 @@ module Reading
|
|
11
12
|
@value = value
|
12
13
|
end
|
13
14
|
|
14
|
-
# Builds
|
15
|
+
# Builds an Item::TimeLength from a string.
|
15
16
|
# @param string [String] a time duration in "h:mm" format.
|
16
17
|
# @return [TimeLength]
|
17
18
|
def self.parse(string)
|
@@ -65,12 +66,12 @@ module Reading
|
|
65
66
|
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
66
67
|
# @return [TimeLength]
|
67
68
|
def +(other)
|
68
|
-
if other.is_a? TimeLength
|
69
|
+
if other.is_a? Item::TimeLength
|
69
70
|
self.class.new(value + other.value)
|
70
71
|
elsif other.zero?
|
71
72
|
self
|
72
73
|
else
|
73
|
-
raise TypeError, "#{other.class} can't be added to TimeLength."
|
74
|
+
raise TypeError, "#{other.class} can't be added to Item::TimeLength."
|
74
75
|
end
|
75
76
|
end
|
76
77
|
|
@@ -78,12 +79,12 @@ module Reading
|
|
78
79
|
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
79
80
|
# @return [TimeLength]
|
80
81
|
def -(other)
|
81
|
-
if other.is_a? TimeLength
|
82
|
+
if other.is_a? Item::TimeLength
|
82
83
|
self.class.new(value - other.value)
|
83
84
|
elsif other.zero?
|
84
85
|
self
|
85
86
|
else
|
86
|
-
raise TypeError, "#{other.class} can't be subtracted from TimeLength."
|
87
|
+
raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
|
87
88
|
end
|
88
89
|
end
|
89
90
|
|
@@ -108,6 +109,7 @@ module Reading
|
|
108
109
|
end
|
109
110
|
|
110
111
|
# TODO: add coercion for pages (nonzero Integer)
|
112
|
+
# See https://www.mutuallyhuman.com/blog/class-coercion-in-ruby
|
111
113
|
# @param other [Integer] must be zero.
|
112
114
|
def coerce(other)
|
113
115
|
if other.zero?
|
@@ -127,7 +129,7 @@ module Reading
|
|
127
129
|
return 1
|
128
130
|
end
|
129
131
|
|
130
|
-
unless other.is_a? TimeLength
|
132
|
+
unless other.is_a? Item::TimeLength
|
131
133
|
raise TypeError, "TimeLength can't be compared to #{other.class} #{other}."
|
132
134
|
end
|
133
135
|
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Reading
|
2
|
+
class Item
|
3
|
+
# A view object for an Item, providing shortcuts to information that is handy
|
4
|
+
# to show (for example) on a webpage.
|
5
|
+
class View
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
attr_reader :name, :rating, :type_emoji, :genres, :date_or_status,
|
9
|
+
:isbn, :url, :experience_count, :groups, :blurb, :public_notes
|
10
|
+
|
11
|
+
# @param item [Item] the Item from which to extract view information.
|
12
|
+
# @param config [Hash] an entire config.
|
13
|
+
def initialize(item, config)
|
14
|
+
@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)
|
19
|
+
@date_or_status = extract_date_or_status(item)
|
20
|
+
@experience_count = item.experiences.count
|
21
|
+
@groups = item.experiences.map(&:group).compact
|
22
|
+
@blurb = item.notes.find(&:blurb?)&.content
|
23
|
+
@public_notes = item.notes.reject(&:private?).reject(&:blurb?).map(&:content)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# A star (or nil if the item doesn't make the cut), or the number rating if
|
29
|
+
# star ratings are disabled.
|
30
|
+
# @param item [Item]
|
31
|
+
# @param config [Hash] an entire config.
|
32
|
+
# @return [String, Integer, Float]
|
33
|
+
def extract_star_or_rating(item, config)
|
34
|
+
minimum_rating = config.deep_fetch(:item, :view, :minimum_rating_for_star)
|
35
|
+
if minimum_rating
|
36
|
+
"โญ" if item.rating && item.rating >= minimum_rating
|
37
|
+
else
|
38
|
+
item.rating
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
# The ISBN/ASIN, URL, format, and extra info of the first variant that has
|
44
|
+
# an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
|
45
|
+
# Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
|
46
|
+
# @param item [Item]
|
47
|
+
# @param config [Hash] an entire config.
|
48
|
+
# @return [Array(String, String, Symbol, Array<String>)]
|
49
|
+
def extract_first_source_info(item, config)
|
50
|
+
item.variants.map { |variant|
|
51
|
+
isbn = variant.isbn
|
52
|
+
if isbn
|
53
|
+
url = config.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
|
54
|
+
else
|
55
|
+
url = variant.sources.map { |source| source.url }.compact.first
|
56
|
+
end
|
57
|
+
|
58
|
+
[isbn, url, variant]
|
59
|
+
}
|
60
|
+
.select { |isbn, url, _variant| isbn || url }
|
61
|
+
.first || [nil, nil, item.variants.first]
|
62
|
+
end
|
63
|
+
|
64
|
+
# The view name of the item.
|
65
|
+
# @param item [Item]
|
66
|
+
# @param variant [Data, nil] a variant from the Item.
|
67
|
+
# @param config [Hash] an entire config.
|
68
|
+
# @return [String]
|
69
|
+
def extract_name(item, variant, config)
|
70
|
+
author_and_title = "#{item.author + " โ " if item.author}#{item.title}"
|
71
|
+
return author_and_title if variant.nil?
|
72
|
+
|
73
|
+
unless variant.series.empty? && variant.extra_info.empty?
|
74
|
+
pretty_series = variant.series.map { |series|
|
75
|
+
if series.volume
|
76
|
+
"#{series.name}, ##{series.volume}"
|
77
|
+
else
|
78
|
+
"in #{series.name}"
|
79
|
+
end
|
80
|
+
}
|
81
|
+
|
82
|
+
name_separator = config.deep_fetch(:item, :view, :name_separator)
|
83
|
+
series_and_extra_info = name_separator +
|
84
|
+
(pretty_series + variant.extra_info).join(name_separator)
|
85
|
+
end
|
86
|
+
|
87
|
+
author_and_title + (series_and_extra_info || "")
|
88
|
+
end
|
89
|
+
|
90
|
+
# The emoji for the type that represents (encompasses) a given format.
|
91
|
+
# @param format [Symbol, nil]
|
92
|
+
# @param config [Hash] an entire config.
|
93
|
+
# @return [String]
|
94
|
+
def extract_type_emoji(format, config)
|
95
|
+
types = config.deep_fetch(:item, :view, :types)
|
96
|
+
|
97
|
+
return types.deep_fetch(format, :emoji) if types.has_key?(format)
|
98
|
+
|
99
|
+
type = types
|
100
|
+
.find { |type, hash| hash[:from_formats]&.include?(format) }
|
101
|
+
&.first # key
|
102
|
+
|
103
|
+
types.deep_fetch(
|
104
|
+
type || config.deep_fetch(:item, :view, :default_type),
|
105
|
+
:emoji,
|
106
|
+
)
|
107
|
+
end
|
108
|
+
|
109
|
+
# The date (if done) or status, stringified.
|
110
|
+
# @param item [Item]
|
111
|
+
# @return [String]
|
112
|
+
def extract_date_or_status(item)
|
113
|
+
if item.done?
|
114
|
+
item.last_end_date&.strftime("%Y-%m-%d")
|
115
|
+
else
|
116
|
+
item.status.to_s.gsub('_', ' ')
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
data/lib/reading/item.rb
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
|
3
|
+
require_relative "item/view"
|
4
|
+
require_relative "config"
|
5
|
+
require_relative "util/hash_to_data"
|
6
|
+
require_relative "util/hash_array_deep_fetch"
|
7
|
+
|
8
|
+
module Reading
|
9
|
+
# A wrapper for an item parsed from a CSV reading log, providing convenience
|
10
|
+
# methods beyond what the parser's raw Hash output can provide.
|
11
|
+
class Item
|
12
|
+
using Util::HashToData
|
13
|
+
using Util::HashArrayDeepFetch
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
ATTRIBUTES = %i[rating author title genres variants experiences notes]
|
17
|
+
|
18
|
+
private attr_reader :attributes, :config
|
19
|
+
attr_reader :view, :status, :last_end_date
|
20
|
+
|
21
|
+
def_delegators :attributes, *ATTRIBUTES
|
22
|
+
|
23
|
+
# @param item_hash [Hash] a parsed item like the template in
|
24
|
+
# Config#default_config[:item][:template].
|
25
|
+
# @param config [Hash] an entire config.
|
26
|
+
# @param view [Class, nil, Boolean] the class that will be used to build the
|
27
|
+
# view object, or nil/false if no view object should be built. If you use
|
28
|
+
# a custom view class, the only requirement is that its #initialize take
|
29
|
+
# an Item and a full config as arguments.
|
30
|
+
def initialize(item_hash, config: Config.new.hash, view: Item::View)
|
31
|
+
item_hash = item_hash.dup
|
32
|
+
|
33
|
+
add_missing_attributes_with_filler_values(item_hash, config)
|
34
|
+
|
35
|
+
@attributes = item_hash.to_data
|
36
|
+
@config = config
|
37
|
+
|
38
|
+
@status, @last_end_date = get_status_and_last_end_date
|
39
|
+
@view = view.new(self, config) if view
|
40
|
+
end
|
41
|
+
|
42
|
+
# Whether this item is done.
|
43
|
+
# @return [Boolean]
|
44
|
+
def done?
|
45
|
+
status == :done
|
46
|
+
end
|
47
|
+
|
48
|
+
# Whether this item has a fixed length, such as a book or audiobook (as
|
49
|
+
# opposed to an ongoing podcast).
|
50
|
+
# @return [Boolean]
|
51
|
+
def definite_length?
|
52
|
+
attributes.variants.any? { |variant| !!variant.length }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Equality to another Item.
|
56
|
+
# @other [Item]
|
57
|
+
# @return [Boolean]
|
58
|
+
def ==(other)
|
59
|
+
unless other.is_a?(Item)
|
60
|
+
raise ArgumentError, "An Item can be compared only with another Item."
|
61
|
+
end
|
62
|
+
|
63
|
+
attributes == other.send(:attributes)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# For each missing item attribute (key in config[:item][:template]) in
|
69
|
+
# item_hash, adds the key and a filler value.
|
70
|
+
# @param item_hash [Hash]
|
71
|
+
# @param config [Hash] an entire config.
|
72
|
+
def add_missing_attributes_with_filler_values(item_hash, config)
|
73
|
+
config.deep_fetch(:item, :template).each do |k, v|
|
74
|
+
next if item_hash.has_key?(k)
|
75
|
+
|
76
|
+
filler = v.is_a?(Array) ? [] : nil
|
77
|
+
item_hash[k] = filler
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Determines the status and the last end date. Note: for an item of indefinite
|
82
|
+
# length (e.g. podcast) there is a grace period during which the status
|
83
|
+
# remains :in_progress after the last activity. If that grace period is over,
|
84
|
+
# the status is :done. It's :planned if there are no spans with dates.
|
85
|
+
# @return [Array(Symbol, Date)]
|
86
|
+
def get_status_and_last_end_date
|
87
|
+
return [:planned, nil] if experiences.none? || experiences.flat_map(&:spans).none?
|
88
|
+
|
89
|
+
experiences_with_spans_with_dates = experiences
|
90
|
+
.select { |experience| experience.spans.any? { |span| span.dates } }
|
91
|
+
|
92
|
+
return [:planned, nil] unless experiences_with_spans_with_dates.any?
|
93
|
+
|
94
|
+
last_end_date = experiences_with_spans_with_dates
|
95
|
+
.last
|
96
|
+
.spans
|
97
|
+
.select { |span| span.dates }
|
98
|
+
.last
|
99
|
+
.dates
|
100
|
+
.end
|
101
|
+
|
102
|
+
return [:in_progress, nil] unless last_end_date
|
103
|
+
|
104
|
+
if definite_length?
|
105
|
+
[:done, last_end_date]
|
106
|
+
else
|
107
|
+
grace_period = config.deep_fetch(:item, :indefinite_in_progress_grace_period_days)
|
108
|
+
indefinite_in_progress_grace_period_is_over =
|
109
|
+
(Date.today - grace_period) > last_end_date
|
110
|
+
|
111
|
+
return [:done, last_end_date] if indefinite_in_progress_grace_period_is_over
|
112
|
+
|
113
|
+
[:in_progress, last_end_date]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -4,7 +4,7 @@ module Reading
|
|
4
4
|
# The base class for all the attribute in parsing/attributes, each of which
|
5
5
|
# extracts an attribute from a parsed row. Together they transform the
|
6
6
|
# parsed row (an intermediate hash) into item attributes, as in
|
7
|
-
# Config#default_config[:
|
7
|
+
# Config#default_config[:item][:template].
|
8
8
|
class Attribute
|
9
9
|
private attr_reader :config
|
10
10
|
|
@@ -23,7 +23,7 @@ module Reading
|
|
23
23
|
|
24
24
|
# Extracts experiences from the parsed row.
|
25
25
|
# @return [Array<Hash>] an array of experiences; see
|
26
|
-
# Config#default_config[:
|
26
|
+
# Config#default_config[:item][:template][:experiences]
|
27
27
|
def transform
|
28
28
|
size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
|
29
29
|
# Pad start dates with {} and end dates with nil up to the size of
|
@@ -54,13 +54,13 @@ module Reading
|
|
54
54
|
# A shortcut to the experience template.
|
55
55
|
# @return [Hash]
|
56
56
|
def template
|
57
|
-
config.deep_fetch(:
|
57
|
+
config.deep_fetch(:item, :template, :experiences).first
|
58
58
|
end
|
59
59
|
|
60
60
|
# A shortcut to the span template.
|
61
61
|
# @return [Hash]
|
62
62
|
def span_template
|
63
|
-
config.deep_fetch(:
|
63
|
+
config.deep_fetch(:item, :template, :experiences, 0, :spans).first
|
64
64
|
end
|
65
65
|
|
66
66
|
# The :spans sub-attribute for the given pair of date entries.
|
@@ -27,7 +27,7 @@ module Reading
|
|
27
27
|
|
28
28
|
# Extracts experiences from the parsed row.
|
29
29
|
# @return [Array<Hash>] an array of experiences; see
|
30
|
-
# Config#default_config[:
|
30
|
+
# Config#default_config[:item][:template][:experiences]
|
31
31
|
def transform
|
32
32
|
experiences = parsed_row[:history].map { |entries|
|
33
33
|
{
|
@@ -48,13 +48,13 @@ module Reading
|
|
48
48
|
# A shortcut to the span template.
|
49
49
|
# @return [Hash]
|
50
50
|
def span_template
|
51
|
-
@span_template ||= config.deep_fetch(:
|
51
|
+
@span_template ||= config.deep_fetch(:item, :template, :experiences, 0, :spans).first
|
52
52
|
end
|
53
53
|
|
54
54
|
# The :spans sub-attribute for the given History column entries.
|
55
55
|
# @param entries [Array<Hash>] History entries for one experience.
|
56
56
|
# @return [Array<Hash>] an array of spans; see
|
57
|
-
# Config#default_config[:
|
57
|
+
# Config#default_config[:item][:template][:experiences].first[:spans]
|
58
58
|
def spans_from_history_entries(entries)
|
59
59
|
daily_spans = {}
|
60
60
|
active = {
|
@@ -304,11 +304,11 @@ module Reading
|
|
304
304
|
# Distributes an amount across the given date(s).
|
305
305
|
# @param date_or_range [Date, Range<Date>] the date or range across
|
306
306
|
# which the amount will be split up.
|
307
|
-
# @param amount [Float, Integer,
|
307
|
+
# @param amount [Float, Integer, Item::TimeLength] amount in
|
308
308
|
# pages or time.
|
309
309
|
# @param repetitions [Integer] e.g. "x4" in a History entry.
|
310
310
|
# @param frequency [Integer] e.g. "/week" in a History entry.
|
311
|
-
# @return [Hash{Date => Float, Integer,
|
311
|
+
# @return [Hash{Date => Float, Integer, Item::TimeLength}]
|
312
312
|
def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
|
313
313
|
unless amount
|
314
314
|
raise InvalidHistoryError, "Missing length or amount"
|
@@ -59,7 +59,7 @@ module Reading
|
|
59
59
|
# if there is more than one more start date than end dates.
|
60
60
|
# @raise [InvalidDateError]
|
61
61
|
def validate_number_of_start_dates_and_end_dates(experiences)
|
62
|
-
|
62
|
+
_both_dates, not_both_dates = experiences
|
63
63
|
.filter { |exp| exp[:spans].first&.dig(:dates) }
|
64
64
|
.map { |exp| [exp[:spans].first[:dates].begin, exp[:spans].last[:dates].end] }
|
65
65
|
.partition { |start_date, end_date| start_date && end_date }
|
@@ -13,7 +13,7 @@ module Reading
|
|
13
13
|
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
14
14
|
# @param head_index [Integer] current item's position in the Head column.
|
15
15
|
# @return [Array<Hash>] an array of experiences; see
|
16
|
-
# Config#default_config[:
|
16
|
+
# Config#default_config[:item][:template][:experiences]
|
17
17
|
def transform_from_parsed(parsed_row, head_index)
|
18
18
|
if !parsed_row[:history].blank?
|
19
19
|
return HistoryTransformer.new(parsed_row, config).transform
|
@@ -6,7 +6,7 @@ module Reading
|
|
6
6
|
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
7
7
|
# @param _head_index [Integer] current item's position in the Head column.
|
8
8
|
# @return [Array<Hash>] an array of notes; see
|
9
|
-
# Config#default_config[:
|
9
|
+
# Config#default_config[:item][:template][:notes]
|
10
10
|
def transform_from_parsed(parsed_row, _head_index)
|
11
11
|
parsed_row[:notes]&.map { |note|
|
12
12
|
{
|
@@ -6,7 +6,7 @@ module Reading
|
|
6
6
|
# Extracts the :progress sub-attribute (percent, pages, or time) from
|
7
7
|
# the given hash.
|
8
8
|
# @param hash [Hash] any parsed hash that contains progress.
|
9
|
-
# @return [Float, Integer,
|
9
|
+
# @return [Float, Integer, Item::TimeLength]
|
10
10
|
def self.progress(hash)
|
11
11
|
hash[:progress_percent]&.to_f&./(100) ||
|
12
12
|
hash[:progress_pages]&.to_i ||
|
@@ -29,7 +29,7 @@ module Reading
|
|
29
29
|
# that e.g. "1:00 x14" gives a length of 1 hour instead of 14 hours.
|
30
30
|
# This is useful for the History column, where that 1 hour can be used
|
31
31
|
# as the default amount.
|
32
|
-
# @return [Float, Integer,
|
32
|
+
# @return [Float, Integer, Item::TimeLength]
|
33
33
|
def self.length(hash, key_name: :length, episodic: false, ignore_repetitions: false)
|
34
34
|
return nil unless hash
|
35
35
|
|
@@ -8,7 +8,7 @@ module Reading
|
|
8
8
|
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
9
9
|
# @param head_index [Integer] current item's position in the Head column.
|
10
10
|
# @return [Array<Hash>] an array of variants; see
|
11
|
-
# Config#default_config[:
|
11
|
+
# Config#default_config[:item][:template][:variants]
|
12
12
|
def transform_from_parsed(parsed_row, head_index)
|
13
13
|
head = parsed_row[:head][head_index]
|
14
14
|
|
@@ -29,7 +29,7 @@ module Reading
|
|
29
29
|
# A shortcut to the variant template.
|
30
30
|
# @return [Hash]
|
31
31
|
def template
|
32
|
-
config.deep_fetch(:
|
32
|
+
config.deep_fetch(:item, :template, :variants).first
|
33
33
|
end
|
34
34
|
|
35
35
|
# The :series sub-attribute for the given parsed hash.
|
data/lib/reading/parsing/csv.rb
CHANGED
@@ -3,7 +3,6 @@ require_relative "../util/blank"
|
|
3
3
|
require_relative "../util/string_remove"
|
4
4
|
require_relative "../util/string_truncate"
|
5
5
|
require_relative "../util/numeric_to_i_if_whole"
|
6
|
-
require_relative "../util/hash_to_struct"
|
7
6
|
require_relative "../util/hash_deep_merge"
|
8
7
|
require_relative "../util/hash_array_deep_fetch"
|
9
8
|
require_relative "../util/hash_compact_by_template"
|
@@ -11,6 +10,7 @@ require_relative "../errors"
|
|
11
10
|
|
12
11
|
# Used just here.
|
13
12
|
require_relative "../config"
|
13
|
+
require_relative "../item"
|
14
14
|
require_relative "parser"
|
15
15
|
require_relative "transformer"
|
16
16
|
|
@@ -18,7 +18,7 @@ module Reading
|
|
18
18
|
module Parsing
|
19
19
|
#
|
20
20
|
# Validates a path or stream (string, file, etc.) of a CSV reading log, then
|
21
|
-
# parses it into
|
21
|
+
# parses it into an array of Items.
|
22
22
|
#
|
23
23
|
# Parsing happens in two steps:
|
24
24
|
# (1) Parse a row string into an intermediate hash representing the columns.
|
@@ -31,33 +31,40 @@ module Reading
|
|
31
31
|
# inspired by the Parslet gem: https://kschiess.github.io/parslet/transform.html
|
32
32
|
#
|
33
33
|
class CSV
|
34
|
-
|
35
|
-
|
36
|
-
private attr_reader :parser, :transformer
|
34
|
+
private attr_reader :parser, :transformer, :hash_output, :item_view
|
37
35
|
|
38
36
|
# Validates a path or stream (string, file, etc.) of a CSV reading log,
|
39
37
|
# builds the config, and initializes the parser and transformer.
|
40
|
-
# @param path [String] path to the CSV file; if
|
38
|
+
# @param path [String] path to the CSV file; used if no stream is given.
|
41
39
|
# @param stream [Object] an object responding to #each_linewith CSV row(s);
|
42
|
-
#
|
40
|
+
# if nil, path is used instead.
|
43
41
|
# @param config [Hash] a custom config which overrides the defaults,
|
44
42
|
# e.g. { errors: { styling: :html } }
|
45
|
-
|
43
|
+
# @param hash_output [Boolean] whether an array of raw Hashes should be
|
44
|
+
# returned, without Items being created from them.
|
45
|
+
# @param view [Class, nil, Boolean] the class that will be used to build
|
46
|
+
# each Item's view object, or nil/false if no view object should be built.
|
47
|
+
# If you use a custom view class, the only requirement is that its
|
48
|
+
# #initialize take an Item and a full config as arguments.
|
49
|
+
def initialize(path = nil, stream: nil, config: {}, hash_output: false, item_view: Item::View)
|
46
50
|
validate_path_or_stream(path, stream)
|
47
51
|
full_config = Config.new(config).hash
|
48
52
|
|
49
53
|
@path = path
|
50
54
|
@stream = stream
|
55
|
+
@hash_output = hash_output
|
56
|
+
@item_view = item_view
|
51
57
|
@parser = Parser.new(full_config)
|
52
58
|
@transformer = Transformer.new(full_config)
|
53
59
|
end
|
54
60
|
|
55
61
|
# Parses and transforms the reading log into item data.
|
56
|
-
# @return [Array<
|
57
|
-
# Config#default_config[:
|
58
|
-
# structure to that Hash (with every inner Hash replaced by a
|
62
|
+
# @return [Array<Item>] an array of Items like the template in
|
63
|
+
# Config#default_config[:item][:template]. The Items are identical in
|
64
|
+
# structure to that Hash (with every inner Hash replaced by a Data for
|
65
|
+
# dot access).
|
59
66
|
def parse
|
60
|
-
input = @
|
67
|
+
input = @stream || File.open(@path)
|
61
68
|
items = []
|
62
69
|
|
63
70
|
input.each_line do |line|
|
@@ -72,7 +79,11 @@ module Reading
|
|
72
79
|
items += row_items
|
73
80
|
end
|
74
81
|
|
75
|
-
|
82
|
+
if hash_output
|
83
|
+
items
|
84
|
+
else
|
85
|
+
items.map { |item_hash| Item.new(item_hash, view: item_view) }
|
86
|
+
end
|
76
87
|
ensure
|
77
88
|
input&.close if input.respond_to?(:close)
|
78
89
|
end
|
@@ -83,14 +94,14 @@ module Reading
|
|
83
94
|
# @raise [FileError] if the given path is invalid.
|
84
95
|
# @raise [ArgumentError] if both stream and path are nil.
|
85
96
|
def validate_path_or_stream(path, stream)
|
86
|
-
if
|
97
|
+
if stream && stream.respond_to?(:each_line)
|
98
|
+
return true
|
99
|
+
elsif path
|
87
100
|
if !File.exist?(path)
|
88
101
|
raise FileError, "File not found! #{path}"
|
89
102
|
elsif File.directory?(path)
|
90
103
|
raise FileError, "A file is expected, but the path given is a directory: #{path}"
|
91
104
|
end
|
92
|
-
elsif stream && stream.respond_to?(:each_line)
|
93
|
-
return true
|
94
105
|
else
|
95
106
|
raise ArgumentError,
|
96
107
|
"Either a file path or a stream (string, file, etc.) must be provided."
|
@@ -75,8 +75,8 @@ module Reading
|
|
75
75
|
# @return [Hash{Class => String}] a hash whose keys are classes inheriting
|
76
76
|
# Parsing::Rows::Column.
|
77
77
|
def extract_columns(string)
|
78
|
-
|
79
|
-
column_strings =
|
78
|
+
string = string.dup.force_encoding(Encoding::UTF_8)
|
79
|
+
column_strings = string.split(config.fetch(:column_separator))
|
80
80
|
|
81
81
|
row_types = [Rows::Regular, Rows::CompactPlanned, Rows::Comment]
|
82
82
|
column_classes = row_types
|
@@ -14,7 +14,7 @@ module Reading
|
|
14
14
|
# Transforms an intermediate hash (parsed from a CSV row) into item data.
|
15
15
|
# While the intermediate hash mirrors the structure of a row, the output of
|
16
16
|
# Transformer is based around item attributes, which are listed in
|
17
|
-
# Config#default_config[:
|
17
|
+
# Config#default_config[:item][:template] and in the files in parsing/attributes.
|
18
18
|
#
|
19
19
|
class Transformer
|
20
20
|
using Util::HashArrayDeepFetch
|
@@ -34,13 +34,13 @@ module Reading
|
|
34
34
|
# @param parsed_row [Hash{Symbol => Hash, Array}] output from
|
35
35
|
# Parsing::Parser#parse_row_to_intermediate_hash.
|
36
36
|
# @return [Array<Hash>] an array of Hashes like the template in
|
37
|
-
# Config#default_config[:
|
37
|
+
# Config#default_config[:item][:template].
|
38
38
|
def transform_intermediate_hash_to_item_hashes(parsed_row)
|
39
39
|
if parsed_row[:head].blank?
|
40
40
|
raise InvalidHeadError, "Blank or missing Head column"
|
41
41
|
end
|
42
42
|
|
43
|
-
template = config.
|
43
|
+
template = config.deep_fetch(:item, :template)
|
44
44
|
|
45
45
|
parsed_row[:head].map.with_index { |_head, head_index|
|
46
46
|
template.map { |attribute_name, default_value|
|
@@ -58,7 +58,7 @@ module Reading
|
|
58
58
|
# Sets the attributes classes which do all the transforming work.
|
59
59
|
# See parsing/attributes/*.
|
60
60
|
def set_attributes
|
61
|
-
@attributes ||= config.
|
61
|
+
@attributes ||= config.deep_fetch(:item, :template).map { |attribute_name, _default|
|
62
62
|
attribute_name_camelcase = attribute_name.to_s.split("_").map(&:capitalize).join
|
63
63
|
attribute_class = Attributes.const_get(attribute_name_camelcase)
|
64
64
|
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Reading
|
2
|
+
module Util
|
3
|
+
# Converts a Hash to a Data. Converts inner hashes (and inner arrays of hashes) as well.
|
4
|
+
module HashToData
|
5
|
+
refine Hash do
|
6
|
+
# @return [Data]
|
7
|
+
def to_data
|
8
|
+
MEMOIZED_DATAS[keys] ||= Data.define(*keys)
|
9
|
+
data_class = MEMOIZED_DATAS[keys]
|
10
|
+
|
11
|
+
data_values = transform_values { |v|
|
12
|
+
if v.is_a?(Hash)
|
13
|
+
v.to_data
|
14
|
+
elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
|
15
|
+
v.map(&:to_data)
|
16
|
+
else
|
17
|
+
v
|
18
|
+
end
|
19
|
+
}.values
|
20
|
+
|
21
|
+
data_class.new(*data_values)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
MEMOIZED_DATAS = {}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/reading/version.rb
CHANGED
data/lib/reading.rb
CHANGED
@@ -1,8 +1,27 @@
|
|
1
1
|
require_relative "reading/parsing/csv"
|
2
|
+
require_relative "reading/filter"
|
3
|
+
require_relative "reading/config"
|
2
4
|
require_relative "reading/item/time_length.rb"
|
3
5
|
|
4
6
|
# The gem's public API. See https://github.com/fpsvogel/reading#usage
|
5
|
-
|
7
|
+
#
|
8
|
+
# Architectural overview:
|
9
|
+
#
|
10
|
+
# (CSV input) (Items) (filtered Items)
|
11
|
+
# | ฮ | ฮ
|
12
|
+
# | | ยท---. |
|
13
|
+
# | | | |
|
14
|
+
# V | V |
|
15
|
+
# ::parse | ::filter |
|
16
|
+
# | | | |
|
17
|
+
# | .----------> Item Filter
|
18
|
+
# Config, | / / \
|
19
|
+
# errors.rb ----- Parsing::CSV --ยท Item::View Item::TimeLength
|
20
|
+
# / \
|
21
|
+
# Parsing::Parser Parsing::Transformer
|
22
|
+
# | |
|
23
|
+
# parsing/attributes/* parsing/rows/*
|
24
|
+
#
|
6
25
|
module Reading
|
7
26
|
# Parses a CSV file or string. See Parsing::CSV#initialize and #parse for details.
|
8
27
|
def self.parse(...)
|
@@ -10,9 +29,21 @@ module Reading
|
|
10
29
|
csv.parse
|
11
30
|
end
|
12
31
|
|
32
|
+
# Filters an array of Items. See Filter::by for details.
|
33
|
+
def self.filter(...)
|
34
|
+
Filter.by(...)
|
35
|
+
end
|
36
|
+
|
37
|
+
# The default config.
|
38
|
+
# @return [Hash]
|
39
|
+
def self.default_config
|
40
|
+
Config.new.hash
|
41
|
+
end
|
42
|
+
|
43
|
+
# A shortcut for getting a time from a string.
|
13
44
|
# @param string [String] a time duration in "h:mm" format.
|
14
|
-
# @return [
|
45
|
+
# @return [Item::TimeLength]
|
15
46
|
def self.time(string)
|
16
|
-
|
47
|
+
Item::TimeLength.parse(string)
|
17
48
|
end
|
18
49
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: reading
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Felipe Vogel
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-04-
|
11
|
+
date: 2023-04-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: pastel
|
@@ -66,6 +66,20 @@ dependencies:
|
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '1.6'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: shoulda-context
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '2.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '2.0'
|
69
83
|
- !ruby/object:Gem::Dependency
|
70
84
|
name: pretty-diffs
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -122,7 +136,10 @@ files:
|
|
122
136
|
- lib/reading.rb
|
123
137
|
- lib/reading/config.rb
|
124
138
|
- lib/reading/errors.rb
|
139
|
+
- lib/reading/filter.rb
|
140
|
+
- lib/reading/item.rb
|
125
141
|
- lib/reading/item/time_length.rb
|
142
|
+
- lib/reading/item/view.rb
|
126
143
|
- lib/reading/parsing/attributes/attribute.rb
|
127
144
|
- lib/reading/parsing/attributes/author.rb
|
128
145
|
- lib/reading/parsing/attributes/experiences.rb
|
@@ -156,7 +173,7 @@ files:
|
|
156
173
|
- lib/reading/util/hash_array_deep_fetch.rb
|
157
174
|
- lib/reading/util/hash_compact_by_template.rb
|
158
175
|
- lib/reading/util/hash_deep_merge.rb
|
159
|
-
- lib/reading/util/
|
176
|
+
- lib/reading/util/hash_to_data.rb
|
160
177
|
- lib/reading/util/numeric_to_i_if_whole.rb
|
161
178
|
- lib/reading/util/string_remove.rb
|
162
179
|
- lib/reading/util/string_truncate.rb
|
@@ -1,30 +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
|
-
# @return [Struct]
|
7
|
-
def to_struct
|
8
|
-
MEMOIZED_STRUCTS[keys] ||= Struct.new(*keys)
|
9
|
-
struct_class = MEMOIZED_STRUCTS[keys]
|
10
|
-
|
11
|
-
struct_values = transform_values { |v|
|
12
|
-
if v.is_a?(Hash)
|
13
|
-
v.to_struct
|
14
|
-
elsif v.is_a?(Array) && v.all? { |el| el.is_a?(Hash) }
|
15
|
-
v.map(&:to_struct)
|
16
|
-
else
|
17
|
-
v
|
18
|
-
end
|
19
|
-
}.values
|
20
|
-
|
21
|
-
struct_class.new(*struct_values)
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
private
|
26
|
-
|
27
|
-
MEMOIZED_STRUCTS = {}
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|