rhosync_api 0.0.6 → 0.1.3
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.
- data/History.txt +6 -2
- data/LICENSE +3 -3
- data/README +125 -29
- data/lib/rho-api-connect.rb +535 -0
- data/lib/rho-sources.rb +97 -0
- data/lib/rhosync_api.rb +61 -343
- metadata +71 -40
@@ -0,0 +1,535 @@
|
|
1
|
+
#Based on the rhomobile documentation
|
2
|
+
#http://docs.rhomobile.com/rhosync/rest-api
|
3
|
+
|
4
|
+
require 'rest_client'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module RhoApiConnect
|
8
|
+
|
9
|
+
#login -- rhosync
|
10
|
+
def self.login(server,admin,password)
|
11
|
+
@server = server
|
12
|
+
@username = admin
|
13
|
+
@password = password
|
14
|
+
@token = get_api_token
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.set_token(token)
|
18
|
+
@token = token
|
19
|
+
end
|
20
|
+
|
21
|
+
#logout -- rhosync
|
22
|
+
def self.logout
|
23
|
+
@server = nil
|
24
|
+
@username = nil
|
25
|
+
@password = nil
|
26
|
+
@token = nil
|
27
|
+
@sources = []
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
#Before you can use RhoSync API you should get API token:
|
32
|
+
def self.get_api_token
|
33
|
+
uri = URI.parse(@server)
|
34
|
+
http = Net::HTTP.new(uri.host,uri.port)
|
35
|
+
begin
|
36
|
+
res,data = http.post( '/login',
|
37
|
+
{:login => @username, :password => @password}.to_json,
|
38
|
+
{'Content-Type' => 'application/json'} )
|
39
|
+
cookie = res.response['set-cookie'].split('; ')[0].split('=')
|
40
|
+
cookie = {cookie[0] => URI.escape(cookie[1])}
|
41
|
+
token = RestClient.post("#{@server}/api/get_api_token",'',{:cookies => cookie})
|
42
|
+
rescue=>e
|
43
|
+
cant_connect_rhosync(e)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
#Returns license information of the currently used license
|
48
|
+
def self.get_license_info
|
49
|
+
unless @token.nil?
|
50
|
+
begin
|
51
|
+
license_info = RestClient.post(
|
52
|
+
"#{@server}/api/get_license_info",
|
53
|
+
{:api_token => @token}.to_json, :content_type => :json
|
54
|
+
).body
|
55
|
+
JSON.parse(license_info)
|
56
|
+
rescue=>e
|
57
|
+
cant_connect_rhosync(e)
|
58
|
+
end
|
59
|
+
else
|
60
|
+
access_denied
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
#Reset the server: flush db and re-bootstrap server
|
65
|
+
def self.reset
|
66
|
+
unless @token.nil?
|
67
|
+
begin
|
68
|
+
RestClient.post("#{@server}/api/reset",
|
69
|
+
{ :api_token => @token }.to_json,
|
70
|
+
:content_type => :json
|
71
|
+
)
|
72
|
+
rescue=>e
|
73
|
+
cant_connect_rhosync(e)
|
74
|
+
end
|
75
|
+
else
|
76
|
+
access_denied
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# :message - message which will be used to display notification popup dialog on the device
|
81
|
+
# :badge - iphone specific badge
|
82
|
+
# :sound - name of the sound file to play upon receiving PUSH notification
|
83
|
+
# :vibrate - number of seconds to vibrate upon receiving PUSH notification
|
84
|
+
# :sources - list of data source names to be synced upon receiving PUSH notification
|
85
|
+
def self.ping(user_id,ping_params)
|
86
|
+
unless @token.nil?
|
87
|
+
unless user_id.nil?
|
88
|
+
begin
|
89
|
+
RestClient.post(
|
90
|
+
"#{@server}/api/ping",ping_params.to_json,
|
91
|
+
:content_type => :json
|
92
|
+
)
|
93
|
+
rescue=>e
|
94
|
+
cant_connect_rhosync(e)
|
95
|
+
end
|
96
|
+
else
|
97
|
+
puts "the user's ID can't be null "
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
else
|
101
|
+
access_denied
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
#List users registered with this RhoSync application.
|
106
|
+
def self.list_users
|
107
|
+
unless @token.nil?
|
108
|
+
begin
|
109
|
+
users = RestClient.post(
|
110
|
+
"#{@server}/api/list_users",
|
111
|
+
{ :api_token => @token }.to_json,
|
112
|
+
:content_type => :json
|
113
|
+
).body
|
114
|
+
JSON.parse(users)
|
115
|
+
rescue=>e
|
116
|
+
cant_connect_rhosync(e)
|
117
|
+
end
|
118
|
+
else
|
119
|
+
access_denied
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
#Create a user in this RhoSync application.
|
124
|
+
def self.create_user(login,password = "")
|
125
|
+
unless @token.nil?
|
126
|
+
unless login.nil?
|
127
|
+
begin
|
128
|
+
RestClient.post("#{@server}/api/create_user",
|
129
|
+
{
|
130
|
+
:api_token => @token,
|
131
|
+
:attributes => {
|
132
|
+
:login => login,
|
133
|
+
:password => password
|
134
|
+
}
|
135
|
+
}.to_json,
|
136
|
+
:content_type => :json
|
137
|
+
)
|
138
|
+
rescue=>e
|
139
|
+
cant_connect_rhosync(e)
|
140
|
+
end
|
141
|
+
else
|
142
|
+
puts "the user's ID can't be null "
|
143
|
+
nil
|
144
|
+
end
|
145
|
+
else
|
146
|
+
access_denied
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
#Update a user in the RhoSync application.
|
151
|
+
def self.update_user(login,password)
|
152
|
+
unless @token.nil?
|
153
|
+
unless login.nil?
|
154
|
+
begin
|
155
|
+
RestClient.post("#{@server}/api/update_user",
|
156
|
+
{
|
157
|
+
:api_token => @token,
|
158
|
+
:attributes => {
|
159
|
+
:login => login,
|
160
|
+
:new_password => password
|
161
|
+
}
|
162
|
+
}.to_json,
|
163
|
+
:content_type => :json
|
164
|
+
)
|
165
|
+
rescue=>e
|
166
|
+
cant_connect_rhosync(e)
|
167
|
+
end
|
168
|
+
else
|
169
|
+
puts "the user's ID can't be null "
|
170
|
+
nil
|
171
|
+
end
|
172
|
+
else
|
173
|
+
access_denied
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
#Delete User and all associated devices from the RhoSync application.
|
178
|
+
def self.delete_user(user_id)
|
179
|
+
unless @token.nil?
|
180
|
+
unless user_id.nil?
|
181
|
+
begin
|
182
|
+
RestClient.post(
|
183
|
+
"#{@server}/api/delete_user",
|
184
|
+
{
|
185
|
+
:api_token => @token,
|
186
|
+
:user_id => user_id
|
187
|
+
}.to_json,
|
188
|
+
:content_type => :json
|
189
|
+
)
|
190
|
+
rescue=>e
|
191
|
+
cant_connect_rhosync(e)
|
192
|
+
end
|
193
|
+
else
|
194
|
+
puts "the user's ID can't be null "
|
195
|
+
nil
|
196
|
+
end
|
197
|
+
else
|
198
|
+
access_denied
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
#List clients (devices) associated with given user.
|
203
|
+
def self.list_clients(user_id)
|
204
|
+
unless @token.nil?
|
205
|
+
begin
|
206
|
+
clients = RestClient.post("#{@server}/api/list_clients",
|
207
|
+
{
|
208
|
+
:api_token => @token,
|
209
|
+
:user_id => user_id
|
210
|
+
}.to_json,
|
211
|
+
:content_type => :json
|
212
|
+
).body
|
213
|
+
JSON.parse(clients)
|
214
|
+
rescue=>e
|
215
|
+
cant_connect_rhosync(e)
|
216
|
+
end
|
217
|
+
else
|
218
|
+
access_denied
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
#Creates a client (device) for a given user.
|
223
|
+
def self.create_client(user_id)
|
224
|
+
unless @token.nil?
|
225
|
+
unless user_id.nil?
|
226
|
+
begin
|
227
|
+
RestClient.post(
|
228
|
+
"#{@server}/api/create_client",
|
229
|
+
{
|
230
|
+
:api_token => @token,
|
231
|
+
:user_id => user_id
|
232
|
+
}.to_json,
|
233
|
+
:content_type => :json
|
234
|
+
).body
|
235
|
+
rescue=>e
|
236
|
+
cant_connect_rhosync(e)
|
237
|
+
end
|
238
|
+
else
|
239
|
+
puts "the user's ID can't be null "
|
240
|
+
nil
|
241
|
+
end
|
242
|
+
else
|
243
|
+
access_denied
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
#Deletes the specified client (device).
|
248
|
+
def self.delete_client(user_id,client_id)
|
249
|
+
unless @token.nil?
|
250
|
+
unless user_id.nil? and client_id.nil?
|
251
|
+
begin
|
252
|
+
RestClient.post(
|
253
|
+
"#{@server}/api/delete_client",
|
254
|
+
{
|
255
|
+
:api_token => @token,
|
256
|
+
:user_id => user_id,
|
257
|
+
:client_id => client_id
|
258
|
+
}.to_json,
|
259
|
+
:content_type => :json
|
260
|
+
)
|
261
|
+
rescue=>e
|
262
|
+
cant_connect_rhosync(e)
|
263
|
+
end
|
264
|
+
else
|
265
|
+
puts "the user's ID and client's ID can't be null "
|
266
|
+
nil
|
267
|
+
end
|
268
|
+
else
|
269
|
+
access_denied
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
#Returns client (device) attributes, such as device_type, device_pin, device_port. These attributes used by RhoSync push.
|
274
|
+
def self.get_client_params(client_id)
|
275
|
+
unless @token.nil?
|
276
|
+
begin
|
277
|
+
client_params = RestClient.post(
|
278
|
+
"#{@server}/api/get_client_params",
|
279
|
+
{
|
280
|
+
:api_token => @token,
|
281
|
+
:client_id => client_id
|
282
|
+
}.to_json,
|
283
|
+
:content_type => :json
|
284
|
+
).body
|
285
|
+
JSON.parse(client_params)
|
286
|
+
rescue=>e
|
287
|
+
cant_connect_rhosync(e)
|
288
|
+
end
|
289
|
+
else
|
290
|
+
access_denied
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
#Return list of source adapters for this RhoSync application.
|
295
|
+
def self.list_sources(partition = nil)
|
296
|
+
unless @token.nil?
|
297
|
+
partition = "user" if partition.nil?
|
298
|
+
begin
|
299
|
+
sources = RestClient.post("#{@server}/api/list_sources",
|
300
|
+
{
|
301
|
+
:api_token => @token,
|
302
|
+
:partition_type => partition
|
303
|
+
}.to_json,
|
304
|
+
:content_type => :json
|
305
|
+
).body
|
306
|
+
JSON.parse(sources)
|
307
|
+
rescue=>e
|
308
|
+
cant_connect_rhosync(e)
|
309
|
+
end
|
310
|
+
else
|
311
|
+
access_denied
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
|
316
|
+
#Return attributes associated with a given source:
|
317
|
+
|
318
|
+
# name � name of the data source
|
319
|
+
# poll_interval � query poll interval; defines how often RhoSync will call source adapter to query for new data, set to -1 to disable polling, 0 to always poll
|
320
|
+
# partition_type � to share data across all users, set partition to :app; otherwise use :user partition (default)
|
321
|
+
# sync_type � set to :bulk_only to disable :incremental sync; regular sync is :incremental (default)
|
322
|
+
# queue � name of the queue for both query and create/update/delete (CUD) jobs (used if no specific queues not specified)
|
323
|
+
# query_queue � name of query queue
|
324
|
+
# cud_queue � name of CUD queue
|
325
|
+
def self.get_source_params(source_id)
|
326
|
+
unless @token.nil?
|
327
|
+
begin
|
328
|
+
attributes = RestClient.post("#{@server}/api/get_source_params",
|
329
|
+
{
|
330
|
+
:api_token => @token,
|
331
|
+
:source_id => source_id
|
332
|
+
}.to_json,
|
333
|
+
:content_type => :json
|
334
|
+
).body
|
335
|
+
JSON.parse(attributes)
|
336
|
+
rescue=>e
|
337
|
+
cant_connect_rhosync(e)
|
338
|
+
end
|
339
|
+
else
|
340
|
+
access_denied
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
# Return list of document keys associated with given source and user.
|
345
|
+
# If :user_id set to �*�, this call will return list of keys for �shared� documents.
|
346
|
+
# MD(:md) � master document; represents state of the backend (set of all objects for the given app/user/source on the backend service).
|
347
|
+
def self.list_source_docs(user_id,source_id)
|
348
|
+
unless @token.nil?
|
349
|
+
begin
|
350
|
+
docs = RestClient.post(
|
351
|
+
"#{@server}/api/list_source_docs",
|
352
|
+
{
|
353
|
+
:api_token => @token,
|
354
|
+
:source_id => source_id,
|
355
|
+
:user_id => user_id
|
356
|
+
}.to_json,
|
357
|
+
:content_type => :json
|
358
|
+
).body
|
359
|
+
JSON.parse(docs)
|
360
|
+
rescue=>e
|
361
|
+
cant_connect_rhosync(e)
|
362
|
+
end
|
363
|
+
else
|
364
|
+
access_denied
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
#Return content of a given document (client or source).
|
369
|
+
def self.get_db_doc(doc,data_type = nil)
|
370
|
+
unless @token.nil?
|
371
|
+
begin
|
372
|
+
res = RestClient.post(
|
373
|
+
"#{@server}/api/get_db_doc",
|
374
|
+
{
|
375
|
+
:api_token => @token,
|
376
|
+
:doc => doc,
|
377
|
+
:data_type => data_type
|
378
|
+
}.to_json,
|
379
|
+
:content_type => :json
|
380
|
+
).body
|
381
|
+
JSON.parse(res)
|
382
|
+
rescue=>e
|
383
|
+
cant_connect_rhosync(e)
|
384
|
+
end
|
385
|
+
else
|
386
|
+
access_denied
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
#Sets the content of the specified server document.
|
391
|
+
#Data should be either a string or hash of hashes. Data type should be set accordingly.
|
392
|
+
def self.set_db_doc(doc,data)
|
393
|
+
unless @token.nil?
|
394
|
+
if data.class == "String"
|
395
|
+
data_type = "string"
|
396
|
+
else
|
397
|
+
data_type = nil
|
398
|
+
end
|
399
|
+
begin
|
400
|
+
RestClient.post(
|
401
|
+
"#{@server}/api/set_db_doc",
|
402
|
+
{
|
403
|
+
:api_token => @token,
|
404
|
+
:doc => doc,
|
405
|
+
:data => data,
|
406
|
+
:data_type => data_type
|
407
|
+
}.to_json,
|
408
|
+
:content_type => :json
|
409
|
+
)
|
410
|
+
rescue=>e
|
411
|
+
cant_connect_rhosync(e)
|
412
|
+
end
|
413
|
+
else
|
414
|
+
access_denied
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
#Returns list of document keys associated with particular client.
|
419
|
+
#These documents are used by the server to sync data with the client.
|
420
|
+
#CD (:cd) � client document; represents the state of the client (set of all objects on the given client).
|
421
|
+
def self.list_client_docs(client_id,source_id)
|
422
|
+
unless @token.nil?
|
423
|
+
begin
|
424
|
+
docs = RestClient.post(
|
425
|
+
"#{@server}/api/list_client_docs",
|
426
|
+
{
|
427
|
+
:api_token => @token,
|
428
|
+
:source_id => source_id,
|
429
|
+
:client_id => client_id
|
430
|
+
}.to_json,
|
431
|
+
:content_type => :json
|
432
|
+
).body
|
433
|
+
JSON.parse(docs)
|
434
|
+
rescue=>e
|
435
|
+
cant_connect_rhosync(e)
|
436
|
+
end
|
437
|
+
else
|
438
|
+
access_denied
|
439
|
+
end
|
440
|
+
end
|
441
|
+
|
442
|
+
#NEW METHODS IN THE GEM
|
443
|
+
#Return content of a given document
|
444
|
+
def self.get_db_doc_by_type(user_id,source_id,type_doc)
|
445
|
+
unless @token.nil?
|
446
|
+
begin
|
447
|
+
docs = list_source_docs(user_id,source_id)
|
448
|
+
res = RestClient.post(
|
449
|
+
"#{@server}/api/get_db_doc",
|
450
|
+
{
|
451
|
+
:api_token => @token,
|
452
|
+
:doc => docs["#{type_doc}"],
|
453
|
+
:data_type => nil
|
454
|
+
}.to_json,
|
455
|
+
:content_type => :json
|
456
|
+
).body
|
457
|
+
JSON.parse(res)
|
458
|
+
rescue=>e
|
459
|
+
cant_connect_rhosync(e)
|
460
|
+
end
|
461
|
+
else
|
462
|
+
access_denied
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
#set content of a given document
|
467
|
+
def self.set_db_doc_by_type(user_id,source_id,data,type_doc)
|
468
|
+
unless @token.nil?
|
469
|
+
begin
|
470
|
+
docs = list_source_docs(user_id,source_id)
|
471
|
+
set_db_doc(docs["#{type_doc}"],data)
|
472
|
+
rescue=>e
|
473
|
+
cant_connect_rhosync(e)
|
474
|
+
end
|
475
|
+
else
|
476
|
+
access_denied
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
#Sets the content of the specified server document.
|
481
|
+
#Data should be either a string or hash of hashes. Data type should be set accordingly.
|
482
|
+
def self.push_objects(user_id,source_id,data)
|
483
|
+
unless @token.nil?
|
484
|
+
begin
|
485
|
+
RestClient.post(
|
486
|
+
"#{@server}/api/push_objects",
|
487
|
+
{
|
488
|
+
:api_token => @token,
|
489
|
+
:user_id => user_id,
|
490
|
+
:source_id => source_id,
|
491
|
+
:objects => data
|
492
|
+
}.to_json,
|
493
|
+
:content_type => :json
|
494
|
+
)
|
495
|
+
rescue=>e
|
496
|
+
cant_connect_rhosync(e)
|
497
|
+
end
|
498
|
+
else
|
499
|
+
access_denied
|
500
|
+
end
|
501
|
+
end
|
502
|
+
|
503
|
+
def self.push_deletes(user_id,source_id,data)
|
504
|
+
unless @token.nil?
|
505
|
+
begin
|
506
|
+
RestClient.post(
|
507
|
+
"#{@server}/api/push_deletes",
|
508
|
+
{
|
509
|
+
:api_token => @token,
|
510
|
+
:user_id => user_id,
|
511
|
+
:source_id => source_id,
|
512
|
+
:objects => data
|
513
|
+
}.to_json,
|
514
|
+
:content_type => :json
|
515
|
+
)
|
516
|
+
rescue=>e
|
517
|
+
cant_connect_rhosync(e)
|
518
|
+
end
|
519
|
+
else
|
520
|
+
access_denied
|
521
|
+
end
|
522
|
+
end
|
523
|
+
|
524
|
+
def self.access_denied
|
525
|
+
puts "you don't have access, please login in"
|
526
|
+
nil
|
527
|
+
end
|
528
|
+
|
529
|
+
def self.cant_connect_rhosync(e)
|
530
|
+
puts "rhosync error : #{e.inspect}"
|
531
|
+
nil
|
532
|
+
end
|
533
|
+
|
534
|
+
|
535
|
+
end
|
data/lib/rho-sources.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
#Based on the rhomobile documentation
|
2
|
+
#http://docs.rhomobile.com/rhosync/rest-api
|
3
|
+
|
4
|
+
|
5
|
+
class RhoSources
|
6
|
+
|
7
|
+
def initialize(source_name,token)
|
8
|
+
@name = source_name
|
9
|
+
@token = token
|
10
|
+
RhoApiConnect.set_token(token)
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
@name
|
15
|
+
end
|
16
|
+
|
17
|
+
def find(user, *args)
|
18
|
+
|
19
|
+
new_result = {}
|
20
|
+
|
21
|
+
unless user.nil? or user.empty?
|
22
|
+
result = RhoApiConnect.get_db_doc_by_type(user,@name,"md")
|
23
|
+
unless args.empty?
|
24
|
+
if args[0][:conditions].nil?
|
25
|
+
undefined_arg
|
26
|
+
return nil
|
27
|
+
end
|
28
|
+
result.each do |object,content|
|
29
|
+
content[:object] = object
|
30
|
+
if evalue_condition(content,args[0][:conditions])
|
31
|
+
new_result[object] = content
|
32
|
+
end
|
33
|
+
end
|
34
|
+
result = nil
|
35
|
+
new_result
|
36
|
+
else
|
37
|
+
result
|
38
|
+
end
|
39
|
+
else
|
40
|
+
user_not_nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def create(user, data = nil)
|
45
|
+
unless user.nil? or user.empty?
|
46
|
+
result = RhoApiConnect.push_objects(user,@name,data)
|
47
|
+
else
|
48
|
+
user_not_nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete(user, data = nil)
|
53
|
+
unless user.nil? or user.empty?
|
54
|
+
result = RhoApiConnect.push_deletes(user,@name,data)
|
55
|
+
else
|
56
|
+
user_not_nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def delete_all(user)
|
61
|
+
truncate(user)
|
62
|
+
end
|
63
|
+
|
64
|
+
def truncate(user)
|
65
|
+
unless user.nil? or user.empty?
|
66
|
+
result = RhoApiConnect.set_db_doc_by_type(user,@name,"","md")
|
67
|
+
else
|
68
|
+
user_not_nil
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_key(key)
|
73
|
+
return key if key == :object
|
74
|
+
return key.to_s
|
75
|
+
end
|
76
|
+
|
77
|
+
def evalue_condition(content,conditions)
|
78
|
+
return true if conditions.nil?
|
79
|
+
sw = true
|
80
|
+
conditions.each do |key,value|
|
81
|
+
if content[get_key(key)] != value
|
82
|
+
sw = false
|
83
|
+
end
|
84
|
+
end if conditions.class == Hash
|
85
|
+
sw
|
86
|
+
end
|
87
|
+
|
88
|
+
def user_not_nil
|
89
|
+
puts "user name can not be nil or empty"
|
90
|
+
nil
|
91
|
+
end
|
92
|
+
|
93
|
+
def undefined_arg
|
94
|
+
puts "undefined argument !!"
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
end
|