metaforce-beta 1.2.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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +193 -0
- data/Rakefile +10 -0
- data/bin/metaforce +14 -0
- data/examples/example.rb +52 -0
- data/lib/metaforce.rb +34 -0
- data/lib/metaforce/abstract_client.rb +86 -0
- data/lib/metaforce/cli.rb +130 -0
- data/lib/metaforce/client.rb +31 -0
- data/lib/metaforce/config.rb +100 -0
- data/lib/metaforce/job.rb +203 -0
- data/lib/metaforce/job/crud.rb +13 -0
- data/lib/metaforce/job/deploy.rb +87 -0
- data/lib/metaforce/job/retrieve.rb +102 -0
- data/lib/metaforce/login.rb +39 -0
- data/lib/metaforce/manifest.rb +106 -0
- data/lib/metaforce/metadata/client.rb +18 -0
- data/lib/metaforce/metadata/client/crud.rb +86 -0
- data/lib/metaforce/metadata/client/file.rb +113 -0
- data/lib/metaforce/reporters.rb +2 -0
- data/lib/metaforce/reporters/base_reporter.rb +56 -0
- data/lib/metaforce/reporters/deploy_reporter.rb +69 -0
- data/lib/metaforce/reporters/retrieve_reporter.rb +11 -0
- data/lib/metaforce/services/client.rb +84 -0
- data/lib/metaforce/version.rb +3 -0
- data/metaforce.gemspec +34 -0
- data/spec/fixtures/package.xml +17 -0
- data/spec/fixtures/payload.zip +0 -0
- data/spec/fixtures/requests/check_deploy_status/done.xml +33 -0
- data/spec/fixtures/requests/check_deploy_status/error.xml +26 -0
- data/spec/fixtures/requests/check_retrieve_status/success.xml +37 -0
- data/spec/fixtures/requests/check_status/done.xml +19 -0
- data/spec/fixtures/requests/check_status/not_done.xml +19 -0
- data/spec/fixtures/requests/create/in_progress.xml +12 -0
- data/spec/fixtures/requests/delete/in_progress.xml +12 -0
- data/spec/fixtures/requests/deploy/in_progress.xml +13 -0
- data/spec/fixtures/requests/describe_layout/success.xml +15 -0
- data/spec/fixtures/requests/describe_metadata/success.xml +230 -0
- data/spec/fixtures/requests/foo/invalid_session.xml +15 -0
- data/spec/fixtures/requests/list_metadata/no_result.xml +6 -0
- data/spec/fixtures/requests/list_metadata/objects.xml +33 -0
- data/spec/fixtures/requests/login/failure.xml +15 -0
- data/spec/fixtures/requests/login/success.xml +39 -0
- data/spec/fixtures/requests/retrieve/in_progress.xml +12 -0
- data/spec/fixtures/requests/send_email/success.xml +1 -0
- data/spec/fixtures/requests/update/in_progress.xml +12 -0
- data/spec/lib/cli_spec.rb +42 -0
- data/spec/lib/client_spec.rb +39 -0
- data/spec/lib/config_spec.rb +12 -0
- data/spec/lib/job/deploy_spec.rb +54 -0
- data/spec/lib/job/retrieve_spec.rb +28 -0
- data/spec/lib/job_spec.rb +111 -0
- data/spec/lib/login_spec.rb +18 -0
- data/spec/lib/manifest_spec.rb +35 -0
- data/spec/lib/metadata/client_spec.rb +135 -0
- data/spec/lib/metaforce_spec.rb +42 -0
- data/spec/lib/reporters/base_reporter_spec.rb +79 -0
- data/spec/lib/reporters/deploy_reporter_spec.rb +124 -0
- data/spec/lib/reporters/retrieve_reporter_spec.rb +14 -0
- data/spec/lib/services/client_spec.rb +37 -0
- data/spec/spec_helper.rb +37 -0
- data/spec/support/client.rb +39 -0
- data/wsdl/23.0/metadata.xml +3520 -0
- data/wsdl/23.0/partner.xml +3190 -0
- data/wsdl/26.0/metadata.xml +4750 -0
- data/wsdl/26.0/partner.xml +3340 -0
- data/wsdl/34.0/metadata.xml +7981 -0
- data/wsdl/34.0/partner.xml +5398 -0
- data/wsdl/35.0/metadata.xml +8183 -0
- data/wsdl/35.0/partner.xml +5755 -0
- data/wsdl/40.0/metadata.xml +29052 -0
- data/wsdl/40.0/partner.xml +7642 -0
- metadata +327 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Metaforce
|
|
2
|
+
class Job::Deploy < Job
|
|
3
|
+
|
|
4
|
+
# Public: Instantiate a new deploy job.
|
|
5
|
+
#
|
|
6
|
+
# Examples
|
|
7
|
+
#
|
|
8
|
+
# job = Metaforce::Job::Deploy.new(client, './path/to/deploy')
|
|
9
|
+
# # => #<Metaforce::Job::Deploy @id=nil>
|
|
10
|
+
#
|
|
11
|
+
# Returns self.
|
|
12
|
+
def initialize(client, path, options={})
|
|
13
|
+
super(client)
|
|
14
|
+
@path, @options = path, options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Public: Perform the job.
|
|
18
|
+
#
|
|
19
|
+
# Examples
|
|
20
|
+
#
|
|
21
|
+
# job = Metaforce::Job::Deploy.new(client, './path/to/deploy')
|
|
22
|
+
# job.perform
|
|
23
|
+
# # => #<Metaforce::Job::Deploy @id='1234'>
|
|
24
|
+
#
|
|
25
|
+
# Returns self.
|
|
26
|
+
def perform
|
|
27
|
+
@id = client._deploy(payload, @options).id
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Public: Get the detailed status of the deploy.
|
|
32
|
+
#
|
|
33
|
+
# Examples
|
|
34
|
+
#
|
|
35
|
+
# job.result
|
|
36
|
+
# # => { :id => '1234', :success => true, ... }
|
|
37
|
+
#
|
|
38
|
+
# Returns the DeployResult (http://www.salesforce.com/us/developer/docs/api_meta/Content/meta_deployresult.htm).
|
|
39
|
+
def result
|
|
40
|
+
@result ||= client.status(id, :deploy)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Public: Returns true if the deploy was successful.
|
|
44
|
+
#
|
|
45
|
+
# Examples
|
|
46
|
+
#
|
|
47
|
+
# job.success?
|
|
48
|
+
# # => true
|
|
49
|
+
#
|
|
50
|
+
# Returns true or false based on the DeployResult.
|
|
51
|
+
def success?
|
|
52
|
+
result.success
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Internal: Base64 encodes the contents of the zip file.
|
|
58
|
+
#
|
|
59
|
+
# Examples
|
|
60
|
+
#
|
|
61
|
+
# job.payload
|
|
62
|
+
# # => '<lots of base64 encoded content>'
|
|
63
|
+
#
|
|
64
|
+
# Returns the content of the zip file encoded to base64.
|
|
65
|
+
def payload
|
|
66
|
+
Base64.encode64(File.open(file, 'rb').read)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Internal: Returns the path to the zip file.
|
|
70
|
+
def file
|
|
71
|
+
File.file?(@path) ? @path : zip_file
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Internal: Creates a zip file with the contents of the directory.
|
|
75
|
+
def zip_file
|
|
76
|
+
path = Dir.mktmpdir
|
|
77
|
+
File.join(path, 'deploy.zip').tap do |path|
|
|
78
|
+
Zip::File.open(path, Zip::File::CREATE) do |zip|
|
|
79
|
+
Dir["#{@path}/**/**"].each do |file|
|
|
80
|
+
zip.add(file.sub("#{File.dirname(@path)}/", ''), file)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module Metaforce
|
|
2
|
+
class Job::Retrieve < Job
|
|
3
|
+
|
|
4
|
+
# Public: Instantiate a new retrieve job.
|
|
5
|
+
#
|
|
6
|
+
# Examples
|
|
7
|
+
#
|
|
8
|
+
# job = Metaforce::Job::Retrieve.new(client)
|
|
9
|
+
# # => #<Metaforce::Job::Retrieve @id=nil>
|
|
10
|
+
#
|
|
11
|
+
# Returns self.
|
|
12
|
+
def initialize(client, options={})
|
|
13
|
+
super(client)
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Public: Perform the job.
|
|
18
|
+
#
|
|
19
|
+
# Examples
|
|
20
|
+
#
|
|
21
|
+
# job = Metaforce::Job::Retrieve.new(client)
|
|
22
|
+
# job.perform
|
|
23
|
+
# # => #<Metaforce::Job::Retrieve @id='1234'>
|
|
24
|
+
#
|
|
25
|
+
# Returns self.
|
|
26
|
+
def perform
|
|
27
|
+
@id = client._retrieve(@options).id
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Public: Get the detailed status of the retrieve.
|
|
32
|
+
#
|
|
33
|
+
# Examples
|
|
34
|
+
#
|
|
35
|
+
# job.result
|
|
36
|
+
# # => { :id => '1234', :zip_file => '<base64 encoded content>', ... }
|
|
37
|
+
#
|
|
38
|
+
# Returns the RetrieveResult (http://www.salesforce.com/us/developer/docs/api_meta/Content/meta_retrieveresult.htm).
|
|
39
|
+
def result
|
|
40
|
+
@result ||= client.status(id, :retrieve)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Public: Decodes the content of the returned zip file.
|
|
44
|
+
#
|
|
45
|
+
# Examples
|
|
46
|
+
#
|
|
47
|
+
# job.zip_file
|
|
48
|
+
# # => '<binary content>'
|
|
49
|
+
#
|
|
50
|
+
# Returns the decoded content.
|
|
51
|
+
def zip_file
|
|
52
|
+
Base64.decode64(result.zip_file)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Public: Unzips the returned zip file to the location.
|
|
56
|
+
#
|
|
57
|
+
# destination - Path to extract the contents to.
|
|
58
|
+
#
|
|
59
|
+
# Examples
|
|
60
|
+
#
|
|
61
|
+
# job.extract_to('./path')
|
|
62
|
+
# # => #<Metaforce::Job::Retrieve @id='1234'>
|
|
63
|
+
#
|
|
64
|
+
# Returns self.
|
|
65
|
+
def extract_to(destination)
|
|
66
|
+
return on_complete { |job| job.extract_to(destination) } unless started?
|
|
67
|
+
with_tmp_zip_file do |file|
|
|
68
|
+
unzip(file, destination)
|
|
69
|
+
end
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Internal: Unzips source to destination.
|
|
76
|
+
def unzip(source, destination)
|
|
77
|
+
Zip::File.open(source) do |zip|
|
|
78
|
+
zip.each do |f|
|
|
79
|
+
path = File.join(destination, f.name)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
81
|
+
zip.extract(f, path) { true }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Internal: Writes the zip file content to a temporary location so it can
|
|
87
|
+
# be extracted.
|
|
88
|
+
def with_tmp_zip_file
|
|
89
|
+
file = Tempfile.new('retrieve')
|
|
90
|
+
begin
|
|
91
|
+
file.binmode
|
|
92
|
+
file.write(zip_file)
|
|
93
|
+
file.rewind
|
|
94
|
+
yield file
|
|
95
|
+
ensure
|
|
96
|
+
file.close
|
|
97
|
+
file.unlink
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Metaforce
|
|
2
|
+
class Login
|
|
3
|
+
def initialize(username, password, security_token=nil)
|
|
4
|
+
@username, @password, @security_token = username, password, security_token
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# Public: Perform the login request.
|
|
8
|
+
#
|
|
9
|
+
# Returns a hash with the session id and server urls.
|
|
10
|
+
def login
|
|
11
|
+
response = client.request(:login) do
|
|
12
|
+
soap.body = {
|
|
13
|
+
:username => username,
|
|
14
|
+
:password => password
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
response.body[:login_response][:result]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Internal: Savon client.
|
|
23
|
+
def client
|
|
24
|
+
@client ||= Savon.client(Metaforce.configuration.partner_wsdl) do |wsdl|
|
|
25
|
+
wsdl.endpoint = Metaforce.configuration.endpoint
|
|
26
|
+
end.tap { |client| client.http.auth.ssl.verify_mode = :none }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Internal: Usernamed passed in from options.
|
|
30
|
+
def username
|
|
31
|
+
@username
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Internal: Password + Security Token combined.
|
|
35
|
+
def password
|
|
36
|
+
[@password, @security_token].join('')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require 'nokogiri'
|
|
2
|
+
require 'active_support/core_ext'
|
|
3
|
+
|
|
4
|
+
module Metaforce
|
|
5
|
+
class Manifest < Hash
|
|
6
|
+
|
|
7
|
+
# Public: Initializes a new instance of a manifest (package.xml) file.
|
|
8
|
+
#
|
|
9
|
+
# It can either take a hash:
|
|
10
|
+
# {
|
|
11
|
+
# :apex_class => [
|
|
12
|
+
# "TestController",
|
|
13
|
+
# "TestClass"
|
|
14
|
+
# ],
|
|
15
|
+
# :apex_component => [
|
|
16
|
+
# "SiteLogin"
|
|
17
|
+
# ]
|
|
18
|
+
# }
|
|
19
|
+
#
|
|
20
|
+
# Or an xml string containing the contents of a packge.xml file:
|
|
21
|
+
# <?xml version="1.0"?>
|
|
22
|
+
# <Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
|
23
|
+
# <types>
|
|
24
|
+
# <members>TestClass</members>
|
|
25
|
+
# <members>AnotherClass</members>
|
|
26
|
+
# <name>ApexClass</name>
|
|
27
|
+
# </types>
|
|
28
|
+
# <types>
|
|
29
|
+
# <members>Component</members>
|
|
30
|
+
# <name>ApexComponent</name>
|
|
31
|
+
# </types>
|
|
32
|
+
# <types>
|
|
33
|
+
# <members>Assets</members>
|
|
34
|
+
# <name>StaticResource</name>
|
|
35
|
+
# </types>
|
|
36
|
+
# <version>23.0</version>
|
|
37
|
+
# </Package>
|
|
38
|
+
#
|
|
39
|
+
def initialize(components={})
|
|
40
|
+
self.replace Hash.new { |h,k| h[k] = [] }
|
|
41
|
+
if components.is_a?(Hash)
|
|
42
|
+
self.merge!(components)
|
|
43
|
+
elsif components.is_a?(String)
|
|
44
|
+
self.parse(components)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Public: Returns a string containing a package.xml file
|
|
49
|
+
#
|
|
50
|
+
# <?xml version="1.0"?>
|
|
51
|
+
# <Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
|
52
|
+
# <types>
|
|
53
|
+
# <members>TestClass</members>
|
|
54
|
+
# <members>AnotherClass</members>
|
|
55
|
+
# <name>ApexClass</name>
|
|
56
|
+
# </types>
|
|
57
|
+
# <types>
|
|
58
|
+
# <members>Component</members>
|
|
59
|
+
# <name>ApexComponent</name>
|
|
60
|
+
# </types>
|
|
61
|
+
# <types>
|
|
62
|
+
# <members>Assets</members>
|
|
63
|
+
# <name>StaticResource</name>
|
|
64
|
+
# </types>
|
|
65
|
+
# <version>23.0</version>
|
|
66
|
+
# </Package>
|
|
67
|
+
def to_xml
|
|
68
|
+
xml_builder = Nokogiri::XML::Builder.new do |xml|
|
|
69
|
+
xml.Package('xmlns' => 'http://soap.sforce.com/2006/04/metadata') {
|
|
70
|
+
self.each do |key, members|
|
|
71
|
+
xml.types {
|
|
72
|
+
members.each do |member|
|
|
73
|
+
xml.members member
|
|
74
|
+
end
|
|
75
|
+
xml.name key.to_s.camelize
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
xml.version Metaforce.configuration.api_version
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
xml_builder.to_xml
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Public: Converts the manifest into a format that can be used by the
|
|
85
|
+
# metadata api.
|
|
86
|
+
def to_package
|
|
87
|
+
self.map do |type, members|
|
|
88
|
+
{ :members => members, :name => type.to_s.camelize }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Public: Parses a package.xml file
|
|
93
|
+
def parse(file)
|
|
94
|
+
document = Nokogiri::XML(file).remove_namespaces!
|
|
95
|
+
document.xpath('//types').each do |type|
|
|
96
|
+
name = type.xpath('name').first.content
|
|
97
|
+
key = name.underscore.to_sym
|
|
98
|
+
type.xpath('members').each do |member|
|
|
99
|
+
self[key] << member.content
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Metaforce
|
|
2
|
+
module Metadata
|
|
3
|
+
class Client < Metaforce::AbstractClient
|
|
4
|
+
require 'metaforce/metadata/client/file'
|
|
5
|
+
require 'metaforce/metadata/client/crud'
|
|
6
|
+
|
|
7
|
+
include Metaforce::Metadata::Client::File
|
|
8
|
+
include Metaforce::Metadata::Client::CRUD
|
|
9
|
+
|
|
10
|
+
endpoint :metadata_server_url
|
|
11
|
+
wsdl Metaforce.configuration.metadata_wsdl
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
"#<#{self.class} @options=#{@options.inspect}>"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Metaforce
|
|
2
|
+
module Metadata
|
|
3
|
+
class Client
|
|
4
|
+
module CRUD
|
|
5
|
+
|
|
6
|
+
# Public: Create metadata
|
|
7
|
+
#
|
|
8
|
+
# Examples
|
|
9
|
+
#
|
|
10
|
+
# client._create(:apex_page, :full_name => 'TestPage', label: 'Test page', :content => '<apex:page>foobar</apex:page>')
|
|
11
|
+
def _create(type, metadata={})
|
|
12
|
+
type = type.to_s.camelize
|
|
13
|
+
request :create do |soap|
|
|
14
|
+
soap.body = {
|
|
15
|
+
:metadata => prepare(metadata)
|
|
16
|
+
}.merge(attributes!(type))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Public: Delete metadata
|
|
21
|
+
#
|
|
22
|
+
# Examples
|
|
23
|
+
#
|
|
24
|
+
# client._delete(:apex_component, 'Component')
|
|
25
|
+
def _delete(type, *args)
|
|
26
|
+
type = type.to_s.camelize
|
|
27
|
+
metadata = args.map { |full_name| {:full_name => full_name} }
|
|
28
|
+
request :delete do |soap|
|
|
29
|
+
soap.body = {
|
|
30
|
+
:metadata => metadata
|
|
31
|
+
}.merge(attributes!(type))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Public: Update metadata
|
|
36
|
+
#
|
|
37
|
+
# Examples
|
|
38
|
+
#
|
|
39
|
+
# client._update(:apex_page, 'OldPage', :full_name => 'TestPage', :label => 'Test page', :content => '<apex:page>hello world</apex:page>')
|
|
40
|
+
def _update(type, current_name, metadata={})
|
|
41
|
+
type = type.to_s.camelize
|
|
42
|
+
request :update do |soap|
|
|
43
|
+
soap.body = {
|
|
44
|
+
:metadata => {
|
|
45
|
+
:current_name => current_name,
|
|
46
|
+
:metadata => prepare(metadata),
|
|
47
|
+
:attributes! => { :metadata => { 'xsi:type' => "ins0:#{type}" } }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create(*args)
|
|
54
|
+
Job::CRUD.new(self, :_create, args)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update(*args)
|
|
58
|
+
Job::CRUD.new(self, :_update, args)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete(*args)
|
|
62
|
+
Job::CRUD.new(self, :_delete, args)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def attributes!(type)
|
|
68
|
+
{:attributes! => { 'ins0:metadata' => { 'xsi:type' => "ins0:#{type}" } }}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Internal: Prepare metadata by base64 encoding any content keys.
|
|
72
|
+
def prepare(metadata)
|
|
73
|
+
metadata = Array[metadata].compact.flatten
|
|
74
|
+
metadata.each { |m| encode_content(m) }
|
|
75
|
+
metadata
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Internal: Base64 encodes any :content keys.
|
|
79
|
+
def encode_content(metadata)
|
|
80
|
+
metadata[:content] = Base64.encode64(metadata[:content]) if metadata.has_key?(:content)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|