cloudinary 1.9.1 → 1.10.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 +15 -5
- data/.gitignore +1 -0
- data/CHANGELOG.md +32 -0
- data/cloudinary.gemspec +2 -1
- data/lib/cloudinary/api.rb +28 -13
- data/lib/cloudinary/cache.rb +38 -0
- data/lib/cloudinary/cache/breakpoints_cache.rb +31 -0
- data/lib/cloudinary/cache/key_value_cache_adapter.rb +25 -0
- data/lib/cloudinary/cache/rails_cache_adapter.rb +34 -0
- data/lib/cloudinary/cache/storage/rails_cache_storage.rb +5 -0
- data/lib/cloudinary/helper.rb +47 -4
- data/lib/cloudinary/responsive.rb +111 -0
- data/lib/cloudinary/uploader.rb +22 -4
- data/lib/cloudinary/utils.rb +122 -5
- data/lib/cloudinary/version.rb +1 -1
- data/spec/access_control_spec.rb +3 -0
- data/spec/api_spec.rb +19 -13
- data/spec/auth_token_spec.rb +8 -10
- data/spec/cache_spec.rb +109 -0
- data/spec/cloudinary_helper_spec.rb +147 -12
- data/spec/image_spec.rb +107 -0
- data/spec/spec_helper.rb +38 -17
- data/spec/uploader_spec.rb +23 -8
- data/spec/utils_spec.rb +46 -3
- data/spec/video_tag_spec.rb +7 -5
- data/spec/video_url_spec.rb +4 -2
- data/tools/update_version +202 -0
- metadata +83 -30
data/lib/cloudinary/uploader.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# Copyright Cloudinary
|
2
2
|
require 'rest_client'
|
3
3
|
require 'json'
|
4
|
+
require 'cloudinary/cache'
|
4
5
|
|
5
6
|
class Cloudinary::Uploader
|
6
7
|
|
@@ -100,6 +101,7 @@ class Cloudinary::Uploader
|
|
100
101
|
else
|
101
102
|
filename = "cloudinaryfile"
|
102
103
|
end
|
104
|
+
unique_upload_id = Cloudinary::Utils.random_public_id
|
103
105
|
upload = nil
|
104
106
|
index = 0
|
105
107
|
chunk_size = options[:chunk_size] || 20_000_000
|
@@ -107,8 +109,7 @@ class Cloudinary::Uploader
|
|
107
109
|
buffer = file.read(chunk_size)
|
108
110
|
current_loc = index*chunk_size
|
109
111
|
range = "bytes #{current_loc}-#{current_loc+buffer.size - 1}/#{file.size}"
|
110
|
-
upload = upload_large_part(Cloudinary::Blob.new(buffer, :original_filename => filename), options.merge(:public_id => public_id, :content_range => range))
|
111
|
-
public_id = upload["public_id"]
|
112
|
+
upload = upload_large_part(Cloudinary::Blob.new(buffer, :original_filename => filename), options.merge(:public_id => public_id, :unique_upload_id => unique_upload_id, :content_range => range))
|
112
113
|
index += 1
|
113
114
|
end
|
114
115
|
upload
|
@@ -292,7 +293,7 @@ class Cloudinary::Uploader
|
|
292
293
|
return call_api("context", options) do
|
293
294
|
{
|
294
295
|
:timestamp => (options[:timestamp] || Time.now.to_i),
|
295
|
-
:context => Cloudinary::Utils.
|
296
|
+
:context => Cloudinary::Utils.encode_context(context),
|
296
297
|
:public_ids => Cloudinary::Utils.build_array(public_ids),
|
297
298
|
:command => command,
|
298
299
|
:type => options[:type]
|
@@ -303,6 +304,7 @@ class Cloudinary::Uploader
|
|
303
304
|
def self.call_api(action, options)
|
304
305
|
options = options.clone
|
305
306
|
return_error = options.delete(:return_error)
|
307
|
+
use_cache = options[:use_cache] || Cloudinary.config.use_cache
|
306
308
|
|
307
309
|
params, non_signable = yield
|
308
310
|
non_signable ||= []
|
@@ -320,6 +322,7 @@ class Cloudinary::Uploader
|
|
320
322
|
api_url = Cloudinary::Utils.cloudinary_api_url(action, options)
|
321
323
|
headers = { "User-Agent" => Cloudinary::USER_AGENT }
|
322
324
|
headers['Content-Range'] = options[:content_range] if options[:content_range]
|
325
|
+
headers['X-Unique-Upload-Id'] = options[:unique_upload_id] if options[:unique_upload_id]
|
323
326
|
headers.merge!(options[:extra_headers]) if options[:extra_headers]
|
324
327
|
RestClient::Request.execute(:method => :post, :url => api_url, :payload => params.reject { |k, v| v.nil? || v=="" }, :timeout => timeout, :headers => headers) do
|
325
328
|
|response, request, tmpresult|
|
@@ -338,11 +341,26 @@ class Cloudinary::Uploader
|
|
338
341
|
end
|
339
342
|
end
|
340
343
|
end
|
341
|
-
|
344
|
+
if use_cache && !result.nil?
|
345
|
+
cache_results(result)
|
346
|
+
end
|
342
347
|
result
|
343
348
|
end
|
344
349
|
|
345
350
|
def self.build_custom_headers(headers)
|
346
351
|
Array(headers).map { |*a| a.join(": ") }.join("\n")
|
347
352
|
end
|
353
|
+
|
354
|
+
def self.cache_results(result)
|
355
|
+
if result["responsive_breakpoints"]
|
356
|
+
result["responsive_breakpoints"].each do |bp|
|
357
|
+
Cloudinary::Cache.set(
|
358
|
+
result["public_id"],
|
359
|
+
{type: result["type"], resource_type: result["resource_type"], raw_transformation: bp["transformation"]},
|
360
|
+
bp["breakpoints"].map{|o| o['width']}
|
361
|
+
)
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
end
|
348
366
|
end
|
data/lib/cloudinary/utils.rb
CHANGED
@@ -6,6 +6,7 @@ require 'aws_cf_signer'
|
|
6
6
|
require 'json'
|
7
7
|
require 'cgi'
|
8
8
|
require 'cloudinary/auth_token'
|
9
|
+
require 'cloudinary/responsive'
|
9
10
|
|
10
11
|
class Cloudinary::Utils
|
11
12
|
# @deprecated Use Cloudinary::SHARED_CDN
|
@@ -40,13 +41,100 @@ class Cloudinary::Utils
|
|
40
41
|
"tags" => "tags",
|
41
42
|
"width" => "w"
|
42
43
|
}
|
44
|
+
|
45
|
+
URL_KEYS = %w[
|
46
|
+
api_secret
|
47
|
+
auth_token
|
48
|
+
cdn_subdomain
|
49
|
+
cloud_name
|
50
|
+
cname
|
51
|
+
format
|
52
|
+
private_cdn
|
53
|
+
resource_type
|
54
|
+
secure
|
55
|
+
secure_cdn_subdomain
|
56
|
+
secure_distribution
|
57
|
+
shorten
|
58
|
+
sign_url
|
59
|
+
ssl_detected
|
60
|
+
type
|
61
|
+
url_suffix
|
62
|
+
use_root_path
|
63
|
+
version
|
64
|
+
].map(&:to_sym)
|
65
|
+
|
66
|
+
|
67
|
+
TRANSFORMATION_PARAMS = %w[
|
68
|
+
angle
|
69
|
+
aspect_ratio
|
70
|
+
audio_codec
|
71
|
+
audio_frequency
|
72
|
+
background
|
73
|
+
bit_rate
|
74
|
+
border
|
75
|
+
color
|
76
|
+
color_space
|
77
|
+
crop
|
78
|
+
custom_function
|
79
|
+
default_image
|
80
|
+
delay
|
81
|
+
density
|
82
|
+
dpr
|
83
|
+
duration
|
84
|
+
effect
|
85
|
+
end_offset
|
86
|
+
fetch_format
|
87
|
+
flags
|
88
|
+
gravity
|
89
|
+
height
|
90
|
+
if
|
91
|
+
keyframe_interval
|
92
|
+
offset
|
93
|
+
opacity
|
94
|
+
overlay
|
95
|
+
page
|
96
|
+
prefix
|
97
|
+
quality
|
98
|
+
radius
|
99
|
+
raw_transformation
|
100
|
+
responsive_width
|
101
|
+
size
|
102
|
+
start_offset
|
103
|
+
streaming_profile
|
104
|
+
transformation
|
105
|
+
underlay
|
106
|
+
variables
|
107
|
+
video_codec
|
108
|
+
video_sampling
|
109
|
+
width
|
110
|
+
x
|
111
|
+
y
|
112
|
+
zoom
|
113
|
+
].map(&:to_sym)
|
114
|
+
|
115
|
+
def self.extract_config_params(options)
|
116
|
+
options.select{|k,v| URL_KEYS.include?(k)}
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.extract_transformation_params(options)
|
120
|
+
options.select{|k,v| TRANSFORMATION_PARAMS.include?(k)}
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.chain_transformation(options, *transformation)
|
124
|
+
base_options = extract_config_params(options)
|
125
|
+
transformation = transformation.reject(&:nil?)
|
126
|
+
base_options[:transformation] = build_array(extract_transformation_params(options)).concat(transformation)
|
127
|
+
base_options
|
128
|
+
end
|
129
|
+
|
130
|
+
|
43
131
|
# Warning: options are being destructively updated!
|
44
132
|
def self.generate_transformation_string(options={}, allow_implicit_crop_mode = false)
|
45
133
|
# allow_implicit_crop_mode was added to support height and width parameters without specifying a crop mode.
|
46
134
|
# This only apply to this (cloudinary_gem) SDK
|
47
135
|
|
48
136
|
if options.is_a?(Array)
|
49
|
-
return options.map{|base_transformation| generate_transformation_string(base_transformation.clone, allow_implicit_crop_mode)}.join("/")
|
137
|
+
return options.map{|base_transformation| generate_transformation_string(base_transformation.clone, allow_implicit_crop_mode)}.reject(&:blank?).join("/")
|
50
138
|
end
|
51
139
|
|
52
140
|
symbolize_keys!(options)
|
@@ -105,6 +193,7 @@ class Cloudinary::Utils
|
|
105
193
|
overlay = process_layer(options.delete(:overlay))
|
106
194
|
underlay = process_layer(options.delete(:underlay))
|
107
195
|
ifValue = process_if(options.delete(:if))
|
196
|
+
custom_function = process_custom_function(options.delete(:custom_function))
|
108
197
|
|
109
198
|
params = {
|
110
199
|
:a => normalize_expression(angle),
|
@@ -116,6 +205,7 @@ class Cloudinary::Utils
|
|
116
205
|
:dpr => normalize_expression(dpr),
|
117
206
|
:e => normalize_expression(effect),
|
118
207
|
:fl => flags,
|
208
|
+
:fn => custom_function,
|
119
209
|
:h => normalize_expression(height),
|
120
210
|
:l => overlay,
|
121
211
|
:o => normalize_expression(options.delete(:opacity)),
|
@@ -230,9 +320,13 @@ class Cloudinary::Utils
|
|
230
320
|
text_style = nil
|
231
321
|
components = []
|
232
322
|
|
233
|
-
|
234
|
-
|
235
|
-
|
323
|
+
if public_id.present?
|
324
|
+
if type == "fetch" && public_id.match(%r(^https?:/)i)
|
325
|
+
public_id = Base64.urlsafe_encode64(public_id)
|
326
|
+
else
|
327
|
+
public_id = public_id.gsub("/", ":")
|
328
|
+
public_id = "#{public_id}.#{format}" if format
|
329
|
+
end
|
236
330
|
end
|
237
331
|
|
238
332
|
if text.blank? && resource_type != "text"
|
@@ -348,9 +442,9 @@ class Cloudinary::Utils
|
|
348
442
|
# Warning: options are being destructively updated!
|
349
443
|
def self.unsigned_download_url(source, options = {})
|
350
444
|
|
445
|
+
patch_fetch_format(options)
|
351
446
|
type = options.delete(:type)
|
352
447
|
|
353
|
-
options[:fetch_format] ||= options.delete(:format) if type.to_s == "fetch"
|
354
448
|
transformation = self.generate_transformation_string(options)
|
355
449
|
|
356
450
|
resource_type = options.delete(:resource_type)
|
@@ -410,6 +504,7 @@ class Cloudinary::Utils
|
|
410
504
|
|
411
505
|
transformation = transformation.gsub(%r(([^:])//), '\1/')
|
412
506
|
if sign_url && ( !auth_token || auth_token.empty?)
|
507
|
+
raise(CloudinaryException, "Must supply api_secret") if (secret.nil? || secret.empty?)
|
413
508
|
to_sign = [transformation, sign_version && version, source_to_sign].reject(&:blank?).join("/")
|
414
509
|
to_sign = fully_unescape(to_sign)
|
415
510
|
signature = 's--' + Base64.urlsafe_encode64(Digest::SHA1.digest(to_sign + secret))[0,8] + '--'
|
@@ -968,4 +1063,26 @@ class Cloudinary::Utils
|
|
968
1063
|
end
|
969
1064
|
private_class_method :process_video_params
|
970
1065
|
|
1066
|
+
def self.process_custom_function(param)
|
1067
|
+
return param unless param.is_a? Hash
|
1068
|
+
|
1069
|
+
function_type = param[:function_type]
|
1070
|
+
source = param[:source]
|
1071
|
+
|
1072
|
+
source = Base64.urlsafe_encode64(source) if function_type == "remote"
|
1073
|
+
"#{function_type}:#{source}"
|
1074
|
+
end
|
1075
|
+
|
1076
|
+
#
|
1077
|
+
# Handle the format parameter for fetch urls
|
1078
|
+
# @private
|
1079
|
+
# @param options url and transformation options. This argument may be changed by the function!
|
1080
|
+
#
|
1081
|
+
def self.patch_fetch_format(options={})
|
1082
|
+
if options[:type] === :fetch
|
1083
|
+
format_arg = options.delete(:format)
|
1084
|
+
options[:fetch_format] ||= format_arg
|
1085
|
+
end
|
1086
|
+
end
|
1087
|
+
|
971
1088
|
end
|
data/lib/cloudinary/version.rb
CHANGED
data/spec/access_control_spec.rb
CHANGED
data/spec/api_spec.rb
CHANGED
@@ -202,9 +202,9 @@ describe Cloudinary::Api do
|
|
202
202
|
|
203
203
|
describe 'transformations' do
|
204
204
|
it "should allow listing transformations" do
|
205
|
-
|
206
|
-
expect(
|
207
|
-
expect(
|
205
|
+
transformations = @api.transformations()["transformations"]
|
206
|
+
expect(transformations[0]).not_to be_empty
|
207
|
+
expect(transformations[0]["used"]).to eq(true)
|
208
208
|
end
|
209
209
|
|
210
210
|
it "should allow getting transformation metadata" do
|
@@ -269,7 +269,7 @@ describe Cloudinary::Api do
|
|
269
269
|
expect(RestClient::Request).to receive(:execute).with(deep_hash_value( [:payload, :named ]=> true))
|
270
270
|
@api.transformations :named => true
|
271
271
|
end
|
272
|
-
|
272
|
+
|
273
273
|
end
|
274
274
|
it "should allow deleting implicit transformation" do
|
275
275
|
@api.transformation(TEST_TRANSFOMATION)
|
@@ -467,12 +467,12 @@ describe Cloudinary::Api do
|
|
467
467
|
|
468
468
|
expect(result["published"]).to be_an_instance_of(Array)
|
469
469
|
expect(result["published"].length).to eq(1)
|
470
|
-
|
470
|
+
|
471
471
|
resource = result["published"][0]
|
472
|
-
|
472
|
+
|
473
473
|
expect(resource["public_id"]).to eq(publicId)
|
474
474
|
expect(resource["type"]).to eq('upload')
|
475
|
-
|
475
|
+
|
476
476
|
bytes = resource["bytes"]
|
477
477
|
end
|
478
478
|
it "should publish resources by prefix and overwrite" do
|
@@ -480,13 +480,13 @@ describe Cloudinary::Api do
|
|
480
480
|
|
481
481
|
expect(result["published"]).to be_an_instance_of(Array)
|
482
482
|
expect(result["published"].length).to eq(1)
|
483
|
-
|
483
|
+
|
484
484
|
resource = result["published"][0]
|
485
|
-
|
485
|
+
|
486
486
|
expect(resource["public_id"]).to eq(publicId)
|
487
487
|
expect(resource["bytes"]).not_to eq(bytes)
|
488
488
|
expect(resource["type"]).to eq('upload')
|
489
|
-
|
489
|
+
|
490
490
|
bytes = resource["bytes"]
|
491
491
|
end
|
492
492
|
it "should publish resources by tag and overwrite" do
|
@@ -494,16 +494,22 @@ describe Cloudinary::Api do
|
|
494
494
|
|
495
495
|
expect(result["published"]).to be_an_instance_of(Array)
|
496
496
|
expect(result["published"].length).to eq(1)
|
497
|
-
|
497
|
+
|
498
498
|
resource = result["published"][0]
|
499
|
-
|
499
|
+
|
500
500
|
expect(resource["public_id"]).to eq(publicId)
|
501
501
|
expect(resource["bytes"]).not_to eq(bytes)
|
502
502
|
expect(resource["type"]).to eq('upload')
|
503
|
-
|
503
|
+
|
504
504
|
bytes = resource["bytes"]
|
505
505
|
end
|
506
506
|
end
|
507
|
+
describe "json breakpoints" do
|
508
|
+
it "should retrieve breakpoints as json array" do
|
509
|
+
bp = Cloudinary::Api.get_breakpoints(test_id_1, srcset: {min_width:10, max_width:2000, bytes_step: 10, max_images: 20})
|
510
|
+
expect(bp).to be_truthy
|
511
|
+
end
|
512
|
+
end
|
507
513
|
end
|
508
514
|
|
509
515
|
describe Cloudinary::Api::Response do
|
data/spec/auth_token_spec.rb
CHANGED
@@ -3,25 +3,23 @@ require 'spec_helper'
|
|
3
3
|
require 'cloudinary'
|
4
4
|
|
5
5
|
describe 'auth_token' do
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
Cloudinary.config_from_url "cloudinary://a:b@test123"
|
6
|
+
|
7
|
+
before :each do
|
8
|
+
Cloudinary.reset_config
|
9
|
+
Cloudinary.config_from_url 'cloudinary://a:b@test123'
|
11
10
|
Cloudinary.config.auth_token = { :key => KEY, :duration => 300, :start_time => 11111111 }
|
12
11
|
end
|
13
|
-
after do
|
14
|
-
ENV["CLOUDINARY_URL"] = @url_backup
|
15
|
-
Cloudinary::config_from_url @url_backup
|
16
|
-
end
|
17
12
|
it "should generate with start and duration" do
|
18
13
|
token = Cloudinary::Utils.generate_auth_token :start_time => 1111111111, :acl => "/image/*", :duration => 300
|
19
14
|
expect(token).to eq '__cld_token__=st=1111111111~exp=1111111411~acl=%2fimage%2f*~hmac=1751370bcc6cfe9e03f30dd1a9722ba0f2cdca283fa3e6df3342a00a7528cc51'
|
20
15
|
end
|
21
16
|
|
22
17
|
describe "authenticated url" do
|
23
|
-
before do
|
18
|
+
before :each do
|
19
|
+
Cloudinary.class_variable_set :@@config, nil
|
20
|
+
Cloudinary.config_from_url 'cloudinary://a:b@test123'
|
24
21
|
Cloudinary.config :private_cdn => true
|
22
|
+
Cloudinary.config.auth_token = { :key => KEY, :duration => 300, :start_time => 11111111 }
|
25
23
|
|
26
24
|
end
|
27
25
|
it "should add token if authToken is globally set and signed = true" do
|
data/spec/cache_spec.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'cloudinary'
|
3
|
+
require 'cloudinary/cache'
|
4
|
+
require 'rspec'
|
5
|
+
require 'active_support/cache'
|
6
|
+
|
7
|
+
describe 'Responsive cache' do
|
8
|
+
|
9
|
+
before :all do
|
10
|
+
Cloudinary.reset_config
|
11
|
+
unless defined? Rails and defined? Rails.cache
|
12
|
+
module Rails
|
13
|
+
class << self
|
14
|
+
attr_accessor :cache
|
15
|
+
end
|
16
|
+
Rails.cache = ActiveSupport::Cache::FileStore.new("#{Dir.getwd}/../tmp/cache")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
Cloudinary::config.use_cache = true
|
21
|
+
Cloudinary::Cache.storage= Rails.cache
|
22
|
+
@i=0
|
23
|
+
end
|
24
|
+
|
25
|
+
after :all do
|
26
|
+
# Rails.cache.clear
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_cache
|
30
|
+
Rails.cache.fetch CACHE_KEY do
|
31
|
+
@i = @i + 1
|
32
|
+
end
|
33
|
+
end
|
34
|
+
it 'should cache breakpoints' do
|
35
|
+
j = get_cache
|
36
|
+
j = get_cache
|
37
|
+
|
38
|
+
expect(j).to eql(1)
|
39
|
+
expect(@i).to eql(1)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'should cache upload results' do
|
43
|
+
result = Cloudinary::Uploader.upload(
|
44
|
+
TEST_IMG,
|
45
|
+
:tags => [TEST_TAG, TIMESTAMP_TAG],
|
46
|
+
responsive_breakpoints: [
|
47
|
+
{
|
48
|
+
create_derived: false,
|
49
|
+
transformation: {
|
50
|
+
angle: 90
|
51
|
+
},
|
52
|
+
format: 'gif'
|
53
|
+
},
|
54
|
+
{
|
55
|
+
create_derived: false,
|
56
|
+
transformation: {angle: 45, crop: 'scale'},
|
57
|
+
format: 'png'
|
58
|
+
},
|
59
|
+
{
|
60
|
+
create_derived: false,
|
61
|
+
}
|
62
|
+
]
|
63
|
+
)
|
64
|
+
expect(result["responsive_breakpoints"]).to_not be_nil
|
65
|
+
expect(result["responsive_breakpoints"].length).to_not eql(0)
|
66
|
+
result["responsive_breakpoints"].each do |bp|
|
67
|
+
bp = Cloudinary::Cache.get(
|
68
|
+
result["public_id"],
|
69
|
+
{
|
70
|
+
type: bp["type"],
|
71
|
+
resource_type: bp["resource_type"],
|
72
|
+
raw_transformation: bp["transformation"]})
|
73
|
+
expect(bp).to_not be_nil
|
74
|
+
end
|
75
|
+
end
|
76
|
+
describe Cloudinary::Uploader do
|
77
|
+
let (:options) { {
|
78
|
+
:tags => [TEST_TAG, TIMESTAMP_TAG],
|
79
|
+
:use_cache => true,
|
80
|
+
:responsive_breakpoints => [
|
81
|
+
{
|
82
|
+
:create_derived => false,
|
83
|
+
:transformation => {
|
84
|
+
:angle => 90
|
85
|
+
},
|
86
|
+
:format => 'gif'
|
87
|
+
},
|
88
|
+
{
|
89
|
+
:create_derived => false,
|
90
|
+
:transformation => ResponsiveTest::TRANSFORMATION,
|
91
|
+
:format => ResponsiveTest::FORMAT
|
92
|
+
},
|
93
|
+
{
|
94
|
+
:create_derived => false
|
95
|
+
}
|
96
|
+
]
|
97
|
+
}}
|
98
|
+
before :all do
|
99
|
+
end
|
100
|
+
|
101
|
+
it "Should save responsive breakpoints to cache after upload" do
|
102
|
+
result = Cloudinary::Uploader.upload( TEST_IMG, options)
|
103
|
+
cache_value = Cloudinary::Cache.get(result["public_id"], transformation: ResponsiveTest::TRANSFORMATION, format: ResponsiveTest::FORMAT)
|
104
|
+
|
105
|
+
expect(cache_value).to eql(ResponsiveTest::IMAGE_BP_VALUES)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|