google_drive 0.3.11 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +7 -7
  2. data/README.rdoc +27 -10
  3. data/lib/google_drive/acl.rb +40 -58
  4. data/lib/google_drive/acl_entry.rb +76 -56
  5. data/lib/google_drive/api_client_fetcher.rb +49 -0
  6. data/lib/google_drive/collection.rb +69 -71
  7. data/lib/google_drive/file.rb +171 -128
  8. data/lib/google_drive/session.rb +234 -268
  9. data/lib/google_drive/spreadsheet.rb +19 -163
  10. data/lib/google_drive/util.rb +126 -17
  11. data/lib/google_drive/worksheet.rb +108 -80
  12. data/lib/google_drive.rb +63 -57
  13. data/lib/google_drive_v1/acl.rb +115 -0
  14. data/lib/google_drive_v1/acl_entry.rb +100 -0
  15. data/lib/google_drive_v1/api_client_fetcher.rb +47 -0
  16. data/lib/google_drive_v1/authentication_error.rb +14 -0
  17. data/lib/{google_drive → google_drive_v1}/basic_fetcher.rb +1 -1
  18. data/lib/{google_drive → google_drive_v1}/client_login_fetcher.rb +2 -2
  19. data/lib/google_drive_v1/collection.rb +167 -0
  20. data/lib/google_drive_v1/error.rb +12 -0
  21. data/lib/google_drive_v1/file.rb +258 -0
  22. data/lib/google_drive_v1/list.rb +119 -0
  23. data/lib/google_drive_v1/list_row.rb +88 -0
  24. data/lib/{google_drive → google_drive_v1}/oauth1_fetcher.rb +1 -1
  25. data/lib/{google_drive → google_drive_v1}/oauth2_fetcher.rb +2 -2
  26. data/lib/google_drive_v1/record.rb +31 -0
  27. data/lib/google_drive_v1/session.rb +522 -0
  28. data/lib/google_drive_v1/spreadsheet.rb +248 -0
  29. data/lib/google_drive_v1/table.rb +60 -0
  30. data/lib/google_drive_v1/util.rb +73 -0
  31. data/lib/google_drive_v1/worksheet.rb +498 -0
  32. data/lib/google_drive_v1.rb +148 -0
  33. metadata +112 -77
  34. data/doc_src/google_drive/acl_entry.rb +0 -33
@@ -0,0 +1,522 @@
1
+ # Author: Hiroshi Ichikawa <http://gimite.net/>
2
+ # The license of this source is "New BSD Licence"
3
+
4
+ require "cgi"
5
+ require "stringio"
6
+
7
+ require "rubygems"
8
+ require "nokogiri"
9
+ require "oauth"
10
+ require "oauth2"
11
+
12
+ require "google_drive_v1/util"
13
+ require "google_drive_v1/client_login_fetcher"
14
+ require "google_drive_v1/oauth1_fetcher"
15
+ require "google_drive_v1/oauth2_fetcher"
16
+ require "google_drive_v1/error"
17
+ require "google_drive_v1/authentication_error"
18
+ require "google_drive_v1/spreadsheet"
19
+ require "google_drive_v1/worksheet"
20
+ require "google_drive_v1/collection"
21
+ require "google_drive_v1/file"
22
+
23
+
24
+ module GoogleDriveV1
25
+
26
+ # Use GoogleDriveV1.login or GoogleDriveV1.saved_session to get
27
+ # GoogleDriveV1::Session object.
28
+ class Session
29
+
30
+ include(Util)
31
+ extend(Util)
32
+
33
+ UPLOAD_CHUNK_SIZE = 512 * 1024
34
+
35
+ # DEPRECATED: Will be removed in the next version.
36
+ #
37
+ # The same as GoogleDriveV1.login.
38
+ def self.login(mail, password, proxy = nil)
39
+ warn(
40
+ "WARNING: GoogleDriveV1.login is deprecated and will be removed in the next version. " +
41
+ "Use GoogleDriveV1.login_with_oauth instead.")
42
+ session = Session.new(nil, ClientLoginFetcher.new({}, proxy))
43
+ session.login(mail, password)
44
+ return session
45
+ end
46
+
47
+ # The same as GoogleDriveV1.login_with_oauth.
48
+ def self.login_with_oauth(access_token, proxy = nil)
49
+ if proxy
50
+ warn(
51
+ "WARNING: Specifying a proxy object is deprecated and will not work in the next version. " +
52
+ "Set ENV[\"http_proxy\"] instead.")
53
+ end
54
+ case access_token
55
+ when OAuth::AccessToken
56
+ warn(
57
+ "WARNING: Authorization with OAuth1 is deprecated and will not work in the next version. " +
58
+ "Use OAuth2 instead.")
59
+ raise(GoogleDriveV1::Error, "proxy is not supported with OAuth1.") if proxy
60
+ fetcher = OAuth1Fetcher.new(access_token)
61
+ when OAuth2::AccessToken
62
+ fetcher = OAuth2Fetcher.new(access_token.token, proxy)
63
+ when String
64
+ fetcher = OAuth2Fetcher.new(access_token, proxy)
65
+ else
66
+ raise(GoogleDriveV1::Error,
67
+ "access_token is neither String, OAuth2::Token nor OAuth::Token: %p" % access_token)
68
+ end
69
+ return Session.new(nil, fetcher)
70
+ end
71
+
72
+ # The same as GoogleDriveV1.restore_session.
73
+ def self.restore_session(auth_tokens, proxy = nil)
74
+ warn(
75
+ "WARNING: GoogleDriveV1.restore_session is deprecated and will be removed in the next version. " +
76
+ "Use GoogleDriveV1.login_with_oauth instead.")
77
+ return Session.new(auth_tokens, nil, proxy)
78
+ end
79
+
80
+ # Creates a dummy GoogleDriveV1::Session object for testing.
81
+ def self.new_dummy()
82
+ return Session.new(nil, Object.new())
83
+ end
84
+
85
+ # DEPRECATED: Use GoogleDriveV1.restore_session instead.
86
+ def initialize(auth_tokens = nil, fetcher = nil, proxy = nil)
87
+ if fetcher
88
+ @fetcher = fetcher
89
+ else
90
+ @fetcher = ClientLoginFetcher.new(auth_tokens || {}, proxy)
91
+ end
92
+ end
93
+
94
+ # Authenticates with given +mail+ and +password+, and updates current session object
95
+ # if succeeds. Raises GoogleDriveV1::AuthenticationError if fails.
96
+ # Google Apps account is supported.
97
+ def login(mail, password)
98
+ if !@fetcher.is_a?(ClientLoginFetcher)
99
+ raise(GoogleDriveV1::Error,
100
+ "Cannot call login for session created by login_with_oauth.")
101
+ end
102
+ begin
103
+ @fetcher.auth_tokens = {
104
+ :wise => authenticate(mail, password, :wise),
105
+ :writely => authenticate(mail, password, :writely),
106
+ }
107
+ rescue GoogleDriveV1::Error => ex
108
+ return true if @on_auth_fail && @on_auth_fail.call()
109
+ raise(AuthenticationError, "Authentication failed for #{mail}: #{ex.message}")
110
+ end
111
+ end
112
+
113
+ # Authentication tokens.
114
+ def auth_tokens
115
+ warn(
116
+ "WARNING: GoogleDriveV1::Session\#auth_tokens is deprecated and will be removed in the next version.")
117
+ if !@fetcher.is_a?(ClientLoginFetcher)
118
+ raise(GoogleDriveV1::Error,
119
+ "Cannot call auth_tokens for session created by " +
120
+ "login_with_oauth.")
121
+ end
122
+ return @fetcher.auth_tokens
123
+ end
124
+
125
+ # Authentication token.
126
+ def auth_token(auth = :wise)
127
+ warn(
128
+ "WARNING: GoogleDriveV1::Session\#auth_token is deprecated and will be removed in the next version.")
129
+ return self.auth_tokens[auth]
130
+ end
131
+
132
+ # Proc or Method called when authentication has failed.
133
+ # When this function returns +true+, it tries again.
134
+ def on_auth_fail
135
+ warn(
136
+ "WARNING: GoogleDriveV1::Session\#on_auth_fail is deprecated and will be removed in the next version.")
137
+ return @on_auth_fail
138
+ end
139
+
140
+ def on_auth_fail=(func)
141
+ warn(
142
+ "WARNING: GoogleDriveV1::Session\#on_auth_fail is deprecated and will be removed in the next version.")
143
+ @on_auth_fail = func
144
+ end
145
+
146
+ # Returns list of files for the user as array of GoogleDriveV1::File or its subclass.
147
+ # You can specify query parameters described at
148
+ # https://developers.google.com/google-apps/documents-list/#getting_a_list_of_documents_and_files
149
+ #
150
+ # files doesn't return collections unless "showfolders" => true is specified.
151
+ #
152
+ # e.g.
153
+ # session.files
154
+ # session.files("title" => "hoge", "title-exact" => "true")
155
+ def files(params = {})
156
+ url = concat_url(
157
+ "#{DOCS_BASE_URL}?v=3", "?" + encode_query(params))
158
+ doc = request(:get, url, :auth => :writely)
159
+ return doc.css("feed > entry").map(){ |e| entry_element_to_file(e) }
160
+ end
161
+
162
+ # Returns GoogleDriveV1::File or its subclass whose title exactly matches +title+.
163
+ # Returns nil if not found. If multiple files with the +title+ are found, returns
164
+ # one of them.
165
+ #
166
+ # If given an Array, traverses collections by title. e.g.
167
+ # session.file_by_title(["myfolder", "mysubfolder/even/w/slash", "myfile"])
168
+ def file_by_title(title)
169
+ if title.is_a?(Array)
170
+ return self.root_collection.file_by_title(title)
171
+ else
172
+ return files("title" => title, "title-exact" => "true")[0]
173
+ end
174
+ end
175
+
176
+ # Returns list of spreadsheets for the user as array of GoogleDriveV1::Spreadsheet.
177
+ # You can specify query parameters e.g. "title", "title-exact".
178
+ #
179
+ # e.g.
180
+ # session.spreadsheets
181
+ # session.spreadsheets("title" => "hoge")
182
+ # session.spreadsheets("title" => "hoge", "title-exact" => "true")
183
+ def spreadsheets(params = {})
184
+ url = concat_url(
185
+ "#{DOCS_BASE_URL}/-/spreadsheet?v=3", "?" + encode_query(params))
186
+ doc = request(:get, url, :auth => :writely)
187
+ # The API may return non-spreadsheets too when title-exact is specified.
188
+ # Probably a bug. For workaround, only returns Spreadsheet instances.
189
+ return doc.css("feed > entry").
190
+ map(){ |e| entry_element_to_file(e) }.
191
+ select(){ |f| f.is_a?(Spreadsheet) }
192
+ end
193
+
194
+ # Returns GoogleDriveV1::Spreadsheet with given +key+.
195
+ #
196
+ # e.g.
197
+ # # http://spreadsheets.google.com/ccc?key=pz7XtlQC-PYx-jrVMJErTcg&hl=ja
198
+ # session.spreadsheet_by_key("pz7XtlQC-PYx-jrVMJErTcg")
199
+ def spreadsheet_by_key(key)
200
+ url = "https://spreadsheets.google.com/feeds/worksheets/#{key}/private/full"
201
+ return Spreadsheet.new(self, url)
202
+ end
203
+
204
+ # Returns GoogleDriveV1::Spreadsheet with given +url+. You must specify either of:
205
+ # - URL of the page you open to access the spreadsheet in your browser
206
+ # - URL of worksheet-based feed of the spreadseet
207
+ #
208
+ # e.g.
209
+ # session.spreadsheet_by_url(
210
+ # "https://docs.google.com/spreadsheet/ccc?key=pz7XtlQC-PYx-jrVMJErTcg")
211
+ # session.spreadsheet_by_url(
212
+ # "https://spreadsheets.google.com/feeds/" +
213
+ # "worksheets/pz7XtlQC-PYx-jrVMJErTcg/private/full")
214
+ def spreadsheet_by_url(url)
215
+ # Tries to parse it as URL of human-readable spreadsheet.
216
+ uri = URI.parse(url)
217
+ if ["spreadsheets.google.com", "docs.google.com"].include?(uri.host)
218
+ case uri.path
219
+ when /\/d\/([^\/]+)/
220
+ return spreadsheet_by_key($1)
221
+ when /\/ccc$/
222
+ if (uri.query || "").split(/&/).find(){ |s| s=~ /^key=(.*)$/ }
223
+ return spreadsheet_by_key($1)
224
+ end
225
+ end
226
+ end
227
+ # Assumes the URL is worksheets feed URL.
228
+ return Spreadsheet.new(self, url)
229
+ end
230
+
231
+ # Returns GoogleDriveV1::Spreadsheet with given +title+.
232
+ # Returns nil if not found. If multiple spreadsheets with the +title+ are found, returns
233
+ # one of them.
234
+ def spreadsheet_by_title(title)
235
+ return spreadsheets({"title" => title, "title-exact" => "true"})[0]
236
+ end
237
+
238
+ # Returns GoogleDriveV1::Worksheet with given +url+.
239
+ # You must specify URL of cell-based feed of the worksheet.
240
+ #
241
+ # e.g.
242
+ # session.worksheet_by_url(
243
+ # "http://spreadsheets.google.com/feeds/" +
244
+ # "cells/pz7XtlQC-PYxNmbBVgyiNWg/od6/private/full")
245
+ def worksheet_by_url(url)
246
+ return Worksheet.new(self, nil, url)
247
+ end
248
+
249
+ # Returns the root collection.
250
+ def root_collection
251
+ return Collection.new(self, Collection::ROOT_URL)
252
+ end
253
+
254
+ # Returns the top-level collections (direct children of the root collection).
255
+ def collections
256
+ return self.root_collection.subcollections
257
+ end
258
+
259
+ # Returns a top-level collection whose title exactly matches +title+ as
260
+ # GoogleDriveV1::Collection.
261
+ # Returns nil if not found. If multiple collections with the +title+ are found, returns
262
+ # one of them.
263
+ def collection_by_title(title)
264
+ return self.root_collection.subcollection_by_title(title)
265
+ end
266
+
267
+ # Returns GoogleDriveV1::Collection with given +url+.
268
+ # You must specify either of:
269
+ # - URL of the page you get when you go to https://docs.google.com/ with your browser and
270
+ # open a collection
271
+ # - URL of collection (folder) feed
272
+ #
273
+ # e.g.
274
+ # session.collection_by_url(
275
+ # "https://drive.google.com/#folders/" +
276
+ # "0B9GfDpQ2pBVUODNmOGE0NjIzMWU3ZC00NmUyLTk5NzEtYaFkZjY1MjAyxjMc")
277
+ # session.collection_by_url(
278
+ # "http://docs.google.com/feeds/default/private/full/folder%3A" +
279
+ # "0B9GfDpQ2pBVUODNmOGE0NjIzMWU3ZC00NmUyLTk5NzEtYaFkZjY1MjAyxjMc")
280
+ def collection_by_url(url)
281
+ uri = URI.parse(url)
282
+ if ["docs.google.com", "drive.google.com"].include?(uri.host) &&
283
+ uri.fragment =~ /^folders\/(.+)$/
284
+ # Looks like a URL of human-readable collection page. Converts to collection feed URL.
285
+ url = "#{DOCS_BASE_URL}/folder%3A#{$1}"
286
+ end
287
+ return Collection.new(self, to_v3_url(url))
288
+ end
289
+
290
+ # Creates new spreadsheet and returns the new GoogleDriveV1::Spreadsheet.
291
+ #
292
+ # e.g.
293
+ # session.create_spreadsheet("My new sheet")
294
+ def create_spreadsheet(
295
+ title = "Untitled",
296
+ feed_url = "https://docs.google.com/feeds/documents/private/full")
297
+
298
+ xml = <<-"EOS"
299
+ <atom:entry
300
+ xmlns:atom="http://www.w3.org/2005/Atom"
301
+ xmlns:docs="http://schemas.google.com/docs/2007">
302
+ <atom:category
303
+ scheme="http://schemas.google.com/g/2005#kind"
304
+ term="http://schemas.google.com/docs/2007#spreadsheet"
305
+ label="spreadsheet"/>
306
+ <atom:title>#{h(title)}</atom:title>
307
+ </atom:entry>
308
+ EOS
309
+
310
+ doc = request(:post, feed_url, :data => xml, :auth => :writely)
311
+ ss_url = doc.css(
312
+ "link[rel='http://schemas.google.com/spreadsheets/2006#worksheetsfeed']")[0]["href"]
313
+ return Spreadsheet.new(self, ss_url, title)
314
+
315
+ end
316
+
317
+ # Uploads a file with the given +title+ and +content+.
318
+ # Returns a GoogleSpreadsheet::File object.
319
+ #
320
+ # e.g.
321
+ # # Uploads and converts to a Google Docs document:
322
+ # session.upload_from_string(
323
+ # "Hello world.", "Hello", :content_type => "text/plain")
324
+ #
325
+ # # Uploads without conversion:
326
+ # session.upload_from_string(
327
+ # "Hello world.", "Hello", :content_type => "text/plain", :convert => false)
328
+ #
329
+ # # Uploads and converts to a Google Spreadsheet:
330
+ # session.upload_from_string("hoge\tfoo\n", "Hoge", :content_type => "text/tab-separated-values")
331
+ # session.upload_from_string("hoge,foo\n", "Hoge", :content_type => "text/tsv")
332
+ def upload_from_string(content, title = "Untitled", params = {})
333
+ return upload_from_io(StringIO.new(content), title, params)
334
+ end
335
+
336
+ # Uploads a local file.
337
+ # Returns a GoogleSpreadsheet::File object.
338
+ #
339
+ # e.g.
340
+ # # Uploads a text file and converts to a Google Docs document:
341
+ # session.upload_from_file("/path/to/hoge.txt")
342
+ #
343
+ # # Uploads without conversion:
344
+ # session.upload_from_file("/path/to/hoge.txt", "Hoge", :convert => false)
345
+ #
346
+ # # Uploads with explicit content type:
347
+ # session.upload_from_file("/path/to/hoge", "Hoge", :content_type => "text/plain")
348
+ #
349
+ # # Uploads a text file and converts to a Google Spreadsheet:
350
+ # session.upload_from_file("/path/to/hoge.tsv", "Hoge")
351
+ # session.upload_from_file("/path/to/hoge.csv", "Hoge")
352
+ # session.upload_from_file("/path/to/hoge", "Hoge", :content_type => "text/tab-separated-values")
353
+ # session.upload_from_file("/path/to/hoge", "Hoge", :content_type => "text/csv")
354
+ def upload_from_file(path, title = nil, params = {})
355
+ file_name = ::File.basename(path)
356
+ params = {:file_name => file_name}.merge(params)
357
+ open(path, "rb") do |f|
358
+ return upload_from_io(f, title || file_name, params)
359
+ end
360
+ end
361
+
362
+ # Uploads a file. Reads content from +io+.
363
+ # Returns a GoogleSpreadsheet::File object.
364
+ def upload_from_io(io, title = "Untitled", params = {})
365
+ doc = request(:get, "#{DOCS_BASE_URL}?v=3",
366
+ :auth => :writely)
367
+ initial_url = doc.css(
368
+ "link[rel='http://schemas.google.com/g/2005#resumable-create-media']")[0]["href"]
369
+ entry = upload_raw(:post, initial_url, io, title, params)
370
+ return entry_element_to_file(entry)
371
+ end
372
+
373
+ def upload_raw(method, url, io, title = "Untitled", params = {}) #:nodoc:
374
+
375
+ params = {:convert => true}.merge(params)
376
+ pos = io.pos
377
+ io.seek(0, IO::SEEK_END)
378
+ total_bytes = io.pos - pos
379
+ io.pos = pos
380
+ content_type = params[:content_type]
381
+ if !content_type && params[:file_name]
382
+ content_type = EXT_TO_CONTENT_TYPE[::File.extname(params[:file_name]).downcase]
383
+ end
384
+ if !content_type
385
+ content_type = "application/octet-stream"
386
+ end
387
+
388
+ initial_xml = <<-"EOS"
389
+ <entry xmlns="http://www.w3.org/2005/Atom"
390
+ xmlns:docs="http://schemas.google.com/docs/2007">
391
+ <title>#{h(title)}</title>
392
+ </entry>
393
+ EOS
394
+
395
+ default_initial_header = {
396
+ "Content-Type" => "application/atom+xml;charset=utf-8",
397
+ "X-Upload-Content-Type" => content_type,
398
+ "X-Upload-Content-Length" => total_bytes.to_s(),
399
+ }
400
+ initial_full_url = concat_url(url, params[:convert] ? "?convert=true" : "?convert=false")
401
+ initial_response = request(method, initial_full_url,
402
+ :header => default_initial_header.merge(params[:header] || {}),
403
+ :data => initial_xml,
404
+ :auth => :writely,
405
+ :response_type => :response)
406
+ upload_url = initial_response["location"]
407
+
408
+ if total_bytes > 0
409
+ sent_bytes = 0
410
+ while data = io.read(UPLOAD_CHUNK_SIZE)
411
+ content_range = "bytes %d-%d/%d" % [
412
+ sent_bytes,
413
+ sent_bytes + data.bytesize - 1,
414
+ total_bytes,
415
+ ]
416
+ upload_header = {
417
+ "Content-Type" => content_type,
418
+ "Content-Range" => content_range,
419
+ }
420
+ doc = request(
421
+ :put, upload_url, :header => upload_header, :data => data, :auth => :writely)
422
+ sent_bytes += data.bytesize
423
+ end
424
+ else
425
+ upload_header = {
426
+ "Content-Type" => content_type,
427
+ }
428
+ doc = request(
429
+ :put, upload_url, :header => upload_header, :data => "", :auth => :writely)
430
+ end
431
+
432
+ return doc.root
433
+
434
+ end
435
+
436
+ def entry_element_to_file(entry) #:nodoc:
437
+ type, resource_id = entry.css("gd|resourceId").text.split(/:/)
438
+ title = entry.css("title").text
439
+ case type
440
+ when "folder"
441
+ return Collection.new(self, entry)
442
+ when "spreadsheet"
443
+ worksheets_feed_link = entry.css(
444
+ "link[rel='http://schemas.google.com/spreadsheets/2006#worksheetsfeed']")[0]
445
+ return Spreadsheet.new(self, worksheets_feed_link["href"], title)
446
+ else
447
+ return GoogleDriveV1::File.new(self, entry)
448
+ end
449
+ end
450
+
451
+ def request(method, url, params = {}) #:nodoc:
452
+
453
+ # Always uses HTTPS.
454
+ url = url.gsub(%r{^http://}, "https://")
455
+ data = params[:data]
456
+ auth = params[:auth] || :wise
457
+ if params[:header]
458
+ extra_header = params[:header]
459
+ elsif data
460
+ extra_header = {"Content-Type" => "application/atom+xml;charset=utf-8"}
461
+ else
462
+ extra_header = {}
463
+ end
464
+ response_type = params[:response_type] || :xml
465
+
466
+ while true
467
+ response = @fetcher.request_raw(method, url, data, extra_header, auth)
468
+ if response.code == "401" && @on_auth_fail && @on_auth_fail.call()
469
+ next
470
+ end
471
+ if !(response.code =~ /^[23]/)
472
+ raise(
473
+ response.code == "401" ? AuthenticationError : GoogleDriveV1::Error,
474
+ "Response code #{response.code} for #{method} #{url}: " +
475
+ CGI.unescapeHTML(response.body))
476
+ end
477
+ return convert_response(response, response_type)
478
+ end
479
+
480
+ end
481
+
482
+ def inspect
483
+ return "#<%p:0x%x>" % [self.class, self.object_id]
484
+ end
485
+
486
+ private
487
+
488
+ def convert_response(response, response_type)
489
+ case response_type
490
+ when :xml
491
+ return Nokogiri.XML(response.body)
492
+ when :raw
493
+ return response.body
494
+ when :response
495
+ return response
496
+ else
497
+ raise(GoogleDriveV1::Error,
498
+ "Unknown params[:response_type]: %s" % response_type)
499
+ end
500
+ end
501
+
502
+ def authenticate(mail, password, auth)
503
+ params = {
504
+ "accountType" => "HOSTED_OR_GOOGLE",
505
+ "Email" => mail,
506
+ "Passwd" => password,
507
+ "service" => auth.to_s(),
508
+ "source" => "Gimite-RubyGoogleDrive-1.00",
509
+ }
510
+ header = {"Content-Type" => "application/x-www-form-urlencoded"}
511
+ response = request(:post,
512
+ "https://www.google.com/accounts/ClientLogin",
513
+ :data => encode_query(params),
514
+ :auth => :none,
515
+ :header => header,
516
+ :response_type => :raw)
517
+ return response.slice(/^Auth=(.*)$/, 1)
518
+ end
519
+
520
+ end
521
+
522
+ end