brewser 0.1.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.
- data/MIT-LICENSE +20 -0
- data/README.md +13 -0
- data/lib/brewser.rb +32 -0
- data/lib/brewser/brewson.rb +5 -0
- data/lib/brewser/engines.rb +18 -0
- data/lib/brewser/engines/beerxml.rb +433 -0
- data/lib/brewser/engines/beerxml2.rb +28 -0
- data/lib/brewser/engines/brewson.rb +20 -0
- data/lib/brewser/engines/promash_rec.rb +424 -0
- data/lib/brewser/engines/promash_txt.rb +214 -0
- data/lib/brewser/exceptions.rb +11 -0
- data/lib/brewser/json-validation.rb +12 -0
- data/lib/brewser/model/additive.rb +14 -0
- data/lib/brewser/model/base.rb +45 -0
- data/lib/brewser/model/batch.rb +27 -0
- data/lib/brewser/model/fermentable.rb +28 -0
- data/lib/brewser/model/fermentation_schedule.rb +7 -0
- data/lib/brewser/model/fermentation_steps.rb +12 -0
- data/lib/brewser/model/hop.rb +27 -0
- data/lib/brewser/model/mash_schedule.rb +12 -0
- data/lib/brewser/model/mash_steps.rb +24 -0
- data/lib/brewser/model/recipe.rb +50 -0
- data/lib/brewser/model/style.rb +29 -0
- data/lib/brewser/model/units.rb +102 -0
- data/lib/brewser/model/water_profile.rb +16 -0
- data/lib/brewser/model/yeast.rb +29 -0
- data/lib/brewser/ruby-units.rb +31 -0
- data/lib/brewser/version.rb +3 -0
- data/spec/basic_spec.rb +39 -0
- data/spec/beerxml_spec.rb +159 -0
- data/spec/promash_spec.rb +194 -0
- data/spec/spec_helper.rb +29 -0
- metadata +187 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
class ProMashTxt < Brewser::Engine
|
|
2
|
+
|
|
3
|
+
class << self
|
|
4
|
+
|
|
5
|
+
def acceptable?(q)
|
|
6
|
+
begin
|
|
7
|
+
q.match("A ProMash Recipe Report") ? true : false
|
|
8
|
+
rescue
|
|
9
|
+
false
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deserialize(string)
|
|
14
|
+
object = string.match("A ProMash Recipe Report") ? ProMashTxt::Recipe.new : ProMashTxt::Batch.new
|
|
15
|
+
object.from_promash(string.split("\r\n").reject{|a|a==""})
|
|
16
|
+
return object
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#
|
|
24
|
+
class ProMashTxt::Hop < Brewser::Hop
|
|
25
|
+
|
|
26
|
+
def from_promash(string)
|
|
27
|
+
# String should look like this:
|
|
28
|
+
# 3.00 oz. Cascade Whole 4.35 46.6 60 min.
|
|
29
|
+
self.amount = string[0..8].strip.u
|
|
30
|
+
self.name = string[14..46].strip
|
|
31
|
+
self.form = string[48..55].strip
|
|
32
|
+
self.alpha_acids = string[56..61].strip.to_f
|
|
33
|
+
time = string[69..77].strip
|
|
34
|
+
case time
|
|
35
|
+
when "Dry Hop"
|
|
36
|
+
self.added_when = "Dry Hop"
|
|
37
|
+
when "Mash H"
|
|
38
|
+
self.added_when = "Mash"
|
|
39
|
+
when "First WH"
|
|
40
|
+
self.added_when = "First Wort"
|
|
41
|
+
else
|
|
42
|
+
self.added_when = "Boil"
|
|
43
|
+
self.time = time.chop.u
|
|
44
|
+
end
|
|
45
|
+
return self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class ProMashTxt::Fermentable < Brewser::Fermentable
|
|
51
|
+
|
|
52
|
+
def from_promash(string)
|
|
53
|
+
# String should look like this:
|
|
54
|
+
# 12.5 1.50 lbs. Victory Malt America 1.034 25
|
|
55
|
+
self.amount = string[8..17].strip.u
|
|
56
|
+
self.name = string[20..48].strip
|
|
57
|
+
self.potential = string[65..70].strip
|
|
58
|
+
self.color = string[74..77].strip.to_f
|
|
59
|
+
return self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class ProMashTxt::Additive < Brewser::Additive
|
|
65
|
+
|
|
66
|
+
def from_promash(string)
|
|
67
|
+
# String should look like this:
|
|
68
|
+
# 1.00 Tsp Irish Moss Fining 15 Min.(boil)
|
|
69
|
+
self.amount = string[0..10].strip.downcase.u
|
|
70
|
+
self.name = string[14..44].strip
|
|
71
|
+
self.type = string[45..54].strip
|
|
72
|
+
self.time = string[55..61].strip.downcase.gsub(".","").u
|
|
73
|
+
self.added_when = string[63..66].capitalize
|
|
74
|
+
return self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class ProMashTxt::Yeast < Brewser::Yeast
|
|
80
|
+
|
|
81
|
+
def from_promash(string)
|
|
82
|
+
self.name = string || "Unspecified"
|
|
83
|
+
return self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class ProMashTxt::MashStep < Brewser::MashStep
|
|
89
|
+
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class ProMashTxt::MashSchedule < Brewser::MashSchedule
|
|
93
|
+
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class ProMashTxt::FermentationStep < Brewser::FermentationStep
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class ProMashTxt::FermentationSchedule < Brewser::FermentationSchedule
|
|
101
|
+
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class ProMashTxt::WaterProfile < Brewser::WaterProfile
|
|
105
|
+
def from_promash(array)
|
|
106
|
+
self.name = /Profile:\s*(?<name>.*)/.match(array[0]){ |m| m[:name] } || "Unspecified"
|
|
107
|
+
self.calcium = /(?<calcium>\S*)\sppm$/.match(array[2]){ |m| m[:calcium] }
|
|
108
|
+
self.magnesium = /(?<magnesium>\S*)\sppm$/.match(array[3]){ |m| m[:magnesium] }
|
|
109
|
+
self.sodium = /(?<sodium>\S*)\sppm$/.match(array[4]){ |m| m[:sodium] }
|
|
110
|
+
self.sulfates = /(?<sulfate>\S*)\sppm$/.match(array[5]){ |m| m[:sulfate] }
|
|
111
|
+
self.chloride = /(?<chloride>\S*)\sppm$/.match(array[6]){ |m| m[:chloride] }
|
|
112
|
+
self.bicarbonate = /(?<bicarbonate>\S*)\sppm$/.match(array[7]){ |m| m[:bicarbonate] }
|
|
113
|
+
self.ph = /(?<ph>\S*)$/.match(array[8]){ |m| m[:ph] }
|
|
114
|
+
return self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class ProMashTxt::Style < Brewser::Style
|
|
120
|
+
|
|
121
|
+
def from_promash(array)
|
|
122
|
+
if pointer = array.index("BJCP Style and Style Guidelines")
|
|
123
|
+
self.style_guide = "BJCP"
|
|
124
|
+
elsif pointer = array.index("AHA Style and Style Guidelines")
|
|
125
|
+
self.style_guide = "AHA"
|
|
126
|
+
else
|
|
127
|
+
raise Error, "ProMashTxt engine: unable to find style guideline"
|
|
128
|
+
end
|
|
129
|
+
# Line @ pointer should look like this:
|
|
130
|
+
# 06-B American Pale Ales, American Amber Ale
|
|
131
|
+
match_data = /^(?<category_number>\d{2})-(?<style_letter>\w{1})\s*(?<category>(\w\s*)+)\,\s(?<name>(\w\s*)+)/.match(array[pointer+2])
|
|
132
|
+
if match_data
|
|
133
|
+
self.name = match_data[:name]
|
|
134
|
+
self.category = match_data[:category]
|
|
135
|
+
self.category_number = match_data[:category_number]
|
|
136
|
+
self.style_letter = match_data[:style_letter]
|
|
137
|
+
else
|
|
138
|
+
raise Error, "ProMashTxt engine: Unable to extract style details"
|
|
139
|
+
end
|
|
140
|
+
return self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class ProMashTxt::Recipe < Brewser::Recipe
|
|
146
|
+
|
|
147
|
+
def from_promash(array)
|
|
148
|
+
self.name=array[0]
|
|
149
|
+
self.style=ProMashTxt::Style.new.from_promash(array)
|
|
150
|
+
# Recipe specifics should look like this:
|
|
151
|
+
# Recipe Specifics
|
|
152
|
+
# ----------------
|
|
153
|
+
# Batch Size (Gal): 6.00 Wort Size (Gal): 6.00
|
|
154
|
+
# Total Grain (Lbs): 12.00
|
|
155
|
+
# Anticipated OG: 1.059 Plato: 14.52
|
|
156
|
+
# Anticipated SRM: 11.0
|
|
157
|
+
# Anticipated IBU: 53.5
|
|
158
|
+
# Brewhouse Efficiency: 80 %
|
|
159
|
+
# Wort Boil Time: 60 Minutes
|
|
160
|
+
# Can't tell the difference between P/M and A/G so use A/G
|
|
161
|
+
raise Error, "ProMashTxt engine: Unable to find recipe specifics" unless pointer = array.index("Recipe Specifics")
|
|
162
|
+
self.method = /Total Grain/.match(array[pointer+3]).nil? ? "Extract" : "All Grain"
|
|
163
|
+
/Batch Size \((?<volume_unit>\w+)\):\s*(?<batch_size>\S*)\s*Wort Size \(\w*\):\s*(?<boil_size>\S*)/.match(array[pointer+2]) do |match|
|
|
164
|
+
self.recipe_volume = "#{match[:batch_size]} #{match[:volume_unit].downcase}".u
|
|
165
|
+
self.boil_volume = "#{match[:boil_size]} #{match[:volume_unit].downcase}".u
|
|
166
|
+
end
|
|
167
|
+
/Anticipated OG:\s*(?<anticipated_og>\S*)/.match(array[pointer+4]) do |match|
|
|
168
|
+
self.estimated_og = match[:anticipated_og].to_f
|
|
169
|
+
end
|
|
170
|
+
/Anticipated SRM:\s*(?<anticipated_color>\S*)/.match(array[pointer+5]) do |match|
|
|
171
|
+
self.estimated_color = match[:anticipated_color].to_f
|
|
172
|
+
end
|
|
173
|
+
/Anticipated IBU:\s*(?<anticipated_ibu>\S*)/.match(array[pointer+6]) do |match|
|
|
174
|
+
self.estimated_ibu = match[:anticipated_ibu].to_f
|
|
175
|
+
end
|
|
176
|
+
if method == "All Grain"
|
|
177
|
+
/Brewhouse Efficiency:\s*(?<recipe_efficiency>\S*)/.match(array[pointer+7]) do |match|
|
|
178
|
+
self.recipe_efficiency = match[:recipe_efficiency].to_f
|
|
179
|
+
end
|
|
180
|
+
boil_time_pointer = pointer+8
|
|
181
|
+
else
|
|
182
|
+
boil_time_pointer = pointer+7
|
|
183
|
+
end
|
|
184
|
+
/Wort Boil Time:\s*(?<boil_time>\S*)/.match(array[boil_time_pointer]) do |match|
|
|
185
|
+
self.boil_time = "#{match[:boil_time]} min".u
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
hops_start = array.index("Hops")+3
|
|
189
|
+
hops_end = (array.index("Extras") || array.index("Yeast"))-1
|
|
190
|
+
array[hops_start..hops_end].each do |hop|
|
|
191
|
+
self.hops.push ProMashTxt::Hop.new.from_promash(hop)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
fermentables_start = array.index("Grain/Extract/Sugar")+3
|
|
195
|
+
fermentables_end = array.index("Hops")-2
|
|
196
|
+
array[fermentables_start..fermentables_end].each do |fermentable|
|
|
197
|
+
self.fermentables.push ProMashTxt::Fermentable.new.from_promash(fermentable)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
unless array.index("Extras").nil?
|
|
201
|
+
additives_start = array.index("Extras")+3
|
|
202
|
+
additives_end = array.index("Yeast")-1
|
|
203
|
+
array[additives_start..additives_end].each do |additive|
|
|
204
|
+
self.additives.push ProMashTxt::Additive.new.from_promash(additive)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
self.yeasts.push ProMashTxt::Yeast.new.from_promash(array[array.index("Yeast")+2])
|
|
209
|
+
self.water_profile = ProMashTxt::WaterProfile.new.from_promash(array[array.index("Water Profile")+2,array.index("Water Profile")+10])
|
|
210
|
+
|
|
211
|
+
return self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
# A general exception
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when behavior is not implemented, usually used in an abstract class.
|
|
6
|
+
class NotImplemented < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when removed code is called, an alternative solution is provided in message.
|
|
9
|
+
class ImplementationRemoved < Error; end
|
|
10
|
+
|
|
11
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class Additive < Model
|
|
3
|
+
belongs_to :recipe
|
|
4
|
+
|
|
5
|
+
property :name, String, :required => true
|
|
6
|
+
property :description, String, :length => 65535
|
|
7
|
+
property :type, String, :set => ['Spice', 'Fining', 'Water Agent', 'Herb', 'Flavor', 'Fruit', 'Other'], :required => true
|
|
8
|
+
property :added_when, String, :set => ['Boil', 'Mash', 'Primary', 'Secondary', 'Packaging'], :required => true
|
|
9
|
+
property :time, Time, :required => true
|
|
10
|
+
property :amount, WeightOrVolume, :required => true
|
|
11
|
+
|
|
12
|
+
property :use_for, String
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
|
|
3
|
+
class Model
|
|
4
|
+
require 'dm-core'
|
|
5
|
+
require 'dm-validations'
|
|
6
|
+
require 'active_support/inflector'
|
|
7
|
+
|
|
8
|
+
include DataMapper::Resource
|
|
9
|
+
require "#{File.dirname(__FILE__)}/units"
|
|
10
|
+
include Units
|
|
11
|
+
|
|
12
|
+
DataMapper.setup(:default, :adapter => 'in_memory')
|
|
13
|
+
def self.default_repository_name;:default;end
|
|
14
|
+
def self.auto_migrate_down!(rep);end
|
|
15
|
+
def self.auto_migrate_up!(rep);end
|
|
16
|
+
def self.auto_upgrade!(rep);end
|
|
17
|
+
|
|
18
|
+
def deep_json
|
|
19
|
+
h = {}
|
|
20
|
+
instance_variables.each do |e|
|
|
21
|
+
key = e[1..-1]
|
|
22
|
+
next if ["roxml_references", "_persistence_state", "_key"].include? key
|
|
23
|
+
o = instance_variable_get e.to_sym
|
|
24
|
+
h[key] = (o.respond_to? :deep_json) ? o.deep_json : o;
|
|
25
|
+
end
|
|
26
|
+
h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_json *a
|
|
30
|
+
deep_json.to_json *a
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def as_brewson
|
|
34
|
+
BrewSON.serialize(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def as_beerxml
|
|
38
|
+
BeerXML2.serialize(self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
%w(additive batch fermentable fermentation_schedule fermentation_steps hop mash_schedule mash_steps recipe style water_profile yeast).
|
|
42
|
+
each { |f| require "#{File.dirname(__FILE__)}/#{f}" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class Batch < Model
|
|
3
|
+
property :brewed_on, Date
|
|
4
|
+
property :batch_identifier, String
|
|
5
|
+
property :brewer, String
|
|
6
|
+
property :assistant_brewer, String
|
|
7
|
+
property :description, String, :length => 65535
|
|
8
|
+
|
|
9
|
+
has 1, :recipe
|
|
10
|
+
validates_presence_of :recipe
|
|
11
|
+
|
|
12
|
+
has 1, :system
|
|
13
|
+
|
|
14
|
+
property :batch_status, String, :set => ["Planning", "Preparing", "Brewing", "Primary", "Secondary",
|
|
15
|
+
"Lagering", "Conditioning", "Serving", "Dispatched"]
|
|
16
|
+
property :sparge_method, String, :set => ["Batch", "Fly", "No Sparge"]
|
|
17
|
+
property :oxygenation_method, String, :set => ["Shake", "Splash", "Air Injection", "Oxygen Injection"]
|
|
18
|
+
|
|
19
|
+
property :measured_og, Float
|
|
20
|
+
property :measured_fg, Float
|
|
21
|
+
property :computed_abv, Float
|
|
22
|
+
|
|
23
|
+
property :computed_extract_efficiency, Float
|
|
24
|
+
property :computed_brewhouse_efficiency, Float
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class Fermentable < Model
|
|
3
|
+
belongs_to :recipe
|
|
4
|
+
|
|
5
|
+
property :name, String, :required => true
|
|
6
|
+
property :origin, String, :length => 512
|
|
7
|
+
property :supplier, String, :length => 512
|
|
8
|
+
property :description, String, :length => 65535
|
|
9
|
+
|
|
10
|
+
property :type, String, :set => ['Grain', 'Sugar', 'Extract', 'Dry Extract', 'Adjunct'], :required => true
|
|
11
|
+
property :yield_percent, Float
|
|
12
|
+
property :potential, Float, :required => true
|
|
13
|
+
property :ppg, Integer
|
|
14
|
+
|
|
15
|
+
property :color, Float, :required => true
|
|
16
|
+
|
|
17
|
+
property :amount, Weight, :required => true
|
|
18
|
+
property :late_addition?, Boolean, :default => false
|
|
19
|
+
|
|
20
|
+
property :coarse_fine_diff, Float
|
|
21
|
+
property :moisture, Float
|
|
22
|
+
property :diastatic_power, Float
|
|
23
|
+
property :protein, Float
|
|
24
|
+
property :max_in_batch, Float
|
|
25
|
+
property :recommend_mash?, Boolean
|
|
26
|
+
property :ibu_gal_per_lb, Float
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class FermentationStep < Model
|
|
3
|
+
belongs_to :fermentation_schedule
|
|
4
|
+
|
|
5
|
+
property :name, String
|
|
6
|
+
property :purpose, String, :set => ['primary', 'secondary', 'tertiary', 'conditioning']
|
|
7
|
+
|
|
8
|
+
property :index, Integer
|
|
9
|
+
property :time, TimeInDays
|
|
10
|
+
property :temperature, Temperature
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class Hop < Model
|
|
3
|
+
belongs_to :recipe
|
|
4
|
+
|
|
5
|
+
property :name, String, :required => true
|
|
6
|
+
property :description, String, :length => 65535
|
|
7
|
+
|
|
8
|
+
property :type, String, :set => ['Bittering', 'Aroma', 'Both']
|
|
9
|
+
property :alpha_acids, Float, :required => true
|
|
10
|
+
property :beta_acids, Float
|
|
11
|
+
property :added_when, String, :set => ['Boil', 'Dry', 'Mash', 'FWH', 'Aroma', 'Hop Back', 'Infuser'], :required => true
|
|
12
|
+
property :time, Time
|
|
13
|
+
property :amount, Weight
|
|
14
|
+
|
|
15
|
+
property :form, String, :set => ['Pellet', 'Plug', 'Whole']
|
|
16
|
+
|
|
17
|
+
property :storageability, Float
|
|
18
|
+
property :origin, String, :length => 512
|
|
19
|
+
property :substitutes, String, :length => 512
|
|
20
|
+
property :humulene, Float
|
|
21
|
+
property :caryophyllene, Float
|
|
22
|
+
property :cohumulone, Float
|
|
23
|
+
property :myrcene, Float
|
|
24
|
+
property :farnsene, Float
|
|
25
|
+
property :total_oil, Float
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
class MashSchedule < Brewser::Model
|
|
3
|
+
belongs_to :recipe
|
|
4
|
+
|
|
5
|
+
property :name, String, :required => true
|
|
6
|
+
property :description, String
|
|
7
|
+
property :grain_temp, Temperature
|
|
8
|
+
property :sparge_temp, Temperature
|
|
9
|
+
|
|
10
|
+
has n, :mash_steps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Brewser
|
|
2
|
+
|
|
3
|
+
class MashStep < Model
|
|
4
|
+
belongs_to :mash_schedule
|
|
5
|
+
|
|
6
|
+
property :index, Integer
|
|
7
|
+
|
|
8
|
+
property :name, String, :required => true
|
|
9
|
+
property :description, String, :length => 65535
|
|
10
|
+
|
|
11
|
+
property :type, String, :set => ['Direct', 'Infusion', 'Decoction']
|
|
12
|
+
property :purpose, String, :set => ['acid_rest', 'protein_rest', 'saccharification_rest', 'mash_out']
|
|
13
|
+
|
|
14
|
+
property :step_volume, Volume
|
|
15
|
+
property :ramp_time, Time
|
|
16
|
+
|
|
17
|
+
property :infusion_volume, Volume
|
|
18
|
+
property :infusion_temperature, Temperature
|
|
19
|
+
|
|
20
|
+
property :rest_temperature, Temperature, :required => true
|
|
21
|
+
property :rest_time, Time, :required => true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|