pigeons 0.0.1pre
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/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +676 -0
- data/README.md +29 -0
- data/Rakefile +10 -0
- data/lib/generators/pigeons/install_generator.rb +42 -0
- data/lib/generators/pigeons/templates/pigeon_letter.rb +30 -0
- data/lib/generators/pigeons/templates/pigeon_letter_migration.rb +16 -0
- data/lib/generators/pigeons/templates/pigeon_mailer.rb +8 -0
- data/lib/generators/pigeons/templates/pigeons.json +5 -0
- data/lib/pigeons/checks.rb +98 -0
- data/lib/pigeons/elements.rb +35 -0
- data/lib/pigeons/errors.rb +8 -0
- data/lib/pigeons/extensions.rb +34 -0
- data/lib/pigeons/logger.rb +15 -0
- data/lib/pigeons/pigeons.rb +156 -0
- data/lib/pigeons/pigeons_tasks.rb +8 -0
- data/lib/pigeons/scope.rb +205 -0
- data/lib/pigeons/version.rb +3 -0
- data/lib/pigeons.rb +10 -0
- data/pigeons.gemspec +29 -0
- data/tasks/pigeons.rake +142 -0
- data/test/test_helper.rb +143 -0
- data/test/test_pigeons.rb +429 -0
- metadata +200 -0
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Pigeons
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'pigeons'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install pigeons
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
|
4
|
+
module Pigeons
|
5
|
+
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
source_root File.expand_path('../templates', __FILE__)
|
10
|
+
|
11
|
+
# desc "Some description of my generator here"
|
12
|
+
|
13
|
+
# Commandline options can be defined here using Thor-like options:
|
14
|
+
# class_option :my_opt, :type => :boolean, :default => false, :desc => "My Option"
|
15
|
+
|
16
|
+
# I can later access that option using:
|
17
|
+
# options[:my_opt]
|
18
|
+
|
19
|
+
# Generator Code. Remember this is just suped-up Thor so methods are executed in order
|
20
|
+
def generate_pigeons_json
|
21
|
+
copy_file "pigeons.json", "config/pigeons.json"
|
22
|
+
end
|
23
|
+
|
24
|
+
def generate_pigeon_letter
|
25
|
+
copy_file "pigeon_letter.rb", "app/models/pigeon_letter.rb"
|
26
|
+
end
|
27
|
+
|
28
|
+
def generate_pigeon_letter_migration
|
29
|
+
migration_template "pigeon_letter_migration.rb", "db/migrate/create_pigeon_letters"
|
30
|
+
end
|
31
|
+
|
32
|
+
def generate_pigeon_mailer
|
33
|
+
copy_file "pigeon_mailer.rb", "app/mailers/pigeon_mailer.rb"
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.next_migration_number(dirname)
|
37
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Represents a letter sent by Pigeons
|
2
|
+
#
|
3
|
+
# Attributes
|
4
|
+
# letter_type:: Type of letter being sent
|
5
|
+
# cargo_id:: Associated object id (e.g. a <user_id>) *polymorphic
|
6
|
+
# cargo_type:: Assoication type (e.g. User) *polymorphic
|
7
|
+
# sent_at:: Time letter was sent
|
8
|
+
# flight:: What flight was this sent on?
|
9
|
+
class PigeonLetter < ActiveRecord::Base
|
10
|
+
|
11
|
+
### Associations
|
12
|
+
belongs_to :cargo, polymorphic: true
|
13
|
+
|
14
|
+
### Attributes
|
15
|
+
attr_accessible :letter_type, :cargo, :cargo_id, :cargo_type, :flight
|
16
|
+
|
17
|
+
### Validations
|
18
|
+
validates_presence_of :letter_type
|
19
|
+
validates_presence_of :cargo_id
|
20
|
+
validates_presence_of :cargo_type
|
21
|
+
validates_presence_of :flight
|
22
|
+
|
23
|
+
### Member Functions
|
24
|
+
|
25
|
+
def send!
|
26
|
+
PigeonMailer.send(letter_type, cargo, flight).deliver
|
27
|
+
self.update_attribute(:sent_at, Time.now)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreatePigeonLetters < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :pigeon_letters do |t|
|
4
|
+
t.string :letter_type
|
5
|
+
t.string :flight
|
6
|
+
t.string :cargo_type
|
7
|
+
t.integer :cargo_id
|
8
|
+
t.datetime :sent_at
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :pigeon_letters, [ :cargo_id, :cargo_type ], :name => "index_pigeon_letters_cargo_id_cargo_type"
|
14
|
+
add_index :pigeon_letters, [ :letter_type ], :name => "index_pigeon_letters_letter_type"
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Pigeons
|
2
|
+
|
3
|
+
module Checks
|
4
|
+
|
5
|
+
# We're going to try to factor all major queries into individual functions
|
6
|
+
def self.no_recent_letter_check(args)
|
7
|
+
scope = args[:scope]
|
8
|
+
pigeon_arel = args[:pigeon]
|
9
|
+
source_arel = args[:source]
|
10
|
+
|
11
|
+
scope.where(
|
12
|
+
PigeonLetter.where(
|
13
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
14
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
15
|
+
).and(
|
16
|
+
pigeon_arel[:created_at].gt(Pigeons::Settings.cooldown.ago)
|
17
|
+
)
|
18
|
+
).exists.not
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.non_recurring_check(args)
|
23
|
+
scope = args[:scope]
|
24
|
+
pigeon_arel = args[:pigeon]
|
25
|
+
source_arel = args[:source]
|
26
|
+
letter = args[:letter]
|
27
|
+
|
28
|
+
scope.where(
|
29
|
+
PigeonLetter.where(
|
30
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
31
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
32
|
+
).and(
|
33
|
+
pigeon_arel[:created_at].not_eq(nil)
|
34
|
+
).and(
|
35
|
+
pigeon_arel[:letter_type].eq(letter)
|
36
|
+
)
|
37
|
+
).exists.not
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.recurring_check(args)
|
42
|
+
scope = args[:scope]
|
43
|
+
pigeon_arel = args[:pigeon]
|
44
|
+
source_arel = args[:source]
|
45
|
+
letter = args[:letter]
|
46
|
+
relative_time = args[:relative_time]
|
47
|
+
|
48
|
+
scope.where(
|
49
|
+
PigeonLetter.where(
|
50
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
51
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
52
|
+
).and(
|
53
|
+
pigeon_arel[:created_at].gt(relative_time)
|
54
|
+
).and(
|
55
|
+
pigeon_arel[:letter_type].eq(letter)
|
56
|
+
)
|
57
|
+
).exists.not
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.after_letter_check(args)
|
62
|
+
scope = args[:scope]
|
63
|
+
pigeon_arel = args[:pigeon]
|
64
|
+
source_arel = args[:source]
|
65
|
+
letters = args[:letters]
|
66
|
+
relative_action = args[:relative_action]
|
67
|
+
relative_time = args[:relative_time]
|
68
|
+
|
69
|
+
# TODO: We really need to sit down and think about what this check means
|
70
|
+
# Right now, it's going to mean
|
71
|
+
# All letters of letters type must have been made after relative_time ago
|
72
|
+
# And there must exist a letter of some such type
|
73
|
+
# As in, "we need the existence of a letter of type X or Y, and no letter of type X or Y may have been sent in the last .. days"
|
74
|
+
|
75
|
+
scope.where(
|
76
|
+
PigeonLetter.where(
|
77
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
78
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
79
|
+
).and(
|
80
|
+
pigeon_arel[:letter_type].in(letters)
|
81
|
+
)
|
82
|
+
).exists
|
83
|
+
).where(
|
84
|
+
PigeonLetter.where(
|
85
|
+
pigeon_arel[:cargo_id].eq(source_arel[:id]).and(
|
86
|
+
pigeon_arel[:cargo_type].eq(source_arel.name.classify)
|
87
|
+
).and(
|
88
|
+
pigeon_arel[:letter_type].in(letters)
|
89
|
+
).and(
|
90
|
+
relative_action == :after ?
|
91
|
+
pigeon_arel[:created_at].gt(relative_time) : nil # TODO: nil?
|
92
|
+
)
|
93
|
+
).exists.not
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
|
2
|
+
module Pigeons
|
3
|
+
|
4
|
+
module Elements
|
5
|
+
|
6
|
+
# This is the beast of our regex base parsing library
|
7
|
+
# He is going to parse all discernable elements from the string
|
8
|
+
# And send it back as elements
|
9
|
+
def self.parse_elements(letter)
|
10
|
+
article = /a(?:[n]?|ll)/ix
|
11
|
+
base_element = /(#{article}\s+)? (?<base_element>[\w\s]+?)/ix
|
12
|
+
conditionals = /(who((\'ve)|(\s+ have))?\s+) (?<conditionals>[\w\s]+)/ix
|
13
|
+
letter_type = /(#{article}\s+)? (?<letter>[\w\s]+) \s+ letter/ix
|
14
|
+
time_metric = /( (?<time_metric_coefficient>\d+) \s+)? (?<time_metric_unit>time|second|minute|hour|day|week|fortnight|month|year)(s?)/ix
|
15
|
+
time_qualifier = /(?<relative>after) \s+ (?<time_item>[^.!$]+)/ix
|
16
|
+
time_clause = /((?<recurring>every)\s+)? (?<time_metric>#{time_metric})? (\s*#{time_qualifier})?/ix
|
17
|
+
|
18
|
+
grammar = /^\s* # Allow whitespace
|
19
|
+
((?<joiner>and|then)\s+)? # Joiner is and or then
|
20
|
+
(#{base_element} \s+)? # Base element as ActiveRecord type or base block
|
21
|
+
(#{conditionals} \s+)? # Who ... condition block
|
22
|
+
get(s)? \s+ # get
|
23
|
+
#{letter_type} # a ... letter
|
24
|
+
(\s+ #{time_clause})? # after letter or event block
|
25
|
+
\s*[.!]? # optional punctuation
|
26
|
+
\s* # allow whitespace
|
27
|
+
$/ix
|
28
|
+
|
29
|
+
# Pigeons::Logger.debug [ 'Grammar', grammar ]
|
30
|
+
grammar.match(letter)
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Pigeons
|
2
|
+
|
3
|
+
# Extensions will tell Pigeon how to parse flights for app-specific context
|
4
|
+
class Extension
|
5
|
+
# base /regexp/ { return scope }
|
6
|
+
|
7
|
+
protected
|
8
|
+
|
9
|
+
def self.base(matcher, &clause)
|
10
|
+
matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
|
11
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
12
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
13
|
+
|
14
|
+
Pigeons.add_base(matcher, clause)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.condition(matcher, &clause)
|
18
|
+
matcher = Regexp.new(matcher, "i") if matcher.is_a?(String)
|
19
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
20
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
21
|
+
|
22
|
+
Pigeons.add_conditional(matcher, clause)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.event(matcher, &clause)
|
26
|
+
matcher = Regep.new(matcher, "i") if matcher.is_a?(String)
|
27
|
+
raise ArgumentError, "First argument must be a string or regular expression" unless matcher.is_a?(Regexp)
|
28
|
+
raise ArgumentError, "Must include block which returns scope" unless block_given?
|
29
|
+
|
30
|
+
Pigeons.add_event(matcher, clause)
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
|
2
|
+
# Pigeon controls the Carrier Pigeon system
|
3
|
+
# Note, we're going to try to make this not require Rails - but it may require some aspects of ActiveSupport, etc.
|
4
|
+
|
5
|
+
module Pigeons
|
6
|
+
|
7
|
+
class Settings
|
8
|
+
# Initialize class attributes
|
9
|
+
class_attribute :pigeon_config_file
|
10
|
+
class_attribute :cooldown # Limit time between sending entity a second letter
|
11
|
+
class_attribute :bases, :conditionals, :events
|
12
|
+
|
13
|
+
# Set Defaults
|
14
|
+
self.pigeon_config_file = nil
|
15
|
+
self.cooldown = 2.days
|
16
|
+
|
17
|
+
# Initialize
|
18
|
+
self.bases = []
|
19
|
+
self.conditionals = []
|
20
|
+
self.events = []
|
21
|
+
end
|
22
|
+
|
23
|
+
# returns will be { flight_name: [ { letter: letter_a, query: sql query, count: result count, entity: type of entity }, { ... } ] } ...
|
24
|
+
def self.assemble(options={})
|
25
|
+
opts = {
|
26
|
+
debug: false,
|
27
|
+
deep_debug: false, # include flight results in debug output
|
28
|
+
send: true
|
29
|
+
}.with_indifferent_access.merge(options)
|
30
|
+
|
31
|
+
'''Assembles all base entities deserving a post in this flight'''
|
32
|
+
|
33
|
+
# First, we're going to check that PigeonMailer and PigeonLetter exist (and are as we'd expect)
|
34
|
+
require 'pigeon_letter' unless defined?(PigeonLetter)
|
35
|
+
require 'pigeon_mailer' unless defined?(PigeonMailer)
|
36
|
+
|
37
|
+
# We'll set a natural default location if Rails is defined
|
38
|
+
Settings.pigeon_config_file ||= "#{Rails.root}/config/pigeons.json" if defined?(Rails)
|
39
|
+
|
40
|
+
raise PigeonError::PigeonConfigError, "Must create PigeonMailer mailer (TODO: instructions)" unless defined?(PigeonMailer) && ( PigeonMailer < ActionMailer::Base )
|
41
|
+
raise PigeonError::PigeonConfigError, "Must create PigeonLetter model (TODO: instructions)" unless defined?(PigeonLetter) && ( PigeonLetter < ActiveRecord::Base )
|
42
|
+
|
43
|
+
# Next, we're going to pull the configuration JSON file
|
44
|
+
raise PigeonError::PigeonConfigError, "Must set pigeon_config_file" if Settings.pigeon_config_file.blank?
|
45
|
+
raise PigeonError::PigeonConfigError, "Missing pigeon configuration file: #{Settings.pigeon_config_file}" if !File.exists?(Settings.pigeon_config_file)
|
46
|
+
|
47
|
+
pigeon_config = begin
|
48
|
+
JSON(File.read(Settings.pigeon_config_file)) # TODO: Caching?
|
49
|
+
rescue JSON::ParserError => e
|
50
|
+
raise PigeonError::PigeonConfigError, "Error parsing pigeon configuration file: #{e.inspect}"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Flights are the "cohorts" defined what and how to send letters to our entities (e.g. users)
|
54
|
+
flights = Hash[pigeon_config['flights'].sort]
|
55
|
+
raise PigeonError::PigeonConfigError, "Configuration must include flights object" if flights.nil?
|
56
|
+
|
57
|
+
results = {}
|
58
|
+
|
59
|
+
# Note, order here is important (for mod i), and thus, we've sorted flights alphabeticaly to maintain consistency
|
60
|
+
flights.each_with_index do |(flight, flight_info), flight_id|
|
61
|
+
Pigeons::Logger.log [ 'Pigeons::', 'Flight::', flight ] if opts[:debug]
|
62
|
+
|
63
|
+
results[flight] = []
|
64
|
+
|
65
|
+
# We'll allow flight: [ ... ] instead of flight: { letters: [ ... ] } for shorthand if other options are not needed
|
66
|
+
flight_info = { 'letters' => flight_info } if flight_info.is_a?(Array)
|
67
|
+
|
68
|
+
base_scope = default_base_scope = if flight_info['base'] # Note, this will usually be nil, but it can be set specifically
|
69
|
+
Scope.get_base_scope(flight_info['base'])
|
70
|
+
end
|
71
|
+
|
72
|
+
raise PigeonError::PigeonFlightConfigError, "Letters must be present in flight" if flight_info['letters'].empty?
|
73
|
+
|
74
|
+
previous_letters = []
|
75
|
+
|
76
|
+
flight_info['letters'].each_with_index do |letter_info, i|
|
77
|
+
|
78
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight}", "Letter Info", letter_info ] if opts[:debug]
|
79
|
+
# This is going to parse the letter info and make sure it makes sense
|
80
|
+
elements = Elements.parse_elements(letter_info)
|
81
|
+
|
82
|
+
# Let's see if we were able to parse letter info
|
83
|
+
raise PigeonError::PigeonFlightConfigError, "Failed to parse letter info for flight #{flight}: \"#{letter_info}\". Should be like \"someone\" gets a \"type a\" letter \"sometime\" after \"some event\"" if elements.nil?
|
84
|
+
|
85
|
+
# Otherwise, convert capture groups to a nice hash
|
86
|
+
elements = Hash[elements.names.zip(elements.captures)].with_indifferent_access
|
87
|
+
|
88
|
+
# args such as default base scope? maybe we should change to an arguments hash
|
89
|
+
# note: for running conditionals, we could verify they skip the previous scopes, but this is going to
|
90
|
+
# end up a complicated query. we're going to rely on letter-existence skipping instead
|
91
|
+
scope_res = Scope.get_scope(elements: elements, previous_base_scope: base_scope, previous_letters: previous_letters, flights: flights, flight: flight, flight_id: flight_id, letter_info: letter_info, opts: opts)
|
92
|
+
|
93
|
+
scope = scope_res[:scope]
|
94
|
+
base_scope = scope_res[:base_scope]
|
95
|
+
previous_letters = scope_res[:previous_letters]
|
96
|
+
letter = scope_res[:letter]
|
97
|
+
entity = scope_res[:entity]
|
98
|
+
|
99
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Letter", letter_info ] if opts[:debug]
|
100
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Scope", scope.explain ] if opts[:debug]
|
101
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Count", scope.count ] if opts[:debug]
|
102
|
+
Pigeons::Logger.debug [ 'Pigeons::', "Flight #{flight} Results", scope ] if opts[:deep_debug]
|
103
|
+
|
104
|
+
results[flight].push(letter: letter, query: scope.to_sql, count: scope.count, entity: entity, scope: scope)
|
105
|
+
|
106
|
+
# TODO: We could break this out?
|
107
|
+
self.send_letter(scope, letter, flight) if opts[:send]
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
results
|
112
|
+
end
|
113
|
+
|
114
|
+
# Add base will add a match clause to our checks in parsing
|
115
|
+
# scope_block is a proc that will return a scope used in further flight tests
|
116
|
+
def self.add_base(matcher, clause)
|
117
|
+
Settings.bases.push(matcher: matcher, clause: clause)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Add a conditional block
|
121
|
+
# If a condition matches this conditional block, it will append its where clause
|
122
|
+
def self.add_conditional(matcher, clause)
|
123
|
+
Settings.conditionals.push(matcher: matcher, clause: clause)
|
124
|
+
end
|
125
|
+
|
126
|
+
def self.add_event(matcher, clause)
|
127
|
+
Settings.events.push(matcher: matcher, clause: clause)
|
128
|
+
end
|
129
|
+
|
130
|
+
public # TODO: should these change to private?
|
131
|
+
|
132
|
+
# This is responsible for the actual send letter aspect
|
133
|
+
# It will create PigeonLetters and call delivery
|
134
|
+
# to PigeonMailer
|
135
|
+
def self.send_letter(scope, letter_type, flight)
|
136
|
+
# The question we face here is, do we create letters one-by-one, or do we create the letters in bulk
|
137
|
+
# possibly updating sent_at after sending
|
138
|
+
|
139
|
+
# Also, how do we send letters?
|
140
|
+
# Is that asynchronous?
|
141
|
+
|
142
|
+
# Let's just do this quick and dirty and come back to this
|
143
|
+
scope.find_each do |cargo|
|
144
|
+
letter = PigeonLetter.create!(cargo: cargo, letter_type: letter_type, flight: flight)
|
145
|
+
|
146
|
+
# Send the letter -- note, we may want to also send info about flights for tracking
|
147
|
+
begin
|
148
|
+
letter.send!
|
149
|
+
rescue => e # We're going to rescue here to save ourselves the humiliation of failing.
|
150
|
+
# It would be nice to log this at the PigeonLetter (database) or return level
|
151
|
+
p [ 'Pigeons::SendLetter', 'Failed to send letter', e ]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|