openc-asana 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +4 -0
- data/.gitignore +13 -0
- data/.rspec +4 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +12 -0
- data/.yardopts +5 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +21 -0
- data/Guardfile +86 -0
- data/LICENSE.txt +21 -0
- data/README.md +355 -0
- data/Rakefile +65 -0
- data/examples/Gemfile +6 -0
- data/examples/Gemfile.lock +59 -0
- data/examples/api_token.rb +21 -0
- data/examples/cli_app.rb +25 -0
- data/examples/events.rb +38 -0
- data/examples/omniauth_integration.rb +54 -0
- data/lib/asana.rb +12 -0
- data/lib/asana/authentication.rb +8 -0
- data/lib/asana/authentication/oauth2.rb +42 -0
- data/lib/asana/authentication/oauth2/access_token_authentication.rb +51 -0
- data/lib/asana/authentication/oauth2/bearer_token_authentication.rb +32 -0
- data/lib/asana/authentication/oauth2/client.rb +50 -0
- data/lib/asana/authentication/token_authentication.rb +20 -0
- data/lib/asana/client.rb +124 -0
- data/lib/asana/client/configuration.rb +165 -0
- data/lib/asana/errors.rb +92 -0
- data/lib/asana/http_client.rb +155 -0
- data/lib/asana/http_client/environment_info.rb +53 -0
- data/lib/asana/http_client/error_handling.rb +103 -0
- data/lib/asana/http_client/response.rb +32 -0
- data/lib/asana/resource_includes/attachment_uploading.rb +33 -0
- data/lib/asana/resource_includes/collection.rb +68 -0
- data/lib/asana/resource_includes/event.rb +51 -0
- data/lib/asana/resource_includes/event_subscription.rb +14 -0
- data/lib/asana/resource_includes/events.rb +103 -0
- data/lib/asana/resource_includes/registry.rb +63 -0
- data/lib/asana/resource_includes/resource.rb +103 -0
- data/lib/asana/resource_includes/response_helper.rb +14 -0
- data/lib/asana/resources.rb +14 -0
- data/lib/asana/resources/attachment.rb +44 -0
- data/lib/asana/resources/project.rb +154 -0
- data/lib/asana/resources/story.rb +64 -0
- data/lib/asana/resources/tag.rb +120 -0
- data/lib/asana/resources/task.rb +300 -0
- data/lib/asana/resources/team.rb +55 -0
- data/lib/asana/resources/user.rb +72 -0
- data/lib/asana/resources/workspace.rb +91 -0
- data/lib/asana/ruby2_0_0_compatibility.rb +3 -0
- data/lib/asana/version.rb +5 -0
- data/lib/templates/index.js +8 -0
- data/lib/templates/resource.ejs +225 -0
- data/openc-asana.gemspec +32 -0
- data/package.json +7 -0
- metadata +200 -0
data/Rakefile
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'rspec/core/rake_task'
|
2
|
+
require 'rubocop/rake_task'
|
3
|
+
require 'yard'
|
4
|
+
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
6
|
+
|
7
|
+
RuboCop::RakeTask.new
|
8
|
+
|
9
|
+
YARD::Rake::YardocTask.new do |t|
|
10
|
+
t.stats_options = ['--list-undoc']
|
11
|
+
end
|
12
|
+
|
13
|
+
desc 'Generates a test resource from a YAML using the resource template.'
|
14
|
+
task :codegen do
|
15
|
+
`node spec/support/codegen.js`
|
16
|
+
end
|
17
|
+
|
18
|
+
namespace :bump do
|
19
|
+
def read_version
|
20
|
+
File.readlines('./lib/asana/version.rb')
|
21
|
+
.detect { |l| l =~ /VERSION/ }
|
22
|
+
.scan(/VERSION = '([^']+)/).flatten.first.split('.')
|
23
|
+
.map { |n| Integer(n) }
|
24
|
+
end
|
25
|
+
|
26
|
+
# rubocop:disable Metrics/MethodLength
|
27
|
+
def write_version(major, minor, patch)
|
28
|
+
str = <<-EOS
|
29
|
+
#:nodoc:
|
30
|
+
module Asana
|
31
|
+
# Public: Version of the gem.
|
32
|
+
VERSION = '#{major}.#{minor}.#{patch}'
|
33
|
+
end
|
34
|
+
EOS
|
35
|
+
File.open('./lib/asana/version.rb', 'w') do |f|
|
36
|
+
f.write str
|
37
|
+
end
|
38
|
+
|
39
|
+
new_version = "#{major}.#{minor}.#{patch}"
|
40
|
+
system('git add lib/asana/version.rb')
|
41
|
+
system(%(git commit -m "Bumped to #{new_version}" && ) +
|
42
|
+
%(git tag -a v#{new_version} -m "Version #{new_version}"))
|
43
|
+
puts "\nRun git push --tags to release."
|
44
|
+
end
|
45
|
+
|
46
|
+
desc 'Bumps a patch version'
|
47
|
+
task :patch do
|
48
|
+
major, minor, patch = read_version
|
49
|
+
write_version major, minor, patch + 1
|
50
|
+
end
|
51
|
+
|
52
|
+
desc 'Bumps a minor version'
|
53
|
+
task :minor do
|
54
|
+
major, minor, = read_version
|
55
|
+
write_version major, minor + 1, 0
|
56
|
+
end
|
57
|
+
|
58
|
+
desc 'Bumps a major version'
|
59
|
+
task :major do
|
60
|
+
major, = read_version
|
61
|
+
write_version major + 1, 0, 0
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
task default: [:codegen, :spec, :rubocop, :yard]
|
data/examples/Gemfile
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
PATH
|
2
|
+
remote: ../
|
3
|
+
specs:
|
4
|
+
asana (0.1.1)
|
5
|
+
faraday (~> 0.9)
|
6
|
+
faraday_middleware (~> 0.9)
|
7
|
+
faraday_middleware-multi_json (~> 0.0)
|
8
|
+
oauth2 (~> 1.0)
|
9
|
+
|
10
|
+
GEM
|
11
|
+
remote: https://rubygems.org/
|
12
|
+
specs:
|
13
|
+
faraday (0.9.1)
|
14
|
+
multipart-post (>= 1.2, < 3)
|
15
|
+
faraday_middleware (0.9.1)
|
16
|
+
faraday (>= 0.7.4, < 0.10)
|
17
|
+
faraday_middleware-multi_json (0.0.6)
|
18
|
+
faraday_middleware
|
19
|
+
multi_json
|
20
|
+
hashie (3.4.1)
|
21
|
+
jwt (1.5.0)
|
22
|
+
multi_json (1.11.0)
|
23
|
+
multi_xml (0.5.5)
|
24
|
+
multipart-post (2.0.0)
|
25
|
+
oauth2 (1.0.0)
|
26
|
+
faraday (>= 0.8, < 0.10)
|
27
|
+
jwt (~> 1.0)
|
28
|
+
multi_json (~> 1.3)
|
29
|
+
multi_xml (~> 0.5)
|
30
|
+
rack (~> 1.2)
|
31
|
+
omniauth (1.2.2)
|
32
|
+
hashie (>= 1.2, < 4)
|
33
|
+
rack (~> 1.0)
|
34
|
+
omniauth-asana (0.0.1)
|
35
|
+
omniauth (~> 1.0)
|
36
|
+
omniauth-oauth2 (~> 1.1)
|
37
|
+
omniauth-oauth2 (1.3.0)
|
38
|
+
oauth2 (~> 1.0)
|
39
|
+
omniauth (~> 1.2)
|
40
|
+
rack (1.6.1)
|
41
|
+
rack-protection (1.5.3)
|
42
|
+
rack
|
43
|
+
sinatra (1.4.6)
|
44
|
+
rack (~> 1.4)
|
45
|
+
rack-protection (~> 1.4)
|
46
|
+
tilt (>= 1.3, < 3)
|
47
|
+
tilt (2.0.1)
|
48
|
+
|
49
|
+
PLATFORMS
|
50
|
+
ruby
|
51
|
+
|
52
|
+
DEPENDENCIES
|
53
|
+
asana!
|
54
|
+
omniauth
|
55
|
+
omniauth-asana
|
56
|
+
sinatra
|
57
|
+
|
58
|
+
BUNDLED WITH
|
59
|
+
1.10.3
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler.require
|
3
|
+
require 'asana'
|
4
|
+
|
5
|
+
api_token = ENV['ASANA_API_TOKEN']
|
6
|
+
unless api_token
|
7
|
+
abort "Run this program with the env var ASANA_API_TOKEN.\n" \
|
8
|
+
"Go to http://app.asana.com/-/account_api to see your API token."
|
9
|
+
end
|
10
|
+
|
11
|
+
client = Asana::Client.new do |c|
|
12
|
+
c.authentication :api_token, api_token
|
13
|
+
end
|
14
|
+
|
15
|
+
puts "My Workspaces:"
|
16
|
+
client.workspaces.find_all.each do |workspace|
|
17
|
+
puts "\t* #{workspace.name} - tags:"
|
18
|
+
client.tags.find_by_workspace(workspace: workspace.id).each do |tag|
|
19
|
+
puts "\t\t- #{tag.name}"
|
20
|
+
end
|
21
|
+
end
|
data/examples/cli_app.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler.require
|
3
|
+
require 'asana'
|
4
|
+
|
5
|
+
id, secret = ENV['ASANA_CLIENT_ID'], ENV['ASANA_CLIENT_SECRET']
|
6
|
+
unless id && secret
|
7
|
+
abort "Run this program with the env vars ASANA_CLIENT_ID and ASANA_CLIENT_SECRET.\n" \
|
8
|
+
"Refer to https://asana.com/developers/documentation/getting-started/authentication "\
|
9
|
+
"to get your credentials." \
|
10
|
+
"The redirect URI for your application should be \"urn:ietf:wg:oauth:2.0:oob\"."
|
11
|
+
end
|
12
|
+
|
13
|
+
access_token = Asana::Authentication::OAuth2.offline_flow(client_id: id,
|
14
|
+
client_secret: secret)
|
15
|
+
client = Asana::Client.new do |c|
|
16
|
+
c.authentication :oauth2, access_token
|
17
|
+
end
|
18
|
+
|
19
|
+
puts "My Workspaces:"
|
20
|
+
client.workspaces.find_all.each do |workspace|
|
21
|
+
puts "\t* #{workspace.name} - tags:"
|
22
|
+
client.tags.find_by_workspace(workspace: workspace.id).each do |tag|
|
23
|
+
puts "\t\t- #{tag.name}"
|
24
|
+
end
|
25
|
+
end
|
data/examples/events.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
require 'bundler'
|
3
|
+
Bundler.require
|
4
|
+
require 'asana'
|
5
|
+
|
6
|
+
api_token = ENV['ASANA_API_TOKEN']
|
7
|
+
unless api_token
|
8
|
+
abort "Run this program with the env var ASANA_API_TOKEN.\n" \
|
9
|
+
"Go to http://app.asana.com/-/account_api to see your API token."
|
10
|
+
end
|
11
|
+
|
12
|
+
client = Asana::Client.new do |c|
|
13
|
+
c.authentication :api_token, api_token
|
14
|
+
end
|
15
|
+
|
16
|
+
workspace = client.workspaces.find_all.first
|
17
|
+
task = client.tasks.find_all(assignee: "me", workspace: workspace.id).first
|
18
|
+
unless task
|
19
|
+
task = client.tasks.create(workspace: workspace.id, name: "Hello world!", assignee: "me")
|
20
|
+
end
|
21
|
+
|
22
|
+
Thread.abort_on_exception = true
|
23
|
+
|
24
|
+
Thread.new do
|
25
|
+
puts "Listening for 'changed' events on #{task} in one thread..."
|
26
|
+
task.events(wait: 2).lazy.select { |event| event.action == 'changed' }.each do |event|
|
27
|
+
puts "#{event.user.name} changed #{event.resource}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
Thread.new do
|
32
|
+
puts "Listening for non-'changed' events on #{task} in another thread..."
|
33
|
+
task.events(wait: 1).lazy.reject { |event| event.action == 'changed' }.each do |event|
|
34
|
+
puts "'#{event.action}' event: #{event}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
sleep
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler.require
|
3
|
+
require 'asana'
|
4
|
+
|
5
|
+
class SinatraApp < Sinatra::Base
|
6
|
+
id, secret = ENV['ASANA_CLIENT_ID'], ENV['ASANA_CLIENT_SECRET']
|
7
|
+
unless id && secret
|
8
|
+
abort "Run this program with the env vars ASANA_CLIENT_ID and ASANA_CLIENT_SECRET.\n" \
|
9
|
+
"Refer to https://asana.com/developers/documentation/getting-started/authentication "\
|
10
|
+
"to get your credentials."
|
11
|
+
end
|
12
|
+
|
13
|
+
use OmniAuth::Strategies::Asana, id, secret
|
14
|
+
|
15
|
+
enable :sessions
|
16
|
+
|
17
|
+
get '/' do
|
18
|
+
if $client
|
19
|
+
'<a href="/workspaces">My Workspaces</a>'
|
20
|
+
else
|
21
|
+
'<a href="/sign_in">sign in to asana</a>'
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
get '/workspaces' do
|
26
|
+
if $client
|
27
|
+
"<h1>My Workspaces</h1>" \
|
28
|
+
"<ul>" + $client.workspaces.find_all.map { |w| "<li>#{w.name}</li>" }.join + "</ul>"
|
29
|
+
else
|
30
|
+
redirect '/sign_in'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
get '/auth/:name/callback' do
|
35
|
+
creds = request.env["omniauth.auth"]["credentials"].tap { |h| h.delete('expires') }
|
36
|
+
strategy = request.env["omniauth.strategy"]
|
37
|
+
access_token = OAuth2::AccessToken.from_hash(strategy.client, creds).refresh!
|
38
|
+
$client = Asana::Client.new do |c|
|
39
|
+
c.authentication :oauth2, access_token
|
40
|
+
end
|
41
|
+
redirect '/workspaces'
|
42
|
+
end
|
43
|
+
|
44
|
+
get '/sign_in' do
|
45
|
+
redirect '/auth/asana'
|
46
|
+
end
|
47
|
+
|
48
|
+
get '/sign_out' do
|
49
|
+
$client = nil
|
50
|
+
redirect '/'
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
SinatraApp.run! if __FILE__ == $0
|
data/lib/asana.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'asana/ruby2_0_0_compatibility'
|
2
|
+
require 'asana/authentication'
|
3
|
+
require 'asana/resources'
|
4
|
+
require 'asana/client'
|
5
|
+
require 'asana/errors'
|
6
|
+
require 'asana/http_client'
|
7
|
+
require 'asana/version'
|
8
|
+
|
9
|
+
# Public: Top-level namespace of the Asana API Ruby client.
|
10
|
+
module Asana
|
11
|
+
include Asana::Resources
|
12
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative 'oauth2/bearer_token_authentication'
|
2
|
+
require_relative 'oauth2/access_token_authentication'
|
3
|
+
require_relative 'oauth2/client'
|
4
|
+
|
5
|
+
module Asana
|
6
|
+
module Authentication
|
7
|
+
# Public: Deals with OAuth2 authentication. Contains a function to get an
|
8
|
+
# access token throught a browserless authentication flow, needed for some
|
9
|
+
# applications such as CLI applications.
|
10
|
+
module OAuth2
|
11
|
+
module_function
|
12
|
+
|
13
|
+
# Public: Retrieves an access token through an offline authentication
|
14
|
+
# flow. If your application can receive HTTP requests, you might want to
|
15
|
+
# opt for a browser-based flow and use the omniauth-asana gem instead.
|
16
|
+
#
|
17
|
+
# Your registered application's redirect_uri should be exactly
|
18
|
+
# "urn:ietf:wg:oauth:2.0:oob".
|
19
|
+
#
|
20
|
+
# client_id - [String] the client id of the registered Asana API
|
21
|
+
# application.
|
22
|
+
# client_secret - [String] the client secret of the registered Asana API
|
23
|
+
# application.
|
24
|
+
#
|
25
|
+
# Returns an ::OAuth2::AccessToken object.
|
26
|
+
#
|
27
|
+
# Note: This function reads from STDIN and writes to STDOUT. It is meant
|
28
|
+
# to be used only within the context of a CLI application.
|
29
|
+
def offline_flow(client_id: required('client_id'),
|
30
|
+
client_secret: required('client_secret'))
|
31
|
+
client = Client.new(client_id: client_id,
|
32
|
+
client_secret: client_secret,
|
33
|
+
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob')
|
34
|
+
STDOUT.puts '1. Go to the following URL to authorize the ' \
|
35
|
+
" application: #{client.authorize_url}"
|
36
|
+
STDOUT.puts '2. Paste the authorization code here: '
|
37
|
+
auth_code = STDIN.gets.chomp
|
38
|
+
client.token_from_auth_code(auth_code)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Asana
|
2
|
+
module Authentication
|
3
|
+
module OAuth2
|
4
|
+
# Public: A mechanism to authenticate with an OAuth2 access token (a
|
5
|
+
# bearer token and a refresh token) or just a refresh token.
|
6
|
+
class AccessTokenAuthentication
|
7
|
+
# Public: Builds an AccessTokenAuthentication from a refresh token and
|
8
|
+
# client credentials, by refreshing into a new one.
|
9
|
+
#
|
10
|
+
# refresh_token - [String] a refresh token
|
11
|
+
# client_id - [String] the client id of the registered Asana API
|
12
|
+
# Application.
|
13
|
+
# client_secret - [String] the client secret of the registered Asana API
|
14
|
+
# Application.
|
15
|
+
# redirect_uri - [String] the redirect uri of the registered Asana API
|
16
|
+
# Application.
|
17
|
+
#
|
18
|
+
# Returns an [AccessTokenAuthentication] instance with a refreshed
|
19
|
+
# access token.
|
20
|
+
def self.from_refresh_token(refresh_token,
|
21
|
+
client_id: required('client_id'),
|
22
|
+
client_secret: required('client_secret'),
|
23
|
+
redirect_uri: required('redirect_uri'))
|
24
|
+
client = Client.new(client_id: client_id,
|
25
|
+
client_secret: client_secret,
|
26
|
+
redirect_uri: redirect_uri)
|
27
|
+
new(client.token_from_refresh_token(refresh_token))
|
28
|
+
end
|
29
|
+
|
30
|
+
# Public: Initializes a new AccessTokenAuthentication.
|
31
|
+
#
|
32
|
+
# access_token - [::OAuth2::AccessToken] An ::OAuth2::AccessToken
|
33
|
+
# object.
|
34
|
+
def initialize(access_token)
|
35
|
+
@token = access_token
|
36
|
+
end
|
37
|
+
|
38
|
+
# Public: Configures a Faraday connection injecting a bearer token,
|
39
|
+
# auto-refreshing it when needed.
|
40
|
+
#
|
41
|
+
# connection - [Faraday::Connection] the Faraday connection instance.
|
42
|
+
#
|
43
|
+
# Returns nothing.
|
44
|
+
def configure(connection)
|
45
|
+
@token = @token.refresh! if @token.expired?
|
46
|
+
connection.request :oauth2, @token.token
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Asana
|
2
|
+
module Authentication
|
3
|
+
module OAuth2
|
4
|
+
# Public: A mechanism to authenticate with an OAuth2 bearer token obtained
|
5
|
+
# somewhere, for instance through omniauth-asana.
|
6
|
+
#
|
7
|
+
# Note: This authentication mechanism doesn't support token refreshing. If
|
8
|
+
# you'd like refreshing and you have a refresh token as well as a bearer
|
9
|
+
# token, you can generate a proper access token with
|
10
|
+
# {AccessTokenAuthentication.from_refresh_token}.
|
11
|
+
class BearerTokenAuthentication
|
12
|
+
# Public: Initializes a new BearerTokenAuthentication with a plain
|
13
|
+
# bearer token.
|
14
|
+
#
|
15
|
+
# bearer_token - [String] a plain bearer token.
|
16
|
+
def initialize(bearer_token)
|
17
|
+
@token = bearer_token
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: Configures a Faraday connection injecting its token as an
|
21
|
+
# OAuth2 bearer token.
|
22
|
+
#
|
23
|
+
# connection - [Faraday::Connection] the Faraday connection instance.
|
24
|
+
#
|
25
|
+
# Returns nothing.
|
26
|
+
def configure(connection)
|
27
|
+
connection.request :oauth2, @token
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'oauth2'
|
2
|
+
|
3
|
+
module Asana
|
4
|
+
module Authentication
|
5
|
+
module OAuth2
|
6
|
+
# Public: Deals with the details of obtaining an OAuth2 authorization URL
|
7
|
+
# and obtaining access tokens from either authorization codes or refresh
|
8
|
+
# tokens.
|
9
|
+
class Client
|
10
|
+
# Public: Initializes a new client with client credentials associated
|
11
|
+
# with a registered Asana API application.
|
12
|
+
#
|
13
|
+
# client_id - [String] a client id from the registered application
|
14
|
+
# client_secret - [String] a client secret from the registered
|
15
|
+
# application
|
16
|
+
# redirect_uri - [String] a redirect uri from the registered
|
17
|
+
# application
|
18
|
+
def initialize(client_id: required('client_id'),
|
19
|
+
client_secret: required('client_secret'),
|
20
|
+
redirect_uri: required('redirect_uri'))
|
21
|
+
@client = ::OAuth2::Client.new(client_id, client_secret,
|
22
|
+
site: 'https://app.asana.com',
|
23
|
+
authorize_url: '/-/oauth_authorize',
|
24
|
+
token_url: '/-/oauth_token')
|
25
|
+
@redirect_uri = redirect_uri
|
26
|
+
end
|
27
|
+
|
28
|
+
# Public:
|
29
|
+
# Returns the [String] OAuth2 authorize URL.
|
30
|
+
def authorize_url
|
31
|
+
@client.auth_code.authorize_url(redirect_uri: @redirect_uri)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Public: Retrieves a token from an authorization code.
|
35
|
+
#
|
36
|
+
# Returns the [::OAuth2::AccessToken] token.
|
37
|
+
def token_from_auth_code(auth_code)
|
38
|
+
@client.auth_code.get_token(auth_code, redirect_uri: @redirect_uri)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Public: Retrieves a token from a refresh token.
|
42
|
+
#
|
43
|
+
# Returns the refreshed [::OAuth2::AccessToken] token.
|
44
|
+
def token_from_refresh_token(token)
|
45
|
+
::OAuth2::AccessToken.new(@client, '', refresh_token: token).refresh!
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|