rave 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,13 +1,19 @@
1
- #Display usage for rave command
2
- def display_usage
3
- puts "Useage: rave [create | server | war] [robot_name] [options]"
4
- puts "'create' generates a Google Wave robot client stub application."
5
- puts "e.g."
6
- puts "rave create my_robot image_url=http://my_robot.appspot.com/image.png profile_url=http://my_robot.appspot.com/"
7
- puts "'server' launches the robot"
8
- puts "e.g."
9
- puts "rave server"
10
- puts "'war' creates a war file suitable for deploying to Google AppEngine"
11
- puts "e.g."
12
- puts "rave war"
1
+ #Display usage for rave command
2
+ def display_usage
3
+ puts "Useage: rave [create | server | war] [robot_name] [options]"
4
+ puts "'create' generates a Google Wave robot client stub application."
5
+ puts "e.g."
6
+ puts "rave create my_robot image_url=http://my_robot.appspot.com/image.png profile_url=http://my_robot.appspot.com/"
7
+ puts "'server' launches the robot"
8
+ puts "e.g."
9
+ puts "rave server"
10
+ puts "'war' creates a war file suitable for deploying to Google AppEngine"
11
+ puts "e.g."
12
+ puts "rave war"
13
+ puts "'appengine_deploy' deploys the tmp/war folder to Google AppEngine"
14
+ puts "e.g."
15
+ puts "rave appengine_deploy"
16
+ puts "'cleanup' removes the .war file and the tmp/war staging directory"
17
+ puts "e.g."
18
+ puts "rave cleanup"
13
19
  end
data/lib/commands/war.rb CHANGED
@@ -1,51 +1,28 @@
1
- #Runs warbler to package up the robot
2
- # then does some cleanup that is specific to App Engine:
3
- # => Deletes the complete JRuby jar from both the app's lib folder and
4
- # the frozen warbler gem, and replaces them with a broken version
5
- # => Changes the file path json-jruby
6
- # TODO: Not sure why this is necessary, but it doesn't run on appengine without it
7
- def create_war(args)
8
- #Run warbler
9
- system("jruby -S warble")
10
- web_inf = File.join(".", "tmp", "war", "WEB-INF")
11
- rave_jars = File.join(File.dirname(__FILE__), "..", "jars")
12
- #Delete the complete JRuby jar that warbler sticks in lib
13
- delete_jruby_from_lib(File.join(web_inf, "lib"))
14
- #Delete the complete JRuby jar from warbler itself
15
- delete_jruby_from_warbler(File.join(web_inf, "gems", "gems"))
16
- #Copy the broken up JRuby jar into warbler #TODO Is warbler necessary? Can we just delete warbler?
17
- copy_jruby_chunks_to_warbler(rave_jars, Dir[File.join(web_inf, "gems", "gems", "warbler-*", "lib")].first)
18
- #Fix the broken paths in json-jruby
19
- fix_json_jruby_paths(File.join(web_inf, "gems", "gems"))
20
- end
21
-
22
- def delete_jruby_from_lib(web_inf_lib)
23
- jar = Dir[File.join(web_inf_lib, "jruby-complete-*.jar")].first
24
- puts "Deleting #{jar}"
25
- File.delete(jar) if jar
26
- end
27
-
28
- def delete_jruby_from_warbler(web_inf_gems)
29
- jar = Dir[File.join(web_inf_gems, "warbler-*", "lib", "jruby-complete-*.jar")].first
30
- puts "Deleting #{jar}"
31
- File.delete(jar) if jar
32
- end
33
-
34
- def copy_jruby_chunks_to_warbler(rave_jar_dir, warbler_jar_dir)
35
- puts "Copying jruby chunks"
36
- %w( jruby-core.jar ruby-stdlib.jar ).each do |jar|
37
- File.copy(File.join(rave_jar_dir, jar), File.join(warbler_jar_dir, jar))
38
- end
39
- end
40
-
41
- def fix_json_jruby_paths(web_inf_gems)
42
- #TODO: Why is this necessary? Is this an appengine issue?
43
- puts "Fixing paths in json-jruby"
44
- ext = Dir[File.join(web_inf_gems, "json-jruby-*", "lib", "json", "ext.rb")].first
45
- if ext
46
- text = File.open(ext, "r") { |f| f.read }
47
- text.gsub!("require 'json/ext/parser'", "require 'ext/parser'")
48
- text.gsub!("require 'json/ext/generator'", "require 'ext/generator'")
49
- File.open(ext, "w") { |f| f.write(text) }
50
- end
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require File.join(File.dirname(__FILE__), 'task.rb')
4
+
5
+ #Runs warbler to package up the robot
6
+ # then does some cleanup that is specific to App Engine:
7
+ # => Deletes the complete JRuby jar from both the app's lib folder and
8
+ # the frozen warbler gem, and replaces them with a broken up version
9
+ # => Changes the file path json-jruby
10
+ # TODO: Not sure why this is necessary, but it doesn't run on appengine without it
11
+ def create_war(args)
12
+ Rake.application.standard_exception_handling do
13
+ Rake.application.init
14
+ Rave::Task.new
15
+ task(:default => "rave:create_war")
16
+ Rake.application.top_level
17
+ end
18
+ end
19
+
20
+ #Runs warbler's cleanup to get rid of the .war file and the tmp/war folder
21
+ def cleanup_war(args)
22
+ Rake.application.standard_exception_handling do
23
+ Rake.application.init
24
+ Rave::Task.new
25
+ task(:default => "rave:clean")
26
+ Rake.application.top_level
27
+ end
51
28
  end
data/lib/exceptions.rb CHANGED
@@ -1,6 +1,20 @@
1
- module Rave
2
- #Exception raised when registering an invalid event
3
- class InvalidEventException < Exception ; end
4
- #Exception raised when registering an event with an invalid handler
5
- class InvalidHandlerException < Exception ; end
1
+ module Rave
2
+ #Exception raised when registering an invalid event
3
+ class InvalidEventException < Exception ; end
4
+
5
+ #Exception raised when registering an event with an invalid handler
6
+ class InvalidHandlerException < Exception ; end
7
+
8
+ # Raised when trying to create an object with the same ID as one that already exists.
9
+ class DuplicatedIDError < Exception; end
10
+
11
+ # Raised if an unimplemented method is called.
12
+ class NotImplementedError < Exception; end
13
+
14
+ # A method option was not one of the values allowed.
15
+ class BadOptionError < ArgumentError
16
+ def initialize(option_name, valid_options, received) # :nodoc:
17
+ super("#{option_name.inspect} option must be one of #{valid_options.inspect}, not #{received.inspect}")
18
+ end
19
+ end
6
20
  end
data/lib/ext/logger.rb ADDED
@@ -0,0 +1,7 @@
1
+ unless RUBY_PLATFORM == 'java'
2
+ require 'logger'
3
+ #Need to alias :warn as :warning to match the java logger
4
+ class Logger
5
+ alias :warning :warn
6
+ end
7
+ end
data/lib/gems.yaml ADDED
@@ -0,0 +1,9 @@
1
+ all:
2
+ rack: >=1.0
3
+ builder: >=2.1.2
4
+ warbler: >=0.9.14
5
+ RedCloth: >=4.2.2
6
+ jruby:
7
+ json-jruby: >=1.1.6
8
+ mri:
9
+ json: >=1.2.0
@@ -1,40 +1,72 @@
1
- #Contains the Rack #call method - to be mixed in to the Robot class
2
- module Rave
3
- module Mixins
4
- module Controller
5
-
6
- LOGGER = java.util.logging.Logger.getLogger("Controller")
7
-
8
- def call(env)
9
- request = Rack::Request.new(env)
10
- path = request.path_info
11
- method = request.request_method
12
- LOGGER.info("#{method}ing #{path}")
13
- begin
14
- #There are only 3 URLs that Wave can access:
15
- # robot capabilities, robot profile, and event notification
16
- if path == "/_wave/capabilities.xml" && method == "GET"
17
- [ 200, { 'Content-Type' => 'text/xml' }, capabilities_xml ]
18
- elsif path == "/_wave/robot/profile" && method == "GET"
19
- [ 200, { 'Content-Type' => 'application/json' }, profile_json ]
20
- elsif path == "/_wave/robot/jsonrpc" && method == "POST"
21
- body = request.body.read
22
- context, events = parse_json_body(body)
23
- events.each do |event|
24
- handle_event(event, context)
25
- end
26
- [ 200, { 'Content-Type' => 'application/json' }, context.to_json ]
27
- else
28
- #TODO: Also, give one more option: respond_to?(:non_robot_url) or something - can override in impl
29
- #TODO: Log this
30
- [ 404, { 'Content-Type' => 'text/html' }, "404 - Not Found" ]
31
- end
32
- rescue Exception => e
33
- #TODO: Log this
34
- [ 500, { 'Content-Type' => 'text/html' }, "500 - Internal Server Error"]
35
- end
36
- end
37
-
38
- end
39
- end
40
- end
1
+ #Contains the Rack #call method - to be mixed in to the Robot class
2
+ module Rave
3
+ module Mixins
4
+ module Controller
5
+ include Logger
6
+
7
+ def call(env)
8
+ request = Rack::Request.new(env)
9
+ path = request.path_info
10
+ method = request.request_method
11
+ logger.info("#{method}ing #{path}")
12
+ begin
13
+ #There are only 3 URLs that Wave can access:
14
+ # robot capabilities, robot profile, and event notification
15
+ if path == "/_wave/capabilities.xml" && method == "GET"
16
+ [ 200, { 'Content-Type' => 'text/xml' }, capabilities_xml ]
17
+ elsif path == "/_wave/robot/profile" && method == "GET"
18
+ [ 200, { 'Content-Type' => 'application/json' }, profile_json ]
19
+ elsif path == "/_wave/robot/jsonrpc" && method == "POST"
20
+ body = request.body.read
21
+ context, events = parse_json_body(body)
22
+ events.each do |event|
23
+ handle_event(event, context)
24
+ end
25
+ response = context.to_json
26
+ logger.info("Structure (after):\n#{context.print_structure}")
27
+ logger.info("Response:\n#{response}")
28
+ [ 200, { 'Content-Type' => 'application/json' }, response ]
29
+ elsif cron_job = @cron_jobs.find { |job| job[:path] == path }
30
+ body = request.body.read
31
+ context, events = parse_json_body(body)
32
+ self.send(cron_job[:handler], context)
33
+ [ 200, { 'Content-Type' => 'application/json' }, context.to_json ]
34
+ elsif File.exist?(file = File.join(".", "public", *(path.split("/"))))
35
+ #Static resource
36
+ [ 200, { 'Content-Type' => static_resource_content_type(file) }, File.open(file) { |f| f.read } ]
37
+ elsif self.respond_to?(:custom_routes)
38
+ #Let the custom route method defined in the robot take care of the call
39
+ self.custom_routes(request, path, method)
40
+ else
41
+ logger.warning("404 - Not Found: #{path}")
42
+ [ 404, { 'Content-Type' => 'text/html' }, "404 - Not Found" ]
43
+ end
44
+ rescue Exception => e
45
+ logger.warning("500 - Internal Server Error: #{path}")
46
+ logger.warning("#{e.class}: #{e.message}\n\n#{e.backtrace.join("\n")}")
47
+ [ 500, { 'Content-Type' => 'text/html' }, "500 - Internal Server Error"]
48
+ end
49
+ end
50
+
51
+ protected
52
+ def static_resource_content_type(path)
53
+ case (ext = File.extname(path))
54
+ when '.html', '.htm'
55
+ 'text/html'
56
+ when '.xml'
57
+ 'text/xml'
58
+ when '.gif'
59
+ 'image/gif'
60
+ when '.jpeg', '.jpg'
61
+ 'image/jpeg'
62
+ when '.tif', '.tiff'
63
+ 'image/tiff'
64
+ when '.txt', ''
65
+ 'text/plain'
66
+ else
67
+ "application/#{ext}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,168 +1,206 @@
1
- #This mixin provides methods for robots to deal with parsing and presenting JSON and XML
2
- module Rave
3
- module Mixins
4
- module DataFormat
5
-
6
- LOGGER = java.util.logging.Logger.getLogger("DataFormat") unless defined?(LOGGER)
7
-
8
- #Returns this robot's capabilities in XML
9
- def capabilities_xml
10
- xml = Builder::XmlMarkup.new
11
- xml.instruct!
12
- xml.tag!("w:robot", "xmlns:w" => "http://wave.google.com/extensions/robots/1.0") do
13
- xml.tag!("w:capabilities") do
14
- @handlers.keys.each do |capability|
15
- xml.tag!("w:capability", "name" => capability)
16
- end
17
- end
18
- unless @cron_jobs.empty?
19
- xml.tag!("w:crons") do
20
- @cron_jobs.each do |job|
21
- xml.tag!("w:cron", "path" => job[:path], "timeinseconds" => job[:seconds])
22
- end
23
- end
24
- end
25
- attrs = { "name" => @name }
26
- attrs["imageurl"] = @image_url if @image_url
27
- attrs["profileurl"] = @profile_url if @profile_url
28
- xml.tag!("w:profile", attrs)
29
- end
30
- end
31
-
32
- #Returns the robot's profile in json format
33
- def profile_json
34
- {
35
- "name" => @name,
36
- "imageurl" => @image_url,
37
- "profile_url" => @profile_url
38
- }.to_json
39
- end
40
-
41
- #Parses context and event info from JSON input
42
- def parse_json_body(json)
43
- LOGGER.info("Parsing JSON:")
44
- LOGGER.info(json.to_s)
45
- data = JSON.parse(json)
46
- #Create Context
47
- context = context_from_json(data)
48
- #Create events
49
- events = events_from_json(data)
50
- return context, events
51
- end
52
-
53
- protected
54
- def context_from_json(json)
55
- blips = {}
56
- blips_from_json(json).each do |blip|
57
- blips[blip.id] = blip
58
- end
59
- wavelets = {}
60
- wavelets_from_json(json).each do |wavelet|
61
- wavelets[wavelet.id] = wavelet
62
- end
63
- waves = {}
64
- #Waves aren't sent back, but we can reconstruct them from the wavelets
65
- waves_from_wavelets(wavelets).each do |wave|
66
- waves[wave.id] = wave
67
- end
68
- Rave::Models::Context.new(
69
- :waves => waves,
70
- :wavelets => wavelets,
71
- :blips => blips
72
- )
73
- end
74
-
75
- def blips_from_json(json)
76
- if json['blips']
77
- json['blips']['map'].values.collect do |blip_data|
78
- blip = Rave::Models::Blip.new(
79
- :id => blip_data['blipId'],
80
- :annotations => annotations_from_json(blip_data),
81
- :child_blip_ids => blip_data['childBlipIds'],
82
- :content => blip_data['content'],
83
- :contributors => blip_data['contributors'],
84
- :creator => blip_data['creator'],
85
- :elements => blip_data['elements'],
86
- :last_modified_time => blip_data['lastModifiedTime'],
87
- :parent_blip_id => blip_data['parentBlipId'],
88
- :version => blip_data['version'],
89
- :wave_id => blip_data['waveId'],
90
- :wavelet_id => blip_data['waveletId']
91
- )
92
- end
93
- else
94
- []
95
- end
96
- end
97
-
98
- def annotations_from_json(json)
99
- if json['annotation'] && json['annotations']['list']
100
- json['annotations']['list'].collect do |annotation|
101
- Rave::Models::Annotation.new(
102
- :name => annotation['name'],
103
- :value => annotation['value'],
104
- :range => Range.new(annotation['range']['start'], annotation['range']['end'])
105
- )
106
- end
107
- else
108
- []
109
- end
110
- end
111
-
112
- def events_from_json(json)
113
- if json['events'] && json['events']['list']
114
- json['events']['list'].collect do |event|
115
- properties = {}
116
- event['properties']['map'].each { |key, value| properties[key] = value['list'] }
117
- Rave::Models::Event.new(
118
- :type => event['type'],
119
- :timestamp => event['timestamp'],
120
- :modified_by => event['modifiedBy'],
121
- :properties => properties
122
- )
123
- end
124
- else
125
- []
126
- end
127
- end
128
-
129
- def wavelets_from_json(json)
130
- #Currently only one wavelet is sent back
131
- #TODO: should this look at the wavelet's children too?
132
- wavelet = json['wavelet']
133
- if wavelet
134
- [
135
- Rave::Models::Wavelet.new(
136
- :creator => wavelet['creator'],
137
- :creation_time => wavelet['creationTime'],
138
- :data_documents => wavelet['dataDocuments'],
139
- :last_modifed_time => wavelet['lastModifiedTime'],
140
- :participants => wavelet['participants'],
141
- :root_blip_id => wavelet['rootBlipId'],
142
- :title => wavelet['title'],
143
- :version => wavelet['version'],
144
- :wave_id => wavelet['waveId'],
145
- :id => wavelet['waveletId']
146
- )
147
- ]
148
- else
149
- []
150
- end
151
- end
152
-
153
- def waves_from_wavelets(wavelets)
154
- wave_wavelet_map = {}
155
- if wavelets
156
- wavelets.values.each do |wavelet|
157
- wave_wavelet_map[wavelet.wave_id] ||= []
158
- wave_wavelet_map[wavelet.wave_id] << wavelet.id
159
- end
160
- end
161
- wave_wavelet_map.collect do |wave_id, wavelet_ids|
162
- Rave::Models::Wave.new(:id => wave_id, :wavelet_ids => wavelet_ids)
163
- end
164
- end
165
-
166
- end
167
- end
168
- end
1
+ #This mixin provides methods for robots to deal with parsing and presenting JSON and XML
2
+ module Rave
3
+ module Mixins
4
+ module DataFormat
5
+ include Logger
6
+
7
+ PROFILE_JAVA_CLASS = 'com.google.wave.api.ParticipantProfile'
8
+
9
+ #Returns this robot's capabilities in XML
10
+ def capabilities_xml
11
+ xml = Builder::XmlMarkup.new
12
+ xml.instruct!
13
+ xml.tag!("w:robot", "xmlns:w" => "http://wave.google.com/extensions/robots/1.0") do
14
+ xml.tag!("w:version", @version)
15
+ xml.tag!("w:capabilities") do
16
+ @handlers.keys.each do |capability|
17
+ xml.tag!("w:capability", "name" => capability)
18
+ end
19
+ end
20
+ unless @cron_jobs.empty?
21
+ xml.tag!("w:crons") do
22
+ @cron_jobs.each do |job|
23
+ xml.tag!("w:cron", "path" => job[:path], "timerinseconds" => job[:seconds])
24
+ end
25
+ end
26
+ end
27
+ attrs = { "name" => @name }
28
+ attrs["imageurl"] = @image_url if @image_url
29
+ attrs["profileurl"] = @profile_url if @profile_url
30
+ xml.tag!("w:profile", attrs)
31
+ end
32
+ end
33
+
34
+ #Returns the robot's profile in json format
35
+ def profile_json
36
+ {
37
+ 'name' => @name,
38
+ 'imageUrl' => @image_url,
39
+ 'profileUrl' => @profile_url,
40
+ 'javaClass' => PROFILE_JAVA_CLASS,
41
+ }.to_json.gsub('\/','/')
42
+ end
43
+
44
+ #Parses context and event info from JSON input
45
+ def parse_json_body(json)
46
+ logger.info("Received:\n#{json.to_s}")
47
+ data = JSON.parse(json)
48
+ #Create Context
49
+ context = context_from_json(data)
50
+ #Create events
51
+ events = events_from_json(data, context)
52
+ logger.info("Structure (before):\n#{context.print_structure}")
53
+ logger.info("Events: #{events.map { |e| e.type }.join(', ')}")
54
+ return context, events
55
+ end
56
+
57
+ protected
58
+ def context_from_json(json)
59
+ blips = {}
60
+ blips_from_json(json).each do |blip|
61
+ blips[blip.id] = blip
62
+ end
63
+ wavelets = {}
64
+ wavelets_from_json(json).each do |wavelet|
65
+ wavelets[wavelet.id] = wavelet
66
+ end
67
+ waves = {}
68
+ #Waves aren't sent back, but we can reconstruct them from the wavelets
69
+ waves_from_wavelets(wavelets).each do |wave|
70
+ waves[wave.id] = wave
71
+ end
72
+ Rave::Models::Context.new(
73
+ :waves => waves,
74
+ :wavelets => wavelets,
75
+ :blips => blips,
76
+ :robot => self
77
+ )
78
+ end
79
+
80
+ def blips_from_json(json)
81
+ map_to_hash(json['blips']).values.collect do |blip_data|
82
+ Rave::Models::Blip.new(
83
+ :id => blip_data['blipId'],
84
+ :annotations => annotations_from_json(blip_data),
85
+ :child_blip_ids => list_to_array(blip_data['childBlipIds']),
86
+ :content => blip_data['content'],
87
+ :contributors => list_to_array(blip_data['contributors']),
88
+ :creator => blip_data['creator'],
89
+ :elements => elements_from_json(blip_data['elements']),
90
+ :last_modified_time => blip_data['lastModifiedTime'],
91
+ :parent_blip_id => blip_data['parentBlipId'],
92
+ :version => blip_data['version'],
93
+ :wave_id => blip_data['waveId'],
94
+ :wavelet_id => blip_data['waveletId']
95
+ )
96
+ end
97
+ end
98
+
99
+ def elements_from_json(elements_map)
100
+ elements = {}
101
+
102
+ map_to_hash(elements_map).each_pair do |position, data|
103
+ elements[position.to_i] = Element.create(data['type'], map_to_hash(data['properties']))
104
+ end
105
+
106
+ elements
107
+ end
108
+
109
+ # Convert a json-java list (which may not be defined) into an array.
110
+ # Defaults to an empty array.
111
+ def list_to_array(list)
112
+ if list.nil?
113
+ []
114
+ else
115
+ list['list'] || []
116
+ end
117
+ end
118
+
119
+ # Convert a json-java map (which may not be defined) into a hash. Defaults
120
+ # to an empty hash.
121
+ def map_to_hash(map)
122
+ if map.nil?
123
+ {}
124
+ else
125
+ map['map'] || {}
126
+ end
127
+ end
128
+
129
+ def annotations_from_json(json)
130
+ list_to_array(json['annotation']).collect do |annotation|
131
+ Rave::Models::Annotation.create(
132
+ annotation['name'],
133
+ annotation['value'],
134
+ range_from_json(annotation['range'])
135
+ )
136
+
137
+ end
138
+ end
139
+
140
+ def range_from_json(json)
141
+ Range.new(json['start'], json['end'])
142
+ end
143
+
144
+ def events_from_json(json, context)
145
+ list_to_array(json['events']).collect do |event|
146
+ properties = {}
147
+ event['properties']['map'].each do |key, value|
148
+ properties[key] = case value
149
+ when String # Just a string, as in blipId.
150
+ value
151
+ when Hash # Serialised array, such as in participantsAdded.
152
+ value['list']
153
+ else
154
+ raise "Unrecognised property #{value} #{value.class}"
155
+ end
156
+ end
157
+ Rave::Models::Event.create(event['type'],
158
+ :timestamp => event['timestamp'],
159
+ :modified_by => event['modifiedBy'],
160
+ :properties => properties,
161
+ :context => context,
162
+ :robot => self
163
+ )
164
+ end
165
+ end
166
+
167
+ def wavelets_from_json(json)
168
+ #Currently only one wavelet is sent back
169
+ #TODO: should this look at the wavelet's children too?
170
+ wavelet = json['wavelet']
171
+ if wavelet
172
+ [
173
+ Rave::Models::Wavelet.new(
174
+ :creator => wavelet['creator'],
175
+ :creation_time => wavelet['creationTime'],
176
+ :data_documents => map_to_hash(wavelet['dataDocuments']),
177
+ :last_modifed_time => wavelet['lastModifiedTime'],
178
+ :participants => list_to_array(wavelet['participants']),
179
+ :root_blip_id => wavelet['rootBlipId'],
180
+ :title => wavelet['title'],
181
+ :version => wavelet['version'],
182
+ :wave_id => wavelet['waveId'],
183
+ :id => wavelet['waveletId']
184
+ )
185
+ ]
186
+ else
187
+ []
188
+ end
189
+ end
190
+
191
+ def waves_from_wavelets(wavelets)
192
+ wave_wavelet_map = {}
193
+ if wavelets
194
+ wavelets.values.each do |wavelet|
195
+ wave_wavelet_map[wavelet.wave_id] ||= []
196
+ wave_wavelet_map[wavelet.wave_id] << wavelet.id
197
+ end
198
+ end
199
+ wave_wavelet_map.collect do |wave_id, wavelet_ids|
200
+ Rave::Models::Wave.new(:id => wave_id, :wavelet_ids => wavelet_ids)
201
+ end
202
+ end
203
+
204
+ end
205
+ end
206
+ end