reading 0.8.0 → 0.9.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 +80 -10
- data/lib/reading/config.rb +27 -5
- data/lib/reading/errors.rb +4 -1
- data/lib/reading/item/time_length.rb +60 -23
- data/lib/reading/item/view.rb +14 -19
- data/lib/reading/item.rb +321 -54
- data/lib/reading/parsing/attributes/attribute.rb +0 -7
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +10 -11
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +27 -18
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +18 -19
- data/lib/reading/parsing/attributes/experiences.rb +5 -5
- data/lib/reading/parsing/attributes/shared.rb +13 -6
- data/lib/reading/parsing/attributes/variants.rb +9 -6
- data/lib/reading/parsing/csv.rb +38 -35
- data/lib/reading/parsing/parser.rb +23 -24
- data/lib/reading/parsing/rows/blank.rb +23 -0
- data/lib/reading/parsing/rows/comment.rb +6 -7
- data/lib/reading/parsing/rows/compact_planned.rb +9 -9
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +2 -2
- data/lib/reading/parsing/rows/custom_config.rb +42 -0
- data/lib/reading/parsing/rows/regular.rb +15 -14
- data/lib/reading/parsing/rows/regular_columns/length.rb +8 -8
- data/lib/reading/parsing/rows/regular_columns/sources.rb +15 -9
- data/lib/reading/parsing/transformer.rb +13 -17
- data/lib/reading/stats/filter.rb +738 -0
- data/lib/reading/stats/grouping.rb +243 -0
- data/lib/reading/stats/operation.rb +313 -0
- data/lib/reading/stats/query.rb +37 -0
- data/lib/reading/stats/terminal_result_formatters.rb +91 -0
- data/lib/reading/util/exclude.rb +12 -0
- data/lib/reading/util/hash_to_data.rb +2 -2
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +36 -21
- metadata +10 -6
- data/bin/readingfile +0 -31
- data/lib/reading/util/string_remove.rb +0 -28
- data/lib/reading/util/string_truncate.rb +0 -22
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e839c46251c307a65a234a9a374ad964f68d934d974005d6aa38cbb35e10f756
|
4
|
+
data.tar.gz: fa20c0b78f3fd1194495e1e604bb1d95b37e0334ac12c745d6508e026cd6fe97
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2bbca1450b2f6ae4c0d2b6d4cfeec495bf30b20639fcb3a5b121711e1d995a8b13a5ca64100d7a5e11d00d60c3808d61d25583510e3cd19902d412eca216154f
|
7
|
+
data.tar.gz: 74a676238941cdd5a0a7ecf8b2a4ed3c705883daadc25b3710a17eb9e325ec294d8b9fad71e653779af1ea9e01d0e8fed6423592dbe750b4f37c79f0f690387d
|
data/bin/reading
CHANGED
@@ -1,31 +1,101 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
#
|
3
|
+
# Starts the reading statistics interactive CLI, if a CSV file path arg is given.
|
4
|
+
# If a CSV string is given instead, then parsing output (item hashes) is displayed.
|
4
5
|
#
|
5
6
|
# Usage:
|
6
7
|
# Run on the command line:
|
7
|
-
# reading "<CSV string>" "<optional comma-separated names of enabled columns>"
|
8
|
+
# reading "<CSV file path or string>" "<optional comma-separated names of enabled columns>"
|
8
9
|
#
|
9
10
|
# Examples:
|
11
|
+
# reading /home/felipe/reading.csv
|
12
|
+
# reading /home/felipe/reading.csv 'head, sources'
|
10
13
|
# reading '3|📕Trying|Little Library 1970147288'
|
11
14
|
# reading '📕Trying|Little Library 1970147288' 'head, sources'
|
12
15
|
|
16
|
+
require 'debug'
|
17
|
+
require_relative '../lib/reading'
|
18
|
+
require_relative '../lib/reading/stats/terminal_result_formatters'
|
19
|
+
require 'amazing_print'
|
20
|
+
require 'readline'
|
21
|
+
require 'pastel'
|
13
22
|
|
14
|
-
|
15
|
-
|
16
|
-
|
23
|
+
EXIT_COMMANDS = %w[exit e quit q]
|
24
|
+
PASTEL = Pastel.new
|
25
|
+
GROUP_HEADING_FORMATTERS = [
|
26
|
+
-> { PASTEL.magenta.bold.underline(_1) },
|
27
|
+
-> { PASTEL.green.bold.underline(_1) },
|
28
|
+
-> { PASTEL.yellow.bold.underline(_1) },
|
29
|
+
-> { PASTEL.cyan.bold.underline(_1) },
|
30
|
+
-> { PASTEL.magenta.on_white(_1) },
|
31
|
+
-> { PASTEL.green.on_white(_1) },
|
32
|
+
-> { PASTEL.yellow.on_white(_1) },
|
33
|
+
]
|
34
|
+
|
35
|
+
# Recursively prints a hash of results (possibly grouped).
|
36
|
+
# @param grouped_results [Hash, Array]
|
37
|
+
# @param group_heading_formatters [Array<Proc>] a subset of GROUP_HEADING_FORMATTERS
|
38
|
+
def print_grouped_results(grouped_results, group_heading_formatters)
|
39
|
+
indent_level = GROUP_HEADING_FORMATTERS.count - group_heading_formatters.count
|
40
|
+
|
41
|
+
if grouped_results.nil? || grouped_results.empty?
|
42
|
+
puts " " * indent_level + PASTEL.bright_black("none") + "\n"
|
43
|
+
return
|
44
|
+
elsif !grouped_results.is_a? Hash
|
45
|
+
puts " " * indent_level + grouped_results.gsub("\n", "\n" + " " * indent_level) + "\n"
|
46
|
+
return
|
47
|
+
end
|
48
|
+
|
49
|
+
grouped_results.each do |group_name, grouped|
|
50
|
+
puts " " * indent_level + group_heading_formatters.first.call(group_name)
|
51
|
+
print_grouped_results(grouped, group_heading_formatters[1..])
|
52
|
+
end
|
53
|
+
end
|
17
54
|
|
18
55
|
input = ARGV[0]
|
19
56
|
unless input
|
20
|
-
raise ArgumentError,
|
57
|
+
raise ArgumentError,
|
58
|
+
"Argument required, either a CSV file path or a CSV string. Examples:\n" \
|
59
|
+
"parsereading /home/felipe/reading.csv\n" \
|
60
|
+
"parsereading '3|📕Trying|Little Library 1970147288'"
|
21
61
|
end
|
22
62
|
|
23
|
-
config = {}
|
24
63
|
if ARGV[1]
|
25
64
|
enabled_columns = ARGV[1].split(",").map(&:strip).map(&:to_sym)
|
26
|
-
|
65
|
+
Reading::Config.build(enabled_columns:)
|
27
66
|
end
|
28
67
|
|
29
|
-
|
68
|
+
input_is_csv_path = input.end_with?('.csv')
|
69
|
+
|
70
|
+
if input_is_csv_path
|
71
|
+
error_handler = ->(e) { puts "Skipped a row due to a parsing error: #{e}" }
|
72
|
+
|
73
|
+
items = Reading.parse(path: input, item_view: false, error_handler:)
|
30
74
|
|
31
|
-
|
75
|
+
loop do
|
76
|
+
raw_input = Readline.readline(PASTEL.bright_cyan("> "), true)
|
77
|
+
|
78
|
+
exit if EXIT_COMMANDS.include?(raw_input)
|
79
|
+
|
80
|
+
input = raw_input.presence
|
81
|
+
next if raw_input.blank?
|
82
|
+
|
83
|
+
results = Reading.stats(
|
84
|
+
input:,
|
85
|
+
items:,
|
86
|
+
result_formatters: Reading::Stats::ResultFormatters::TERMINAL,
|
87
|
+
)
|
88
|
+
|
89
|
+
print_grouped_results(results, GROUP_HEADING_FORMATTERS)
|
90
|
+
rescue Reading::Error => e
|
91
|
+
puts e
|
92
|
+
end
|
93
|
+
else # CSV string arg
|
94
|
+
begin
|
95
|
+
item_hashes = Reading.parse(lines: input, hash_output: true, item_view: false)
|
96
|
+
rescue Reading::Error => e
|
97
|
+
puts "Skipped a row due to a parsing error: #{e}"
|
98
|
+
end
|
99
|
+
|
100
|
+
ap item_hashes
|
101
|
+
end
|
data/lib/reading/config.rb
CHANGED
@@ -1,15 +1,26 @@
|
|
1
|
-
require_relative
|
2
|
-
require_relative "util/hash_array_deep_fetch"
|
3
|
-
require_relative "errors"
|
1
|
+
require_relative 'errors'
|
4
2
|
|
5
3
|
module Reading
|
6
|
-
# Builds a hash config.
|
4
|
+
# Builds a singleton hash config.
|
7
5
|
class Config
|
8
6
|
using Util::HashDeepMerge
|
9
7
|
using Util::HashArrayDeepFetch
|
10
8
|
|
11
9
|
attr_reader :hash
|
12
10
|
|
11
|
+
# Builds an entire config hash from a custom config hash (which is typically
|
12
|
+
# not an entire config, but it can be, in which case a copy is returned).
|
13
|
+
# @param custom_config [Hash, Config]
|
14
|
+
# @return [Hash]
|
15
|
+
def self.build(custom_config = {})
|
16
|
+
@hash = new(custom_config).hash
|
17
|
+
end
|
18
|
+
|
19
|
+
# @return [Hash]
|
20
|
+
def self.hash
|
21
|
+
@hash ||= build
|
22
|
+
end
|
23
|
+
|
13
24
|
# @param custom_config [Hash] a custom config which overrides the defaults,
|
14
25
|
# e.g. { enabled_columns: [:head, :end_dates] }
|
15
26
|
def initialize(custom_config = {})
|
@@ -55,6 +66,16 @@ module Reading
|
|
55
66
|
column_separator: "|",
|
56
67
|
ignored_characters: "✅❌💲❓⏳",
|
57
68
|
skip_compact_planned: false,
|
69
|
+
pages_per_hour: 35,
|
70
|
+
length_group_boundaries: [200, 400, 600, 1000, 2000],
|
71
|
+
speed: # e.g. listening speed for audiobooks and podcasts.
|
72
|
+
{
|
73
|
+
format:
|
74
|
+
{
|
75
|
+
audiobook: 1.0,
|
76
|
+
audio: 1.0,
|
77
|
+
},
|
78
|
+
},
|
58
79
|
# The Head column is always enabled; the others can be disabled by
|
59
80
|
# using a custom config that omits columns from this array.
|
60
81
|
enabled_columns:
|
@@ -86,6 +107,7 @@ module Reading
|
|
86
107
|
},
|
87
108
|
source_names_from_urls:
|
88
109
|
{
|
110
|
+
"audible.com" => "Audible",
|
89
111
|
"youtube.com" => "YouTube",
|
90
112
|
"youtu.be" => "YouTube",
|
91
113
|
"books.google.com" => "Google Books",
|
@@ -156,8 +178,8 @@ module Reading
|
|
156
178
|
spans:
|
157
179
|
[{
|
158
180
|
dates: nil,
|
181
|
+
progress: 1.0,
|
159
182
|
amount: 0,
|
160
|
-
progress: nil,
|
161
183
|
name: nil,
|
162
184
|
favorite?: false,
|
163
185
|
}],
|
data/lib/reading/errors.rb
CHANGED
@@ -7,7 +7,7 @@ module Reading
|
|
7
7
|
# Means there is something wrong with the user-supplied custom config.
|
8
8
|
class ConfigError < Reading::Error; end
|
9
9
|
|
10
|
-
# Means unexpected input was encountered during parsing.
|
10
|
+
# Means unexpected input was encountered during CSV parsing.
|
11
11
|
class ParsingError < Reading::Error; end
|
12
12
|
|
13
13
|
# Means something in the Head column (author, title, etc.) is invalid.
|
@@ -21,4 +21,7 @@ module Reading
|
|
21
21
|
|
22
22
|
# Means a date is unparsable, or a set of dates does not make logical sense.
|
23
23
|
class InvalidDateError < Reading::Error; end
|
24
|
+
|
25
|
+
# Means a stats command was entered incorrectly.
|
26
|
+
class InputError < Reading::Error; end
|
24
27
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Reading
|
2
2
|
class Item
|
3
3
|
# The length of an item when it is a time, as opposed to pages. (Pages are
|
4
|
-
# represented simply with an Integer.)
|
4
|
+
# represented simply with an Integer or Float.)
|
5
5
|
class Item::TimeLength
|
6
6
|
include Comparable
|
7
7
|
|
@@ -14,16 +14,32 @@ module Reading
|
|
14
14
|
|
15
15
|
# Builds an Item::TimeLength from a string.
|
16
16
|
# @param string [String] a time duration in "h:mm" format.
|
17
|
-
# @return [TimeLength]
|
17
|
+
# @return [TimeLength, nil]
|
18
18
|
def self.parse(string)
|
19
|
+
return nil unless string.match? /\A\d+:\d\d\z/
|
20
|
+
|
19
21
|
hours, minutes = string.split(':').map(&:to_i)
|
20
22
|
new((hours * 60) + minutes)
|
21
23
|
end
|
22
24
|
|
25
|
+
# Builds an Item::TimeLength based on a page count.
|
26
|
+
# @param pages [Integer, Float]
|
27
|
+
# @return [TimeLength]
|
28
|
+
def self.from_pages(pages)
|
29
|
+
new(pages_to_minutes(pages))
|
30
|
+
end
|
31
|
+
|
32
|
+
# Converts a page count to minutes.
|
33
|
+
# @param pages [Integer, Float]
|
34
|
+
# @return [Integer]
|
35
|
+
def self.pages_to_minutes(pages)
|
36
|
+
(pages.to_f / Config.hash.fetch(:pages_per_hour) * 60)
|
37
|
+
end
|
38
|
+
|
23
39
|
# Only the hours, e.g. the "h" value in "h:mm".
|
24
40
|
# @return [Numeric]
|
25
41
|
def hours
|
26
|
-
value / 60
|
42
|
+
value.to_i / 60
|
27
43
|
end
|
28
44
|
|
29
45
|
# Only the hours, e.g. the "mm" value in "h:mm".
|
@@ -35,7 +51,13 @@ module Reading
|
|
35
51
|
# A string in "h:mm" format.
|
36
52
|
# @return [String]
|
37
53
|
def to_s
|
38
|
-
"#{hours}:#{minutes}"
|
54
|
+
"#{hours}:#{minutes.round.to_s.rjust(2, '0')} or #{(value / 60.0 * Config.hash.fetch(:pages_per_hour)).round} pages"
|
55
|
+
end
|
56
|
+
|
57
|
+
# To pages.
|
58
|
+
# @return [Integer]
|
59
|
+
def to_i
|
60
|
+
((value / 60.0) * Config.hash.fetch(:pages_per_hour)).to_i
|
39
61
|
end
|
40
62
|
|
41
63
|
# @return [Boolean]
|
@@ -43,6 +65,12 @@ module Reading
|
|
43
65
|
value.zero?
|
44
66
|
end
|
45
67
|
|
68
|
+
# A copy of self with a rounded @value.
|
69
|
+
# @return [TimeLength]
|
70
|
+
def round
|
71
|
+
self.class.new(@value.round)
|
72
|
+
end
|
73
|
+
|
46
74
|
# Converts @value to an Integer if it's a whole number, and returns self.
|
47
75
|
# @return [TimeLength]
|
48
76
|
def to_i_if_whole!
|
@@ -62,27 +90,25 @@ module Reading
|
|
62
90
|
self.class.new(@value.to_i)
|
63
91
|
end
|
64
92
|
|
65
|
-
#
|
66
|
-
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
93
|
+
# @param other [TimeLength, Numeric]
|
67
94
|
# @return [TimeLength]
|
68
95
|
def +(other)
|
69
96
|
if other.is_a? Item::TimeLength
|
70
97
|
self.class.new(value + other.value)
|
71
|
-
elsif other.
|
72
|
-
self
|
98
|
+
elsif other.is_a? Numeric
|
99
|
+
self.class.new(value + self.class.pages_to_minutes(other))
|
73
100
|
else
|
74
101
|
raise TypeError, "#{other.class} can't be added to Item::TimeLength."
|
75
102
|
end
|
76
103
|
end
|
77
104
|
|
78
|
-
#
|
79
|
-
# @param other [TimeLength, Integer] must be zero if it's an Integer.
|
105
|
+
# @param other [TimeLength, Numeric]
|
80
106
|
# @return [TimeLength]
|
81
107
|
def -(other)
|
82
108
|
if other.is_a? Item::TimeLength
|
83
109
|
self.class.new(value - other.value)
|
84
|
-
elsif other.
|
85
|
-
self
|
110
|
+
elsif other.is_a? Numeric
|
111
|
+
self.class.new(value - self.class.pages_to_minutes(other))
|
86
112
|
else
|
87
113
|
raise TypeError, "#{other.class} can't be subtracted from Item::TimeLength."
|
88
114
|
end
|
@@ -108,25 +134,23 @@ module Reading
|
|
108
134
|
end
|
109
135
|
end
|
110
136
|
|
111
|
-
#
|
112
|
-
#
|
113
|
-
# @param other [Integer] must be zero.
|
137
|
+
# See https://web.archive.org/web/20221206095821/https://www.mutuallyhuman.com/blog/class-coercion-in-ruby/
|
138
|
+
# @param other [Numeric]
|
114
139
|
def coerce(other)
|
115
|
-
if other.
|
116
|
-
[self.class.
|
140
|
+
if other.is_a? Numeric
|
141
|
+
[self.class.from_pages(other), self]
|
117
142
|
else
|
118
|
-
raise TypeError, "
|
143
|
+
raise TypeError, "#{other.class} can't be coerced into a TimeLength."
|
119
144
|
end
|
120
145
|
end
|
121
146
|
|
122
|
-
#
|
123
|
-
# @
|
147
|
+
# @param other [TimeLength, Numeric]
|
148
|
+
# @return [Integer]
|
124
149
|
def <=>(other)
|
125
150
|
return 1 if other.nil?
|
126
151
|
|
127
|
-
if other.
|
128
|
-
|
129
|
-
return 1
|
152
|
+
if other.is_a? Numeric
|
153
|
+
other = self.class.from_pages(other)
|
130
154
|
end
|
131
155
|
|
132
156
|
unless other.is_a? Item::TimeLength
|
@@ -135,6 +159,19 @@ module Reading
|
|
135
159
|
|
136
160
|
value <=> other.value
|
137
161
|
end
|
162
|
+
|
163
|
+
# Must be implemented for hash key equality checks.
|
164
|
+
# @param other [TimeLength, Numeric]
|
165
|
+
# @return [Boolean]
|
166
|
+
def eql?(other)
|
167
|
+
hash == other.hash
|
168
|
+
end
|
169
|
+
|
170
|
+
# Must be implemented along with #eql? for hash key equality checks.
|
171
|
+
# @return [Integer]
|
172
|
+
def hash
|
173
|
+
value
|
174
|
+
end
|
138
175
|
end
|
139
176
|
end
|
140
177
|
end
|
data/lib/reading/item/view.rb
CHANGED
@@ -9,13 +9,12 @@ module Reading
|
|
9
9
|
:isbn, :url, :experience_count, :groups, :blurb, :public_notes
|
10
10
|
|
11
11
|
# @param item [Item] the Item from which to extract view information.
|
12
|
-
|
13
|
-
def initialize(item, config)
|
12
|
+
def initialize(item)
|
14
13
|
@genres = item.genres
|
15
|
-
@rating = extract_star_or_rating(item
|
16
|
-
@isbn, @url, variant = extract_first_source_info(item
|
17
|
-
@name = extract_name(item, variant
|
18
|
-
@type_emoji = extract_type_emoji(variant&.format
|
14
|
+
@rating = extract_star_or_rating(item)
|
15
|
+
@isbn, @url, variant = extract_first_source_info(item)
|
16
|
+
@name = extract_name(item, variant)
|
17
|
+
@type_emoji = extract_type_emoji(variant&.format)
|
19
18
|
@date_or_status = extract_date_or_status(item)
|
20
19
|
@experience_count = item.experiences.count
|
21
20
|
@groups = item.experiences.map(&:group).compact
|
@@ -28,10 +27,9 @@ module Reading
|
|
28
27
|
# A star (or nil if the item doesn't make the cut), or the number rating if
|
29
28
|
# star ratings are disabled.
|
30
29
|
# @param item [Item]
|
31
|
-
# @param config [Hash] an entire config.
|
32
30
|
# @return [String, Integer, Float]
|
33
|
-
def extract_star_or_rating(item
|
34
|
-
minimum_rating =
|
31
|
+
def extract_star_or_rating(item)
|
32
|
+
minimum_rating = Config.hash.deep_fetch(:item, :view, :minimum_rating_for_star)
|
35
33
|
if minimum_rating
|
36
34
|
"⭐" if item.rating && item.rating >= minimum_rating
|
37
35
|
else
|
@@ -44,13 +42,12 @@ module Reading
|
|
44
42
|
# an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
|
45
43
|
# Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
|
46
44
|
# @param item [Item]
|
47
|
-
# @param config [Hash] an entire config.
|
48
45
|
# @return [Array(String, String, Symbol, Array<String>)]
|
49
|
-
def extract_first_source_info(item
|
46
|
+
def extract_first_source_info(item)
|
50
47
|
item.variants.map { |variant|
|
51
48
|
isbn = variant.isbn
|
52
49
|
if isbn
|
53
|
-
url =
|
50
|
+
url = Config.hash.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
|
54
51
|
else
|
55
52
|
url = variant.sources.map { |source| source.url }.compact.first
|
56
53
|
end
|
@@ -64,9 +61,8 @@ module Reading
|
|
64
61
|
# The view name of the item.
|
65
62
|
# @param item [Item]
|
66
63
|
# @param variant [Data, nil] a variant from the Item.
|
67
|
-
# @param config [Hash] an entire config.
|
68
64
|
# @return [String]
|
69
|
-
def extract_name(item, variant
|
65
|
+
def extract_name(item, variant)
|
70
66
|
author_and_title = "#{item.author + " – " if item.author}#{item.title}"
|
71
67
|
return author_and_title if variant.nil?
|
72
68
|
|
@@ -79,7 +75,7 @@ module Reading
|
|
79
75
|
end
|
80
76
|
}
|
81
77
|
|
82
|
-
name_separator =
|
78
|
+
name_separator = Config.hash.deep_fetch(:item, :view, :name_separator)
|
83
79
|
series_and_extra_info = name_separator +
|
84
80
|
(pretty_series + variant.extra_info).join(name_separator)
|
85
81
|
end
|
@@ -89,10 +85,9 @@ module Reading
|
|
89
85
|
|
90
86
|
# The emoji for the type that represents (encompasses) a given format.
|
91
87
|
# @param format [Symbol, nil]
|
92
|
-
# @param config [Hash] an entire config.
|
93
88
|
# @return [String]
|
94
|
-
def extract_type_emoji(format
|
95
|
-
types =
|
89
|
+
def extract_type_emoji(format)
|
90
|
+
types = Config.hash.deep_fetch(:item, :view, :types)
|
96
91
|
|
97
92
|
return types.deep_fetch(format, :emoji) if types.has_key?(format)
|
98
93
|
|
@@ -101,7 +96,7 @@ module Reading
|
|
101
96
|
&.first # key
|
102
97
|
|
103
98
|
types.deep_fetch(
|
104
|
-
type ||
|
99
|
+
type || Config.hash.deep_fetch(:item, :view, :default_type),
|
105
100
|
:emoji,
|
106
101
|
)
|
107
102
|
end
|