nextcloud 1.3.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 +7 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +172 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +668 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/nextcloud.rb +47 -0
- data/lib/nextcloud/api.rb +51 -0
- data/lib/nextcloud/errors/nextcloud.rb +5 -0
- data/lib/nextcloud/helpers/nextcloud.rb +101 -0
- data/lib/nextcloud/helpers/properties.rb +73 -0
- data/lib/nextcloud/models/directory.rb +74 -0
- data/lib/nextcloud/models/user.rb +50 -0
- data/lib/nextcloud/ocs/app.rb +90 -0
- data/lib/nextcloud/ocs/file_sharing_api.rb +221 -0
- data/lib/nextcloud/ocs/group.rb +89 -0
- data/lib/nextcloud/ocs/user.rb +225 -0
- data/lib/nextcloud/ocs_api.rb +34 -0
- data/lib/nextcloud/version/nextcloud.rb +5 -0
- data/lib/nextcloud/webdav/directory.rb +174 -0
- data/lib/nextcloud/webdav_api.rb +21 -0
- data/nextcloud.gemspec +45 -0
- metadata +215 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "nextcloud"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/nextcloud.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require "net/https"
|
2
|
+
require "nokogiri"
|
3
|
+
|
4
|
+
require "nextcloud/version/nextcloud"
|
5
|
+
require "nextcloud/errors/nextcloud"
|
6
|
+
|
7
|
+
require "nextcloud/helpers/nextcloud"
|
8
|
+
require "nextcloud/helpers/properties"
|
9
|
+
|
10
|
+
require "nextcloud/api"
|
11
|
+
|
12
|
+
require "nextcloud/ocs_api"
|
13
|
+
require "nextcloud/ocs/user"
|
14
|
+
require "nextcloud/ocs/group"
|
15
|
+
require "nextcloud/ocs/app"
|
16
|
+
require "nextcloud/ocs/file_sharing_api"
|
17
|
+
|
18
|
+
require "nextcloud/webdav_api"
|
19
|
+
require "nextcloud/webdav/directory"
|
20
|
+
|
21
|
+
require "nextcloud/models/user"
|
22
|
+
require "nextcloud/models/directory"
|
23
|
+
|
24
|
+
# Namespace for Nextcloud OCS API communication
|
25
|
+
module Nextcloud
|
26
|
+
class << self
|
27
|
+
# Access to OCS API from base instance
|
28
|
+
#
|
29
|
+
# @param [Hash] args authentication credentials.
|
30
|
+
# @option args [String] :url Nextcloud instance URL
|
31
|
+
# @option args [String] :username Nextcloud instance administrator username
|
32
|
+
# @option args [String] :password Nextcloud instance administrator password
|
33
|
+
def ocs(args)
|
34
|
+
OcsApi.new(args)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Access to WebDAV API from base instance
|
38
|
+
#
|
39
|
+
# @param [Hash] args authentication credentials.
|
40
|
+
# @option args [String] :url Nextcloud instance URL
|
41
|
+
# @option args [String] :username Nextcloud instance administrator username
|
42
|
+
# @option args [String] :password Nextcloud instance administrator password
|
43
|
+
def webdav(args)
|
44
|
+
WebdavApi.new(args)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Nextcloud
|
2
|
+
class Api
|
3
|
+
attr_reader :url, :username, :password
|
4
|
+
protected :url
|
5
|
+
protected :username
|
6
|
+
protected :password
|
7
|
+
|
8
|
+
# Gathers credentials for communicating with Nextcloud instance
|
9
|
+
#
|
10
|
+
# @param [Hash] args authentication credentials.
|
11
|
+
# @option args [String] :url Nextcloud instance URL
|
12
|
+
# @option args [String] :username Nextcloud instance administrator username
|
13
|
+
# @option args [String] :password Nextcloud instance administrator password
|
14
|
+
def initialize(args)
|
15
|
+
@url = URI(args[:url] + "/ocs/v2.php/cloud/")
|
16
|
+
@username = args[:username]
|
17
|
+
@password = args[:password]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Sends API request to Nextcloud
|
21
|
+
#
|
22
|
+
# @param method [Symbol] Request type. Can be :get, :post, :put, etc.
|
23
|
+
# @param path [String] Nextcloud OCS API request path
|
24
|
+
# @param params [Hash, nil] Parameters to send
|
25
|
+
# @return [Object] Nokogiri::XML::Document
|
26
|
+
def request(method, path, params = nil, body = nil, depth = nil, destination = nil, raw = false)
|
27
|
+
response = Net::HTTP.start(@url.host, @url.port,
|
28
|
+
use_ssl: @url.scheme == "https") do |http|
|
29
|
+
req = Kernel.const_get("Net::HTTP::#{method.capitalize}").new(
|
30
|
+
@url.to_s + path, 'Content-Type': "application/x-www-form-urlencoded"
|
31
|
+
)
|
32
|
+
req["OCS-APIRequest"] = true
|
33
|
+
req.basic_auth @username, @password
|
34
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
35
|
+
|
36
|
+
req["Depth"] = 0 if depth
|
37
|
+
req["Destination"] = destination if destination
|
38
|
+
|
39
|
+
req.set_form_data(params) if params
|
40
|
+
req.body = body if body
|
41
|
+
|
42
|
+
http.request(req)
|
43
|
+
end
|
44
|
+
|
45
|
+
# if ![201, 204, 207].include? response.code
|
46
|
+
# raise Errors::Error.new("Nextcloud received invalid status code")
|
47
|
+
# end
|
48
|
+
raw ? response.body : Nokogiri::XML.parse(response.body)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require "active_support/core_ext/hash"
|
2
|
+
require "json"
|
3
|
+
|
4
|
+
module Nextcloud
|
5
|
+
# Helper methods that are used through lib
|
6
|
+
module Helpers
|
7
|
+
# Makes an array out of repeated elements
|
8
|
+
#
|
9
|
+
# @param doc [Object] Nokogiri::XML::Document
|
10
|
+
# @param xpath [String] Path to element that is being repeated
|
11
|
+
# @return [Array] Parsed array
|
12
|
+
def parse_with_meta(doc, xpath)
|
13
|
+
groups = []
|
14
|
+
doc.xpath(xpath).each do |prop|
|
15
|
+
groups << prop.text
|
16
|
+
end
|
17
|
+
meta = get_meta(doc)
|
18
|
+
groups.send(:define_singleton_method, :meta) do
|
19
|
+
meta
|
20
|
+
end
|
21
|
+
groups
|
22
|
+
end
|
23
|
+
|
24
|
+
# Parses meta information returned by API, may include a status, status code and a message
|
25
|
+
#
|
26
|
+
# @param doc [Object] Nokogiri::XML::Document
|
27
|
+
# @return [Hash] Parsed hash
|
28
|
+
def get_meta(doc)
|
29
|
+
meta = doc.xpath("//meta/*").each_with_object({}) do |node, meta|
|
30
|
+
meta[node.name] = node.text
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Converts document to hash
|
35
|
+
#
|
36
|
+
# @param doc [Object] Nokogiri::XML::Document
|
37
|
+
# @param xpath [String] Document path to convert to hash
|
38
|
+
# @return [Hash] Hash that was produced from XML document
|
39
|
+
def doc_to_hash(doc, xpath = "/")
|
40
|
+
h = Hash.from_xml(doc.xpath(xpath).to_xml)
|
41
|
+
h
|
42
|
+
end
|
43
|
+
|
44
|
+
# Adds meta method to an object
|
45
|
+
#
|
46
|
+
# @param doc [Object] Nokogiri::XML::Document to take meta information from
|
47
|
+
# @param obj [#define_singleton_method] Object to add meta method to
|
48
|
+
# @return [#define_singleton_method] Object with meta method defined
|
49
|
+
def add_meta(doc, obj)
|
50
|
+
meta = get_meta(doc)
|
51
|
+
obj.define_singleton_method(:meta) { meta } && obj
|
52
|
+
end
|
53
|
+
|
54
|
+
# Extracts remaining part of url
|
55
|
+
#
|
56
|
+
# @param href [String] Full url
|
57
|
+
# @param url [String] Part to give away
|
58
|
+
# @return [String] "Right" part of string
|
59
|
+
def path_from_href(href, url)
|
60
|
+
href.match(/#{url}(.*)/)[1]
|
61
|
+
end
|
62
|
+
|
63
|
+
# Shows errors, or success message
|
64
|
+
#
|
65
|
+
# @param doc [Object] Nokogiri::XML::Document
|
66
|
+
# @return [Hash] State response
|
67
|
+
def parse_dav_response(doc)
|
68
|
+
doc.remove_namespaces!
|
69
|
+
if doc.at_xpath("//error")
|
70
|
+
{
|
71
|
+
exception: doc.xpath("//exception").text,
|
72
|
+
message: doc.xpath("//message").text
|
73
|
+
}
|
74
|
+
elsif doc.at_xpath("//status")
|
75
|
+
{
|
76
|
+
status: doc.xpath("//status").text
|
77
|
+
}
|
78
|
+
else
|
79
|
+
{
|
80
|
+
status: "ok"
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Shows error or returns false
|
86
|
+
#
|
87
|
+
# @param doc [Object] Nokogiri::XML::Document
|
88
|
+
# @return [Hash,Boolean] Returns error message if found, false otherwise
|
89
|
+
def has_dav_errors(doc)
|
90
|
+
doc.remove_namespaces!
|
91
|
+
if doc.at_xpath("//error")
|
92
|
+
{
|
93
|
+
exception: doc.xpath("//exception").text,
|
94
|
+
message: doc.xpath("//message").text
|
95
|
+
}
|
96
|
+
else
|
97
|
+
false
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Nextcloud
|
2
|
+
module Helpers
|
3
|
+
module Properties
|
4
|
+
# Body to send to receive item properties
|
5
|
+
RESOURCE = '<?xml version="1.0"?>
|
6
|
+
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
7
|
+
<d:prop>
|
8
|
+
<d:getlastmodified />
|
9
|
+
<d:getetag />
|
10
|
+
<d:resourcetype />
|
11
|
+
<d:getcontenttype />
|
12
|
+
<d:getcontentlength />
|
13
|
+
<oc:id />
|
14
|
+
<oc:fileid />
|
15
|
+
<oc:permissions />
|
16
|
+
<oc:size />
|
17
|
+
<nc:has-preview />
|
18
|
+
<oc:favorite />
|
19
|
+
<oc:comments-href />
|
20
|
+
<oc:comments-count />
|
21
|
+
<oc:comments-unread />
|
22
|
+
<oc:owner-id />
|
23
|
+
<oc:owner-display-name />
|
24
|
+
<oc:share-types />
|
25
|
+
<nc:has-preview />
|
26
|
+
</d:prop>
|
27
|
+
</d:propfind>'.freeze
|
28
|
+
|
29
|
+
# Body to send to add an item to favorites
|
30
|
+
MAKE_FAVORITE = '<?xml version="1.0"?>
|
31
|
+
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
32
|
+
<d:set>
|
33
|
+
<d:prop>
|
34
|
+
<oc:favorite>1</oc:favorite>
|
35
|
+
</d:prop>
|
36
|
+
</d:set>
|
37
|
+
</d:propertyupdate>'.freeze
|
38
|
+
|
39
|
+
# Body to send to unfavorite an item
|
40
|
+
UNFAVORITE = '<?xml version="1.0"?>
|
41
|
+
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
42
|
+
<d:set>
|
43
|
+
<d:prop>
|
44
|
+
<oc:favorite>0</oc:favorite>
|
45
|
+
</d:prop>
|
46
|
+
</d:set>
|
47
|
+
</d:propertyupdate>'.freeze
|
48
|
+
|
49
|
+
# Body to send for receiving favorites
|
50
|
+
FAVORITE = '<?xml version="1.0"?>
|
51
|
+
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
52
|
+
<oc:filter-rules>
|
53
|
+
<oc:favorite>1</oc:favorite>
|
54
|
+
</oc:filter-rules>
|
55
|
+
<d:prop>
|
56
|
+
<d:getlastmodified />
|
57
|
+
<d:getetag />
|
58
|
+
<d:getcontenttype />
|
59
|
+
<d:resourcetype />
|
60
|
+
<oc:fileid />
|
61
|
+
<oc:permissions />
|
62
|
+
<oc:size />
|
63
|
+
<d:getcontentlength />
|
64
|
+
<nc:has-preview />
|
65
|
+
<oc:favorite />
|
66
|
+
<oc:comments-unread />
|
67
|
+
<oc:owner-display-name />
|
68
|
+
<oc:share-types />
|
69
|
+
</d:prop>
|
70
|
+
</oc:filter-files>'.freeze
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Nextcloud
|
2
|
+
module Models
|
3
|
+
# Directory model
|
4
|
+
#
|
5
|
+
# @!attribute [rw] href
|
6
|
+
# @return [String] File/directory location
|
7
|
+
# @!attribute [rw] lastmodified
|
8
|
+
# @return [String] Last modification time of file/directory
|
9
|
+
# @!attribute [rw] tag
|
10
|
+
# @return [Hash] Etag
|
11
|
+
# @!attribute [rw] resourcetype
|
12
|
+
# @return [String] Type of a resource
|
13
|
+
# @!attribute [rw] contenttype
|
14
|
+
# @return [String] Type of content
|
15
|
+
# @!attribute [rw] contentlength
|
16
|
+
# @return [String] Length of content
|
17
|
+
# @!attribute [rw] id
|
18
|
+
# @return [String] ID
|
19
|
+
# @!attribute [rw] fileid
|
20
|
+
# @return [String] Fileid
|
21
|
+
# @!attribute [rw] permissions
|
22
|
+
# @return [String] Permissions
|
23
|
+
# @!attribute [rw] has_preview
|
24
|
+
# @return [String] Has preview or not
|
25
|
+
# @!attribute [rw] favorite
|
26
|
+
# @return [String] Is favorited or not
|
27
|
+
# @!attribute [rw] comments_href
|
28
|
+
# @return [String] Address of comments
|
29
|
+
# @!attribute [rw] comments_count
|
30
|
+
# @return [String] Comments count
|
31
|
+
# @!attribute [rw] comments_unread
|
32
|
+
# @return [String] Unread comments
|
33
|
+
# @!attribute [rw] owner_id
|
34
|
+
# @return [String] Id of owner
|
35
|
+
# @!attribute [rw] owner_display_name
|
36
|
+
# @return [String] Display name of owner
|
37
|
+
# @!attribute [rw] share_types
|
38
|
+
# @return [String] Share types
|
39
|
+
class Directory
|
40
|
+
attr_accessor :meta, :contents
|
41
|
+
|
42
|
+
# Initiates a model instance
|
43
|
+
#
|
44
|
+
# @param [Hash]
|
45
|
+
def initialize(href: nil, lastmodified: nil, tag: nil, resourcetype: nil, contenttype: nil, contentlength: nil,
|
46
|
+
id: nil, fileid: nil, permissions: nil, size: nil, has_preview: nil, favorite: nil,
|
47
|
+
comments_href: nil, comments_count: nil, comments_unread: nil, owner_id: nil,
|
48
|
+
owner_display_name: nil, share_types: nil, skip_contents: false)
|
49
|
+
|
50
|
+
self.class.params.each do |v|
|
51
|
+
instance_variable_set("@#{v}", instance_eval(v.to_s)) if instance_eval(v.to_s)
|
52
|
+
end
|
53
|
+
|
54
|
+
remove_instance_variable (:@skip_contents) if skip_contents
|
55
|
+
end
|
56
|
+
|
57
|
+
@params = instance_method(:initialize).parameters.map(&:last)
|
58
|
+
@params.each { |p| instance_eval("attr_accessor :#{p}") }
|
59
|
+
|
60
|
+
class << self
|
61
|
+
attr_reader :params
|
62
|
+
end
|
63
|
+
|
64
|
+
# Adds content to collection
|
65
|
+
#
|
66
|
+
# @param [Hash]
|
67
|
+
# @return [Array] Contents array
|
68
|
+
def add(args)
|
69
|
+
@contents = [] if @contents.nil?
|
70
|
+
@contents << self.class.new(args.merge(skip_contents: true))
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Nextcloud
|
2
|
+
module Models
|
3
|
+
# User model
|
4
|
+
#
|
5
|
+
# @!attribute [rw] enabled
|
6
|
+
# @return [String] Is an user enabled or not
|
7
|
+
# @!attribute [rw] id
|
8
|
+
# @return [String] Identifier of an user
|
9
|
+
# @!attribute [rw] quota
|
10
|
+
# @return [Hash] Quota of user
|
11
|
+
# @!attribute [rw] email
|
12
|
+
# @return [String] E-mail address
|
13
|
+
# @!attribute [rw] displayname
|
14
|
+
# @return [String] User display name
|
15
|
+
# @!attribute [rw] phone
|
16
|
+
# @return [String] User phone number
|
17
|
+
# @!attribute [rw] address
|
18
|
+
# @return [String] User address
|
19
|
+
# @!attribute [rw] website
|
20
|
+
# @return [String] User web-site address
|
21
|
+
# @!attribute [rw] twitter
|
22
|
+
# @return [String] User Twitter account
|
23
|
+
# @!attribute [rw] groups
|
24
|
+
# @return [String] Groups user belongs to
|
25
|
+
# @!attribute [rw] language
|
26
|
+
# @return [String] Nextcloud version for an user
|
27
|
+
class User
|
28
|
+
attr_accessor :meta
|
29
|
+
|
30
|
+
# Initiates a model instance
|
31
|
+
#
|
32
|
+
# @param [Hash]
|
33
|
+
def initialize(enabled: nil, id: nil, quota: nil, email: nil, displayname: nil, phone: nil, address: nil,
|
34
|
+
website: nil,
|
35
|
+
twitter: nil, groups: nil, language: nil)
|
36
|
+
|
37
|
+
self.class.params.each do |v|
|
38
|
+
instance_variable_set("@#{v}", instance_eval(v.to_s)) if instance_eval(v.to_s)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
@params = instance_method(:initialize).parameters.map(&:last)
|
43
|
+
@params.each { |p| instance_eval("attr_accessor :#{p}") }
|
44
|
+
|
45
|
+
class << self
|
46
|
+
attr_reader :params
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module Nextcloud
|
2
|
+
module Ocs
|
3
|
+
# Application class used for interfering with app specific actions
|
4
|
+
#
|
5
|
+
# @!attribute [rw] meta
|
6
|
+
# @return [Hash] Information about API response
|
7
|
+
# @!attribute [rw] appid
|
8
|
+
# @return [Integer] Application identifier
|
9
|
+
class App < OcsApi
|
10
|
+
include Helpers
|
11
|
+
|
12
|
+
attr_accessor :meta, :appid
|
13
|
+
|
14
|
+
# Application initializer
|
15
|
+
#
|
16
|
+
# @param api [Object] Api instance
|
17
|
+
# @param appid [Integer,nil] Application identifier
|
18
|
+
def initialize(args, appid = nil)
|
19
|
+
@appid = appid if appid
|
20
|
+
|
21
|
+
if args.class == Nextcloud::OcsApi
|
22
|
+
@api = args
|
23
|
+
else
|
24
|
+
super(args)
|
25
|
+
@api = self
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Sets app (useful if class is initiated without OcsApi.app)
|
30
|
+
#
|
31
|
+
# @param userid [String] User identifier
|
32
|
+
# @return [Obeject] self
|
33
|
+
def set(appid)
|
34
|
+
@appid = appid
|
35
|
+
self
|
36
|
+
end
|
37
|
+
|
38
|
+
# List enabled applications
|
39
|
+
#
|
40
|
+
# @return [Array] List of applications that are enabled on an instance
|
41
|
+
def enabled
|
42
|
+
filter("enabled")
|
43
|
+
end
|
44
|
+
|
45
|
+
# List disabled applications
|
46
|
+
#
|
47
|
+
# @return [Array] List of applications that are disabled on an instance
|
48
|
+
def disabled
|
49
|
+
filter("disabled")
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get information about an applicaiton
|
53
|
+
#
|
54
|
+
# @param appid [Integer] Application identifier
|
55
|
+
# @return [Hash] Application information
|
56
|
+
def find(appid)
|
57
|
+
response = @api.request(:get, "apps/#{appid}")
|
58
|
+
h = doc_to_hash(response, "//data")["data"]
|
59
|
+
add_meta(response, h)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Enable an application
|
63
|
+
#
|
64
|
+
# @return [Object] Instance with meta response
|
65
|
+
def enable
|
66
|
+
response = @api.request(:post, "apps/#{@appid}")
|
67
|
+
(@meta = get_meta(response)) && self
|
68
|
+
end
|
69
|
+
|
70
|
+
# Disable an application
|
71
|
+
#
|
72
|
+
# @return [Object] Instance with meta response
|
73
|
+
def disable
|
74
|
+
response = @api.request(:delete, "apps/#{@appid}")
|
75
|
+
(@meta = get_meta(response)) && self
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
# Retrieve enabled or disabled applications
|
81
|
+
#
|
82
|
+
# @param filter [String] Either enabled or disabled
|
83
|
+
# @return [Array] List of applications with meta method
|
84
|
+
def filter(filter)
|
85
|
+
response = @api.request(:get, "apps?filter=#{filter}")
|
86
|
+
parse_with_meta(response, "//data/apps/element")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|