reading 0.7.0 โ 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.
- 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
|