pyrat-integrity-basecamp 0.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.
data/README.markdown ADDED
@@ -0,0 +1,50 @@
1
+ Integrity
2
+ =========
3
+
4
+ [Integrity][] is your friendly automated Continuous Integration server.
5
+
6
+ Integrity Basecamp Notifier
7
+ ===========================
8
+
9
+ This lets Integrity alert Basecamp after each build is made.
10
+
11
+ Setup Instructions
12
+ ==================
13
+
14
+ Just install this gem via `sudo gem install -s http://gems.github.com
15
+ pyrat-integrity-basecamp` and then in your Rackup (ie, `config.ru`) file:
16
+
17
+ require "rubygems"
18
+ require "notifier/basecamp"
19
+
20
+ Now you can set up your projects to alert Basecamp after
21
+ each build (just edit the project and the config options should be
22
+ there)
23
+
24
+ License
25
+ =======
26
+
27
+ (The MIT License)
28
+
29
+ Copyright (c) 2009 Alastair Brunton
30
+
31
+ Permission is hereby granted, free of charge, to any person obtaining
32
+ a copy of this software and associated documentation files (the
33
+ 'Software'), to deal in the Software without restriction, including
34
+ without limitation the rights to use, copy, modify, merge, publish,
35
+ distribute, sublicense, and/or sell copies of the Software, and to
36
+ permit persons to whom the Software is furnished to do so, subject to
37
+ the following conditions:
38
+
39
+ The above copyright notice and this permission notice shall be
40
+ included in all copies or substantial portions of the Software.
41
+
42
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
43
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
44
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
45
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
46
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
47
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
48
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
49
+
50
+ [Integrity]: http://integrityapp.com
@@ -0,0 +1,491 @@
1
+ # the following are all standard ruby libraries
2
+ require 'net/https'
3
+ require 'yaml'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ begin
8
+ require 'rubygems'
9
+ require 'xmlsimple'
10
+ rescue LoadError
11
+ begin
12
+ require 'rubygems'
13
+ require_gem 'xml-simple'
14
+ rescue LoadError
15
+ abort <<-ERROR
16
+ The 'xml-simple' library could not be loaded. If you have RubyGems installed
17
+ you can install xml-simple by doing "gem install xml-simple".
18
+ ERROR
19
+ end
20
+ end
21
+
22
+ # An interface to the Basecamp web-services API. Usage is straightforward:
23
+ #
24
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
25
+ # puts "projects: #{session.projects.length}"
26
+ class Basecamp
27
+
28
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
29
+ class Record #:nodoc:
30
+ attr_reader :type
31
+
32
+ def initialize(type, hash)
33
+ @type = type
34
+ @hash = hash
35
+ end
36
+
37
+ def [](name)
38
+ name = dashify(name)
39
+ case @hash[name]
40
+ when Hash then
41
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
42
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
43
+ Record.new(name, @hash[name])
44
+ else @hash[name]
45
+ end
46
+ end
47
+
48
+ def id
49
+ @hash["id"]
50
+ end
51
+
52
+ def attributes
53
+ @hash.keys
54
+ end
55
+
56
+ def respond_to?(sym)
57
+ super || @hash.has_key?(dashify(sym))
58
+ end
59
+
60
+ def method_missing(sym, *args)
61
+ if args.empty? && !block_given? && respond_to?(sym)
62
+ self[sym]
63
+ else
64
+ super
65
+ end
66
+ end
67
+
68
+ def to_s
69
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
70
+ end
71
+
72
+ def inspect
73
+ to_s
74
+ end
75
+
76
+ private
77
+
78
+ def dashify(name)
79
+ name.to_s.tr("_", "-")
80
+ end
81
+ end
82
+
83
+ # A wrapper to represent a file that should be uploaded. This is used so that
84
+ # the form/multi-part encoder knows when to encode a field as a file, versus
85
+ # when to encode it as a simple field.
86
+ class FileUpload
87
+ attr_reader :filename, :content
88
+
89
+ def initialize(filename, content)
90
+ @filename = filename
91
+ @content = content
92
+ end
93
+ end
94
+
95
+ attr_accessor :use_xml
96
+
97
+ # Connects
98
+ def initialize(url, user_name, password, use_ssl = false)
99
+ @use_xml = false
100
+ @user_name, @password = user_name, password
101
+ connect!(url, use_ssl)
102
+ end
103
+
104
+ # Return the list of all accessible projects.
105
+ def projects
106
+ records "project", "/project/list"
107
+ end
108
+
109
+ # Returns the list of message categories for the given project
110
+ def message_categories(project_id)
111
+ records "post-category", "/projects/#{project_id}/post_categories"
112
+ end
113
+
114
+ # Returns the list of file categories for the given project
115
+ def file_categories(project_id)
116
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
117
+ end
118
+
119
+ # Return information for the company with the given id
120
+ def company(id)
121
+ record "/contacts/company/#{id}"
122
+ end
123
+
124
+ # Return an array of the people in the given company. If the project-id is
125
+ # given, only people who have access to the given project will be returned.
126
+ def people(company_id, project_id=nil)
127
+ url = project_id ? "/projects/#{project_id}" : ""
128
+ url << "/contacts/people/#{company_id}"
129
+ records "person", url
130
+ end
131
+
132
+ # Return information about the person with the given id
133
+ def person(id)
134
+ record "/contacts/person/#{id}"
135
+ end
136
+
137
+ # Return information about the message(s) with the given id(s). The API
138
+ # limits you to requesting 25 messages at a time, so if you need to get more
139
+ # than that, you'll need to do it in multiple requests.
140
+ def message(*ids)
141
+ result = records("post", "/msg/get/#{ids.join(",")}")
142
+ result.length == 1 ? result.first : result
143
+ end
144
+
145
+ # Returns a summary of all messages in the given project (and category, if
146
+ # specified). The summary is simply the title and category of the message,
147
+ # as well as the number of attachments (if any).
148
+ def message_list(project_id, category_id=nil)
149
+ url = "/projects/#{project_id}/msg"
150
+ url << "/cat/#{category_id}" if category_id
151
+ url << "/archive"
152
+
153
+ records "post", url
154
+ end
155
+
156
+ # Create a new message in the given project. The +message+ parameter should
157
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
158
+ # should be notified of the post.
159
+ #
160
+ # If you want to add attachments to the message, the +attachments+ parameter
161
+ # should be an array of hashes, where each has has a :name key (optional),
162
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
163
+ # instance.
164
+ #
165
+ # msg = session.post_message(158141,
166
+ # { :title => "Requirements",
167
+ # :body => "Here are the requirements documents you asked for.",
168
+ # :category_id => 2301121 },
169
+ # [john.id, martha.id],
170
+ # [ { :name => "Primary Requirements",
171
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
172
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
173
+ def post_message(project_id, message, notify=[], attachments=[])
174
+ prepare_attachments(attachments)
175
+ record "/projects/#{project_id}/msg/create",
176
+ :post => message,
177
+ :notify => notify,
178
+ :attachments => attachments
179
+ end
180
+
181
+ # Edit the message with the given id. The +message+ parameter should
182
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
183
+ # should be notified of the post.
184
+ #
185
+ # The +attachments+ parameter, if used, should be the same as described for
186
+ # #post_message.
187
+ def update_message(id, message, notify=[], attachments=[])
188
+ prepare_attachments(attachments)
189
+ record "/msg/update/#{id}",
190
+ :post => message,
191
+ :notify => notify,
192
+ :attachments => attachments
193
+ end
194
+
195
+ # Deletes the message with the given id, and returns it.
196
+ def delete_message(id)
197
+ record "/msg/delete/#{id}"
198
+ end
199
+
200
+ # Return a list of the comments for the specified message.
201
+ def comments(post_id)
202
+ records "comment", "/msg/comments/#{post_id}"
203
+ end
204
+
205
+ # Retrieve a specific comment
206
+ def comment(id)
207
+ record "/msg/comment/#{id}"
208
+ end
209
+
210
+ # Add a new comment to a message. +comment+ must be a hash describing the
211
+ # comment. You can add attachments to the comment, too, by giving them in
212
+ # an array. See the #post_message method for a description of how to do that.
213
+ def create_comment(post_id, comment, attachments=[])
214
+ prepare_attachments(attachments)
215
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
216
+ :attachments => attachments
217
+ end
218
+
219
+ # Update the given comment. Attachments follow the same format as #post_message.
220
+ def update_comment(id, comment, attachments=[])
221
+ prepare_attachments(attachments)
222
+ record "/msg/update_comment", :comment_id => id,
223
+ :comment => comment, :attachments => attachments
224
+ end
225
+
226
+ # Deletes (and returns) the given comment.
227
+ def delete_comment(id)
228
+ record "/msg/delete_comment/#{id}"
229
+ end
230
+
231
+ # =========================================================================
232
+ # TODO LISTS AND ITEMS
233
+ # =========================================================================
234
+
235
+ # Marks the given item completed.
236
+ def complete_item(id)
237
+ record "/todos/complete_item/#{id}"
238
+ end
239
+
240
+ # Marks the given item uncompleted.
241
+ def uncomplete_item(id)
242
+ record "/todos/uncomplete_item/#{id}"
243
+ end
244
+
245
+ # Creates a new to-do item.
246
+ def create_item(list_id, content, responsible_party=nil, notify=true)
247
+ record "/todos/create_item/#{list_id}",
248
+ :content => content, :responsible_party => responsible_party,
249
+ :notify => notify
250
+ end
251
+
252
+ # Creates a new list using the given hash of list metadata.
253
+ def create_list(project_id, list)
254
+ record "/projects/#{project_id}/todos/create_list", list
255
+ end
256
+
257
+ # Deletes the given item from it's parent list.
258
+ def delete_item(id)
259
+ record "/todos/delete_item/#{id}"
260
+ end
261
+
262
+ # Deletes the given list and all of its items.
263
+ def delete_list(id)
264
+ record "/todos/delete_list/#{id}"
265
+ end
266
+
267
+ # Retrieves the specified list, and all of its items.
268
+ def get_list(id)
269
+ record "/todos/list/#{id}"
270
+ end
271
+
272
+ # Return all lists for a project. If complete is true, only completed lists
273
+ # are returned. If complete is false, only uncompleted lists are returned.
274
+ def lists(project_id, complete=nil)
275
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
276
+ end
277
+
278
+ # Repositions an item to be at the given position in its list
279
+ def move_item(id, to)
280
+ record "/todos/move_item/#{id}", :to => to
281
+ end
282
+
283
+ # Repositions a list to be at the given position in its project
284
+ def move_list(id, to)
285
+ record "/todos/move_list/#{id}", :to => to
286
+ end
287
+
288
+ # Updates the given item
289
+ def update_item(id, content, responsible_party=nil, notify=true)
290
+ record "/todos/update_item/#{id}",
291
+ :item => { :content => content }, :responsible_party => responsible_party,
292
+ :notify => notify
293
+ end
294
+
295
+ # Updates the given list's metadata
296
+ def update_list(id, list)
297
+ record "/todos/update_list/#{id}", :list => list
298
+ end
299
+
300
+ # =========================================================================
301
+ # MILESTONES
302
+ # =========================================================================
303
+
304
+ # Complete the milestone with the given id
305
+ def complete_milestone(id)
306
+ record "/milestones/complete/#{id}"
307
+ end
308
+
309
+ # Create a new milestone for the given project. +data+ must be hash of the
310
+ # values to set, including +title+, +deadline+, +responsible_party+, and
311
+ # +notify+.
312
+ def create_milestone(project_id, data)
313
+ create_milestones(project_id, [data]).first
314
+ end
315
+
316
+ # As #create_milestone, but can create multiple milestones in a single
317
+ # request. The +milestones+ parameter must be an array of milestone values as
318
+ # descrbed in #create_milestone.
319
+ def create_milestones(project_id, milestones)
320
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
321
+ end
322
+
323
+ # Destroys the milestone with the given id.
324
+ def delete_milestone(id)
325
+ record "/milestones/delete/#{id}"
326
+ end
327
+
328
+ # Returns a list of all milestones for the given project, optionally filtered
329
+ # by whether they are completed, late, or upcoming.
330
+ def milestones(project_id, find="all")
331
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
332
+ end
333
+
334
+ # Uncomplete the milestone with the given id
335
+ def uncomplete_milestone(id)
336
+ record "/milestones/uncomplete/#{id}"
337
+ end
338
+
339
+ # Updates an existing milestone.
340
+ def update_milestone(id, data, move=false, move_off_weekends=false)
341
+ record "/milestones/update/#{id}", :milestone => data,
342
+ :move_upcoming_milestones => move,
343
+ :move_upcoming_milestones_off_weekends => move_off_weekends
344
+ end
345
+
346
+ # Make a raw web-service request to Basecamp. This will return a Hash of
347
+ # Arrays of the response, and may seem a little odd to the uninitiated.
348
+ def request(path, parameters = {}, second_try = false)
349
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
350
+
351
+ if response.code.to_i / 100 == 2
352
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
353
+ 'contentkey' => '__content__', 'forcecontent' => true)
354
+ typecast_value(result)
355
+ elsif response.code == "302" && !second_try
356
+ connect!(@url, !@use_ssl)
357
+ request(path, parameters, true)
358
+ else
359
+ raise "#{response.message} (#{response.code})"
360
+ end
361
+ end
362
+
363
+ # A convenience method for wrapping the result of a query in a Record
364
+ # object. This assumes that the result is a singleton, not a collection.
365
+ def record(path, parameters={})
366
+ result = request(path, parameters)
367
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
368
+ end
369
+
370
+ # A convenience method for wrapping the result of a query in Record
371
+ # objects. This assumes that the result is a collection--any singleton
372
+ # result will be wrapped in an array.
373
+ def records(node, path, parameters={})
374
+ result = request(path, parameters).values.first or return []
375
+ result = result[node] or return []
376
+ result = [result] unless Array === result
377
+ result.map { |row| Record.new(node, row) }
378
+ end
379
+
380
+ private
381
+
382
+ def connect!(url, use_ssl)
383
+ @use_ssl = use_ssl
384
+ @url = url
385
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
386
+ @connection.use_ssl = @use_ssl
387
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
388
+ end
389
+
390
+ def convert_body(body)
391
+ body = use_xml ? body.to_xml : body.to_yaml
392
+ end
393
+
394
+ def content_type
395
+ use_xml ? "application/xml" : "application/x-yaml"
396
+ end
397
+
398
+ def post(path, body, header={})
399
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
400
+ request.basic_auth(@user_name, @password)
401
+ @connection.request(request, body)
402
+ end
403
+
404
+ def store_file(contents)
405
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
406
+ 'Accept' => 'application/xml')
407
+
408
+ if response.code == "200"
409
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
410
+ return result["upload"]["id"]
411
+ else
412
+ raise "Could not store file: #{response.message} (#{response.code})"
413
+ end
414
+ end
415
+
416
+ def typecast_value(value)
417
+ case value
418
+ when Hash
419
+ if value.has_key?("__content__")
420
+ content = translate_entities(value["__content__"]).strip
421
+ case value["type"]
422
+ when "integer" then content.to_i
423
+ when "boolean" then content == "true"
424
+ when "datetime" then Time.parse(content)
425
+ when "date" then Date.parse(content)
426
+ else content
427
+ end
428
+ # a special case to work-around a bug in XmlSimple. When you have an empty
429
+ # tag that has an attribute, XmlSimple will not add the __content__ key
430
+ # to the returned hash. Thus, we check for the presense of the 'type'
431
+ # attribute to look for empty, typed tags, and simply return nil for
432
+ # their value.
433
+ elsif value.keys == %w(type)
434
+ nil
435
+ elsif value["nil"] == "true"
436
+ nil
437
+ # another special case, introduced by the latest rails, where an array
438
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
439
+ # one key is 'type' and the other is the actual array value.
440
+ elsif value.keys.length == 2 && value["type"] == "array"
441
+ value.delete("type")
442
+ typecast_value(value)
443
+ else
444
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
445
+ h[k] = typecast_value(v)
446
+ h
447
+ end
448
+ end
449
+ when Array
450
+ value.map! { |i| typecast_value(i) }
451
+ case value.length
452
+ when 0 then nil
453
+ when 1 then value.first
454
+ else value
455
+ end
456
+ else
457
+ raise "can't typecast #{value.inspect}"
458
+ end
459
+ end
460
+
461
+ def translate_entities(value)
462
+ value.gsub(/&lt;/, "<").
463
+ gsub(/&gt;/, ">").
464
+ gsub(/&quot;/, '"').
465
+ gsub(/&apos;/, "'").
466
+ gsub(/&amp;/, "&")
467
+ end
468
+
469
+ def prepare_attachments(list)
470
+ (list || []).each do |data|
471
+ upload = data[:file]
472
+ id = store_file(upload.content)
473
+ data[:file] = { :file => id,
474
+ :content_type => "application/octet-stream",
475
+ :original_filename => upload.filename }
476
+ end
477
+ end
478
+ end
479
+
480
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
481
+ class Symbol
482
+ def [](*args)
483
+ to_s[*args]
484
+ end
485
+ end
486
+
487
+ class Hash
488
+ def to_xml
489
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
490
+ end
491
+ end
@@ -0,0 +1,41 @@
1
+ require 'rubygems'
2
+ require 'integrity'
3
+ require 'basecamp-api'
4
+
5
+ module Integrity
6
+ class Notifier
7
+ class Basecamp < Notifier::Base
8
+ attr_reader :config
9
+
10
+ def self.to_haml
11
+ File.read File.dirname(__FILE__) / "config.haml"
12
+ end
13
+
14
+
15
+ def deliver!
16
+ @basecamp = Basecamp.new(config['domain'], options['user'], options['pass'], true)
17
+ message = {:title => short_message,
18
+ :body => full_message,
19
+ :category_id => config['category_id']}
20
+ @basecamp.post_message(config['project_id'], message)
21
+ end
22
+
23
+ private
24
+
25
+ def short_message
26
+ "Build #{build.short_commit_identifier} of #{build.project.name} #{build.successful? ? "was successful" : "failed"}"
27
+ end
28
+
29
+ def full_message
30
+ <<-EOM
31
+ Commit Message: #{build.commit_message}
32
+ Commit Date: #{build.commited_at}
33
+ Commit Author: #{build.commit_author.name}
34
+
35
+ #{stripped_build_output}
36
+ EOM
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ :username => 'robot', :domain => 'ifo.projectpath.com',
2
+ # :password => 'robot', :project_id => 828898,
3
+ # :category_id => 15509
4
+
5
+
6
+ %p.normal
7
+ %label{ :for => "basecamp_notifier_account" } Subdomain
8
+ %input.text#basecamp_notifier_account{ :name => "notifiers[Basecamp][account]", :type => "text", :value => config["account"] }
9
+
10
+ %p.normal
11
+ %label{ :for => "basecamp_notifier_room" } Project Id
12
+ %input.text#basecamp_notifier_project{ :name => "notifiers[Basecamp][project_id]", :type => "text", :value => config["project_id"] }
13
+
14
+ %p.normal
15
+ %label{ :for => "basecamp_notifier_room" } Category Id
16
+ %input.text#basecamp_notifier_project{ :name => "notifiers[Basecamp][category_id]", :type => "text", :value => config["category_id"] }
17
+
18
+ %p.normal
19
+ %label{ :for => "basecamp_notifier_user" } User
20
+ %input.text#basecamp_notifier_user{ :name => "notifiers[Basecamp][user]", :value => config["user"], :type => "text" }
21
+
22
+ %p.normal
23
+ %label{ :for => "basecamp_notifier_pass" } Password
24
+ %input.text#basecamp_notifier_pass{ :name => "notifiers[Basecamp][pass]", :value => config["pass"], :type => "text" }
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pyrat-integrity-basecamp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alastair Brunton
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-22 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: foca-integrity
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: "0"
23
+ version:
24
+ description: Easily let Integrity alert Basecamp after each build
25
+ email: info@simplyexcited.co.uk
26
+ executables: []
27
+
28
+ extensions: []
29
+
30
+ extra_rdoc_files: []
31
+
32
+ files:
33
+ - README.markdown
34
+ - lib/notifier/config.haml
35
+ - lib/notifier/basecamp.rb
36
+ - lib/notifier/basecamp-api.rb
37
+ has_rdoc: false
38
+ homepage: http://integrityapp.com
39
+ post_install_message:
40
+ rdoc_options: []
41
+
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: "0"
49
+ version:
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: "0"
55
+ version:
56
+ requirements: []
57
+
58
+ rubyforge_project:
59
+ rubygems_version: 1.2.0
60
+ signing_key:
61
+ specification_version: 2
62
+ summary: Basecamp notifier for the Integrity continuous integration server
63
+ test_files: []
64
+