directory_watcher 1.4.1 → 1.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/History.txt +10 -0
- data/README.txt +1 -1
- data/Rakefile +16 -5
- data/lib/directory_watcher.rb +166 -139
- data/lib/directory_watcher/collector.rb +283 -0
- data/lib/directory_watcher/configuration.rb +228 -0
- data/lib/directory_watcher/coolio_scanner.rb +61 -127
- data/lib/directory_watcher/em_scanner.rb +81 -153
- data/lib/directory_watcher/event.rb +72 -0
- data/lib/directory_watcher/eventable_scanner.rb +242 -0
- data/lib/directory_watcher/file_stat.rb +65 -0
- data/lib/directory_watcher/logable.rb +26 -0
- data/lib/directory_watcher/notifier.rb +49 -0
- data/lib/directory_watcher/paths.rb +55 -0
- data/lib/directory_watcher/rev_scanner.rb +68 -131
- data/lib/directory_watcher/scan.rb +72 -0
- data/lib/directory_watcher/scan_and_queue.rb +22 -0
- data/lib/directory_watcher/scanner.rb +26 -209
- data/lib/directory_watcher/threaded.rb +277 -0
- data/lib/directory_watcher/version.rb +8 -0
- data/spec/directory_watcher_spec.rb +37 -0
- data/spec/paths_spec.rb +7 -0
- data/spec/scanner_scenarios.rb +236 -0
- data/spec/spec_helper.rb +79 -0
- data/spec/utility_classes.rb +117 -0
- data/version.txt +1 -1
- metadata +123 -23
- data/bin/dw +0 -2
@@ -0,0 +1,283 @@
|
|
1
|
+
# Collector reads items from a collection Queue and processes them to see if
|
2
|
+
# FileEvents should be put onto the notification Queue.
|
3
|
+
#
|
4
|
+
class DirectoryWatcher::Collector
|
5
|
+
include DirectoryWatcher::Threaded
|
6
|
+
include DirectoryWatcher::Logable
|
7
|
+
|
8
|
+
# Create a new StatCollector from the given Configuration, and an optional
|
9
|
+
# Scan.
|
10
|
+
#
|
11
|
+
# configuration - The Collector uses from Configuration:
|
12
|
+
# collection_queue - The Queue to read items from the Scanner on
|
13
|
+
# notification_queue - The Queue to submit the Events to the Notifier on
|
14
|
+
# stable - The number of times we see a file hasn't changed before
|
15
|
+
# emitting a stable event
|
16
|
+
# sort_by - the method used to sort events during on_scan results
|
17
|
+
# order_by - The method used to order events from call to on_scan
|
18
|
+
#
|
19
|
+
# pre_load_scan - A Scan to use to load our internal state from before. No
|
20
|
+
# events will be emitted for the FileStat's in this scan.
|
21
|
+
#
|
22
|
+
#def initialize( notification_queue, collection_queue, options = {} )
|
23
|
+
def initialize( config )
|
24
|
+
@stats = Hash.new
|
25
|
+
@stable_counts = Hash.new
|
26
|
+
@config = config
|
27
|
+
on_scan( DirectoryWatcher::Scan.new( config.glob ), false ) if config.pre_load?
|
28
|
+
self.interval = 0.01 # yes this is a fast loop
|
29
|
+
end
|
30
|
+
|
31
|
+
# The number of times we see a file hasn't changed before emitting a stable
|
32
|
+
# count. See Configuration#stable
|
33
|
+
def stable_threshold
|
34
|
+
@config.stable
|
35
|
+
end
|
36
|
+
|
37
|
+
# How to sort Scan results. See Configuration.
|
38
|
+
#
|
39
|
+
def sort_by
|
40
|
+
@config.sort_by
|
41
|
+
end
|
42
|
+
|
43
|
+
# How to order Scan results. See Configuration.
|
44
|
+
#
|
45
|
+
def order_by
|
46
|
+
@config.order_by
|
47
|
+
end
|
48
|
+
|
49
|
+
# The queue from which to read items from the scanners. See Configuration.
|
50
|
+
#
|
51
|
+
def collection_queue
|
52
|
+
@config.collection_queue
|
53
|
+
end
|
54
|
+
|
55
|
+
# The queue to write Events for the Notifier. See Configuration.
|
56
|
+
#
|
57
|
+
def notification_queue
|
58
|
+
@config.notification_queue
|
59
|
+
end
|
60
|
+
|
61
|
+
# Given the scan, update the set of stats with the results from the Scan and
|
62
|
+
# emit events to the notification queue as appropriate.
|
63
|
+
#
|
64
|
+
# scan - The Scan containing all the new FileStat items
|
65
|
+
# emit_events - Should events be emitted for the events in the scan
|
66
|
+
# (default: true)
|
67
|
+
#
|
68
|
+
# There is one odd thing that happens here. Scanners that are EventableScanners
|
69
|
+
# use on_stat to emit removed events, and the standard threaded Scanner only
|
70
|
+
# uses Scans. So we make sure and only emit removed events in this method if
|
71
|
+
# the scanner that gave us the scan was the basic threaded Scanner.
|
72
|
+
#
|
73
|
+
# TODO: Possibly fix this through another abstraction in the Scanners.
|
74
|
+
# No idea about what that would be yet.
|
75
|
+
#
|
76
|
+
# Returns nothing.
|
77
|
+
#
|
78
|
+
def on_scan( scan, emit_events = true )
|
79
|
+
seen_paths = Set.new
|
80
|
+
logger.debug "Sorting by #{sort_by} #{order_by}"
|
81
|
+
sorted_stats( scan.run ).each do |stat|
|
82
|
+
on_stat(stat, emit_events)
|
83
|
+
seen_paths << stat.path
|
84
|
+
end
|
85
|
+
emit_removed_events(seen_paths) if @config.scanner.nil?
|
86
|
+
end
|
87
|
+
|
88
|
+
# Process a single stat and emit an event if necessary.
|
89
|
+
#
|
90
|
+
# stat - The new FileStat to process and see if an event should
|
91
|
+
# be emitted
|
92
|
+
# emit_event - Whether or not an event should be emitted.
|
93
|
+
#
|
94
|
+
# Returns nothing
|
95
|
+
def on_stat( stat, emit_event = true )
|
96
|
+
orig_stat = update_stat( stat )
|
97
|
+
logger.debug "Emitting event for on_stat #{stat}"
|
98
|
+
emit_event_for( orig_stat, stat ) if emit_event
|
99
|
+
end
|
100
|
+
|
101
|
+
# Remove one item from the collection queue and process it.
|
102
|
+
#
|
103
|
+
# This method is required by the Threaded API
|
104
|
+
#
|
105
|
+
# Returns nothing
|
106
|
+
def run
|
107
|
+
case thing = collection_queue.deq
|
108
|
+
when ::DirectoryWatcher::Scan
|
109
|
+
on_scan(thing)
|
110
|
+
when ::DirectoryWatcher::FileStat
|
111
|
+
on_stat(thing)
|
112
|
+
else
|
113
|
+
raise "Unknown item in the queue: #{thing}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Write the current stats to the given IO object as a YAML document.
|
118
|
+
#
|
119
|
+
# io - The IO object to write the document to.
|
120
|
+
#
|
121
|
+
# Returns nothing.
|
122
|
+
def dump_stats( io )
|
123
|
+
YAML.dump(@stats, io)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Read the current stats from the given IO object. Any existing stats in the
|
127
|
+
# Collector will be overwritten
|
128
|
+
#
|
129
|
+
# io - The IO object from which to read the document.
|
130
|
+
#
|
131
|
+
# Returns nothing.
|
132
|
+
def load_stats( io )
|
133
|
+
@stats = YAML.load(io)
|
134
|
+
end
|
135
|
+
|
136
|
+
#######
|
137
|
+
private
|
138
|
+
#######
|
139
|
+
|
140
|
+
# Sort the stats by +sort_by+ and +order_by+ returning the results
|
141
|
+
#
|
142
|
+
def sorted_stats( stats )
|
143
|
+
sorted = stats.sort_by{ |stat| stat.send(sort_by) }
|
144
|
+
sorted = sorted.reverse if order_by == :descending
|
145
|
+
return sorted
|
146
|
+
end
|
147
|
+
|
148
|
+
# Update the stats Hash with the new_stat information, return the old data
|
149
|
+
# that is being replaced.
|
150
|
+
#
|
151
|
+
def update_stat( new_stat )
|
152
|
+
old_stat = @stats.delete(new_stat.path)
|
153
|
+
@stats.store(new_stat.path, new_stat) unless new_stat.removed?
|
154
|
+
return old_stat
|
155
|
+
end
|
156
|
+
|
157
|
+
# Look for removed files and emit removed events for all of them.
|
158
|
+
#
|
159
|
+
# seen_paths - the list of files that we know currently exist
|
160
|
+
#
|
161
|
+
# Return nothing
|
162
|
+
def emit_removed_events( seen_paths )
|
163
|
+
@stats.keys.each do |existing_path|
|
164
|
+
next if seen_paths.include?(existing_path)
|
165
|
+
old_stat = @stats.delete(existing_path)
|
166
|
+
emit_event_for(old_stat, ::DirectoryWatcher::FileStat.for_removed_path(existing_path))
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Determine what type of event to emit, and put that event onto the
|
171
|
+
# notification queue.
|
172
|
+
#
|
173
|
+
# old_stat - The old FileStat
|
174
|
+
# new_stat - The new FileStat
|
175
|
+
#
|
176
|
+
# Returns nothing
|
177
|
+
def emit_event_for( old_stat, new_stat )
|
178
|
+
event = DirectoryWatcher::Event.from_stats( old_stat, new_stat )
|
179
|
+
if should_emit?(event) then
|
180
|
+
logger.debug "Sending event #{event.object_id} to notifcation queue"
|
181
|
+
notification_queue.enq( event )
|
182
|
+
else
|
183
|
+
logger.debug "Emitting of event #{event.object_id} cancelled"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Should the event given actually be emitted.
|
188
|
+
#
|
189
|
+
# If the event passed in is NOT a stable event, return true
|
190
|
+
# If there is a stable_threshold, then check to see if the stable count for
|
191
|
+
# this event's path has crossed the stable threshold.
|
192
|
+
#
|
193
|
+
# This method has the side effect of updating the stable count of the path of
|
194
|
+
# the event. If we are going to return true for the stable event, then we
|
195
|
+
# reset the stable count of that event to 0.
|
196
|
+
#
|
197
|
+
# event - any event
|
198
|
+
#
|
199
|
+
# Returns whether or not to emit the event based upon its stability
|
200
|
+
def should_emit?( event )
|
201
|
+
if event.stable? then
|
202
|
+
if emitting_stable_events? and valid_for_stable_event?( event.path )then
|
203
|
+
increment_stable_count( event.path )
|
204
|
+
if should_emit_stable?( event.path ) then
|
205
|
+
mark_as_invalid_for_stable_event( event.path )
|
206
|
+
return true
|
207
|
+
end
|
208
|
+
end
|
209
|
+
return false
|
210
|
+
elsif event.removed? then
|
211
|
+
mark_as_invalid_for_stable_event( event.path )
|
212
|
+
return true
|
213
|
+
else
|
214
|
+
mark_as_valid_for_stable_event( event.path )
|
215
|
+
return true
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Is the given path able to have a stable event emitted for it?
|
220
|
+
#
|
221
|
+
# A stable event may only be emitted for a path that has already had an added
|
222
|
+
# or modified event already sent. Also, once a stable event has been emitted
|
223
|
+
# for a path, another stable event may not be emitted until it has been
|
224
|
+
# modified, or added again.
|
225
|
+
#
|
226
|
+
# path - the path of the file to check
|
227
|
+
#
|
228
|
+
# Returns whether or not the path may have a stable event emitted for it.
|
229
|
+
def valid_for_stable_event?( path )
|
230
|
+
@stable_counts.has_key?( path )
|
231
|
+
end
|
232
|
+
|
233
|
+
# Let it be known that the given path can now have a stable event emitted for
|
234
|
+
# it.
|
235
|
+
#
|
236
|
+
# path - the path to mark as ready
|
237
|
+
#
|
238
|
+
# Returns nothing
|
239
|
+
def mark_as_valid_for_stable_event( path )
|
240
|
+
logger.debug "#{path} marked as valid for stable"
|
241
|
+
@stable_counts[path] = 0
|
242
|
+
end
|
243
|
+
|
244
|
+
# Mark that the given path is invalid for having a stable event emitted for
|
245
|
+
# it.
|
246
|
+
#
|
247
|
+
# path - the path to mark
|
248
|
+
#
|
249
|
+
# Returns nothing
|
250
|
+
def mark_as_invalid_for_stable_event( path )
|
251
|
+
logger.debug "#{path} marked as invalid for stable"
|
252
|
+
@stable_counts.delete( path )
|
253
|
+
end
|
254
|
+
|
255
|
+
# Increment the stable count for the given path
|
256
|
+
#
|
257
|
+
# path - the path of the file to increment its stable count
|
258
|
+
#
|
259
|
+
# Returns nothing
|
260
|
+
def increment_stable_count( path )
|
261
|
+
@stable_counts[path] += 1
|
262
|
+
end
|
263
|
+
|
264
|
+
# Is the given path ready to have a stable event emitted?
|
265
|
+
#
|
266
|
+
# path - the path to report on
|
267
|
+
#
|
268
|
+
# Returns whether to emit a stable event or not
|
269
|
+
def should_emit_stable?( path )
|
270
|
+
@stable_counts[path] >= stable_threshold
|
271
|
+
end
|
272
|
+
|
273
|
+
# Is it legal for us to emit stable events at all. This checks the config to
|
274
|
+
# see if that is the case.
|
275
|
+
#
|
276
|
+
# In the @config if the stable threshold is set then we are emitting stable
|
277
|
+
# events.
|
278
|
+
#
|
279
|
+
# Returns whether it is legal to propogate stable events
|
280
|
+
def emitting_stable_events?
|
281
|
+
stable_threshold
|
282
|
+
end
|
283
|
+
end
|
@@ -0,0 +1,228 @@
|
|
1
|
+
#
|
2
|
+
# The top level configuration options used by DirectoryWatcher are used by many
|
3
|
+
# of the sub components for a variety of purposes. The Configuration represents
|
4
|
+
# all those options and other global like instances.
|
5
|
+
#
|
6
|
+
# The top level DirectoryWatcher class allows the configs to be changed during
|
7
|
+
# execution, so all of the dependent classes need to be informed when their
|
8
|
+
# options have changed. This class allows that.
|
9
|
+
#
|
10
|
+
class DirectoryWatcher::Configuration
|
11
|
+
# The directory to monitor for events. The glob's will be used in conjunction
|
12
|
+
# with this directory to find the full list of globs available.
|
13
|
+
attr_reader :dir
|
14
|
+
|
15
|
+
# The glob of files to monitor. This is an Array of file matching globs
|
16
|
+
# be aware that changing the :glob value after watching has started has the
|
17
|
+
# potential to cause spurious events if the new globs do not match the old,
|
18
|
+
# files will appear to have been deleted.
|
19
|
+
#
|
20
|
+
# The default is '*'
|
21
|
+
attr_reader :glob
|
22
|
+
|
23
|
+
# The interval at which to do a full scan using the +glob+ to determine Events
|
24
|
+
# to send.
|
25
|
+
#
|
26
|
+
# The default is 30.0 seconds
|
27
|
+
attr_reader :interval
|
28
|
+
|
29
|
+
# Controls the number of intervals a file must remain unchanged before it is
|
30
|
+
# considered "stable". When this condition is met, a stable event is
|
31
|
+
# generated for the file. If stable is set to +nil+ then stable events
|
32
|
+
# will not be generated.
|
33
|
+
#
|
34
|
+
# The default is nil, indicating no stable events are to be emitted.
|
35
|
+
attr_reader :stable
|
36
|
+
|
37
|
+
# pre_load says if an initial scan using the globs should be done to pre
|
38
|
+
# populate the state of the system before sending any events.
|
39
|
+
#
|
40
|
+
# The default is false
|
41
|
+
attr_reader :pre_load
|
42
|
+
|
43
|
+
# The filename to persist the state of the DirectoryWatcher too upon calling
|
44
|
+
# *stop*.
|
45
|
+
#
|
46
|
+
# The default is nil, indicating that no state is to be persisted.
|
47
|
+
attr_reader :persist
|
48
|
+
|
49
|
+
# The back end scanner to use. The available options are:
|
50
|
+
#
|
51
|
+
# nil => Use the default, pure ruby Threaded scanner
|
52
|
+
# :em => Use the EventMachine based scanner. This requires that the
|
53
|
+
# 'eventmachine' gem be installed.
|
54
|
+
# :coolio => Use the Cool.io based scanner. This requires that the
|
55
|
+
# 'cool.io' gem be installed.
|
56
|
+
# :rev => Use the Rev based scanner. This requires that the 'rev' gem be
|
57
|
+
# installed.
|
58
|
+
#
|
59
|
+
# The default is nil, indicating the pure ruby threaded scanner will be used.
|
60
|
+
# This option may not be changed once the DirectoryWatcher is allocated.
|
61
|
+
#
|
62
|
+
attr_reader :scanner
|
63
|
+
|
64
|
+
# The sorting method to use when emitting a set of Events after a Scan has
|
65
|
+
# happened. Since a Scan may produce a number of events, if those Events should
|
66
|
+
# be emitted in a particular order, use +sort_by+ to pick which field to sort
|
67
|
+
# the events, and +order_by+ to say if those events are to be emitted in
|
68
|
+
# :ascending or :descending order.
|
69
|
+
#
|
70
|
+
# Available options:
|
71
|
+
#
|
72
|
+
# :path => The default, they will be sorted by full pathname
|
73
|
+
# :mtime => Last modified time. They will be sorted by their FileStat mtime
|
74
|
+
# :size => The number of bytes in the file.
|
75
|
+
#
|
76
|
+
attr_accessor :sort_by
|
77
|
+
|
78
|
+
# When sorting you may pick if the order should be:
|
79
|
+
#
|
80
|
+
# :ascending => The default, from lowest to highest
|
81
|
+
# :descending => from highest to lowest.
|
82
|
+
#
|
83
|
+
attr_accessor :order_by
|
84
|
+
|
85
|
+
# The Queue through which the Scanner will send data to the Collector
|
86
|
+
#
|
87
|
+
attr_reader :collection_queue
|
88
|
+
|
89
|
+
# The Queue through which the Collector will send data to the Notifier
|
90
|
+
#
|
91
|
+
attr_reader :notification_queue
|
92
|
+
|
93
|
+
# The logger through wich every one will log
|
94
|
+
#
|
95
|
+
attr_reader :logger
|
96
|
+
|
97
|
+
# Return a Hash of all the default options
|
98
|
+
#
|
99
|
+
def self.default_options
|
100
|
+
{
|
101
|
+
:dir => '.',
|
102
|
+
:glob => '*',
|
103
|
+
:interval => 30.0,
|
104
|
+
:stable => nil,
|
105
|
+
:pre_load => false,
|
106
|
+
:persist => nil,
|
107
|
+
:scanner => nil,
|
108
|
+
:sort_by => :path,
|
109
|
+
:order_by => :ascending,
|
110
|
+
:logger => nil,
|
111
|
+
}
|
112
|
+
end
|
113
|
+
|
114
|
+
# Create a new Configuration by blending the passed in items with the defaults
|
115
|
+
#
|
116
|
+
def initialize( options = {} )
|
117
|
+
o = self.class.default_options.merge( options )
|
118
|
+
@dir = o[:dir]
|
119
|
+
@pre_load = o[:pre_load]
|
120
|
+
@scanner = o[:scanner]
|
121
|
+
@sort_by = o[:sort_by]
|
122
|
+
@order_by = o[:order_by]
|
123
|
+
|
124
|
+
# These have validation rules
|
125
|
+
self.persist = o[:persist]
|
126
|
+
self.interval = o[:interval]
|
127
|
+
self.glob = o[:glob]
|
128
|
+
self.stable = o[:stable]
|
129
|
+
self.logger = o[:logger]
|
130
|
+
|
131
|
+
@notification_queue = Queue.new
|
132
|
+
@collection_queue = Queue.new
|
133
|
+
end
|
134
|
+
|
135
|
+
# Is pre_load set or not
|
136
|
+
#
|
137
|
+
def pre_load?
|
138
|
+
@pre_load
|
139
|
+
end
|
140
|
+
|
141
|
+
# The class of the scanner
|
142
|
+
#
|
143
|
+
def scanner_class
|
144
|
+
class_name = scanner.to_s.capitalize + 'Scanner'
|
145
|
+
DirectoryWatcher.const_get( class_name ) rescue DirectoryWatcher::Scanner
|
146
|
+
end
|
147
|
+
|
148
|
+
# call-seq:
|
149
|
+
# glob = '*'
|
150
|
+
# glob = ['lib/**/*.rb', 'test/**/*.rb']
|
151
|
+
#
|
152
|
+
# Sets the glob pattern that will be used when scanning the directory for
|
153
|
+
# files. A single glob pattern can be given or an array of glob patterns.
|
154
|
+
#
|
155
|
+
def glob=( val )
|
156
|
+
glob = case val
|
157
|
+
when String; [File.join(@dir, val)]
|
158
|
+
when Array; val.flatten.map! {|g| File.join(@dir, g)}
|
159
|
+
else
|
160
|
+
raise(ArgumentError,
|
161
|
+
'expecting a glob pattern or an array of glob patterns')
|
162
|
+
end
|
163
|
+
glob.uniq!
|
164
|
+
@glob = glob
|
165
|
+
end
|
166
|
+
|
167
|
+
# Sets the directory scan interval. The directory will be scanned every
|
168
|
+
# _interval_ seconds for changes to files matching the glob pattern.
|
169
|
+
# Raises +ArgumentError+ if the interval is zero or negative.
|
170
|
+
#
|
171
|
+
def interval=( val )
|
172
|
+
val = Float(val)
|
173
|
+
raise ArgumentError, "interval must be greater than zero" if val <= 0
|
174
|
+
@interval = val
|
175
|
+
end
|
176
|
+
|
177
|
+
# Sets the logger instance. This will be used by all classes for logging
|
178
|
+
#
|
179
|
+
def logger=( val )
|
180
|
+
if val then
|
181
|
+
if %w[ debug info warn error fatal ].all? { |meth| val.respond_to?( meth ) } then
|
182
|
+
@logger = val
|
183
|
+
end
|
184
|
+
else
|
185
|
+
@logger = ::DirectoryWatcher::Logable.default_logger
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Sets the number of intervals a file must remain unchanged before it is
|
190
|
+
# considered "stable". When this condition is met, a stable event is
|
191
|
+
# generated for the file. If stable is set to +nil+ then stable events
|
192
|
+
# will not be generated.
|
193
|
+
#
|
194
|
+
# A stable event will be generated once for a file. Another stable event
|
195
|
+
# will only be generated after the file has been modified and then remains
|
196
|
+
# unchanged for _stable_ intervals.
|
197
|
+
#
|
198
|
+
# Example:
|
199
|
+
#
|
200
|
+
# dw = DirectoryWatcher.new( '/tmp', :glob => 'swap.*' )
|
201
|
+
# dw.interval = 15.0
|
202
|
+
# dw.stable = 4
|
203
|
+
#
|
204
|
+
# In this example, a directory watcher is configured to look for swap files
|
205
|
+
# in the /tmp directory. Stable events will be generated every 4 scan
|
206
|
+
# intervals iff a swap remains unchanged for that time. In this case the
|
207
|
+
# time is 60 seconds (15.0 * 4).
|
208
|
+
#
|
209
|
+
def stable=( val )
|
210
|
+
if val.nil?
|
211
|
+
@stable = nil
|
212
|
+
else
|
213
|
+
val = Integer(val)
|
214
|
+
raise ArgumentError, "stable must be greater than zero" if val <= 0
|
215
|
+
@stable = val
|
216
|
+
end
|
217
|
+
return @stable
|
218
|
+
end
|
219
|
+
|
220
|
+
# Sets the name of the file to which the directory watcher state will be
|
221
|
+
# persisted when it is stopped. Setting the persist filename to +nil+ will
|
222
|
+
# disable this feature.
|
223
|
+
#
|
224
|
+
def persist=( filename )
|
225
|
+
@persist = filename ? filename.to_s : nil
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|