active_cmis 0.1.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/.gitignore +3 -0
- data/LICENSE +26 -0
- data/README.md +30 -0
- data/Rakefile +36 -0
- data/TODO +8 -0
- data/VERSION +1 -0
- data/lib/active_cmis.rb +27 -0
- data/lib/active_cmis/acl.rb +181 -0
- data/lib/active_cmis/acl_entry.rb +26 -0
- data/lib/active_cmis/active_cmis.rb +74 -0
- data/lib/active_cmis/atomic_types.rb +232 -0
- data/lib/active_cmis/attribute_prefix.rb +35 -0
- data/lib/active_cmis/collection.rb +175 -0
- data/lib/active_cmis/document.rb +314 -0
- data/lib/active_cmis/exceptions.rb +82 -0
- data/lib/active_cmis/folder.rb +21 -0
- data/lib/active_cmis/internal/caching.rb +86 -0
- data/lib/active_cmis/internal/connection.rb +171 -0
- data/lib/active_cmis/internal/utils.rb +69 -0
- data/lib/active_cmis/ns.rb +18 -0
- data/lib/active_cmis/object.rb +543 -0
- data/lib/active_cmis/policy.rb +13 -0
- data/lib/active_cmis/property_definition.rb +175 -0
- data/lib/active_cmis/rel.rb +17 -0
- data/lib/active_cmis/relationship.rb +47 -0
- data/lib/active_cmis/rendition.rb +65 -0
- data/lib/active_cmis/repository.rb +258 -0
- data/lib/active_cmis/server.rb +88 -0
- data/lib/active_cmis/type.rb +193 -0
- metadata +110 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
module ActiveCMIS
|
2
|
+
# A class used to get and set attributes that have a prefix like cmis: in their attribute IDs
|
3
|
+
class AttributePrefix
|
4
|
+
# @return [Object] The object that the attribute getting and setting will take place on
|
5
|
+
attr_reader :object
|
6
|
+
# @return [String]
|
7
|
+
attr_reader :prefix
|
8
|
+
|
9
|
+
# @private
|
10
|
+
def initialize(object, prefix)
|
11
|
+
@object = object
|
12
|
+
@prefix = prefix
|
13
|
+
end
|
14
|
+
|
15
|
+
# For known attributes will act as a getter and setter
|
16
|
+
def method_missing(method, *parameters)
|
17
|
+
string = method.to_s
|
18
|
+
if string[-1] == ?=
|
19
|
+
assignment = true
|
20
|
+
string = string[0..-2]
|
21
|
+
end
|
22
|
+
attribute = "#{prefix}:#{string}"
|
23
|
+
if object.class.attributes.keys.include? attribute
|
24
|
+
if assignment
|
25
|
+
object.update(attribute => parameters.first)
|
26
|
+
else
|
27
|
+
object.attribute(attribute)
|
28
|
+
end
|
29
|
+
else
|
30
|
+
# TODO: perhaps here we should try to look a bit further to see if there is a second :
|
31
|
+
super
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
module ActiveCMIS
|
2
|
+
|
3
|
+
# A Collection represents an atom feed, and can be used to lazily load data through paging
|
4
|
+
class Collection
|
5
|
+
include Internal::Caching
|
6
|
+
include ::Enumerable
|
7
|
+
|
8
|
+
# The repository that contains this feed
|
9
|
+
# @return [Repository]
|
10
|
+
attr_reader :repository
|
11
|
+
# The basic link that represents the beginning of this feed
|
12
|
+
# @return [URI]
|
13
|
+
attr_reader :url
|
14
|
+
|
15
|
+
def initialize(repository, url, first_page = nil, &map_entry)
|
16
|
+
@repository = repository
|
17
|
+
@url = URI.parse(url)
|
18
|
+
|
19
|
+
@next = @url
|
20
|
+
@elements = []
|
21
|
+
@pages = []
|
22
|
+
|
23
|
+
@map_entry = map_entry || Proc.new do |e|
|
24
|
+
ActiveCMIS::Object.from_atom_entry(repository, e)
|
25
|
+
end
|
26
|
+
|
27
|
+
if first_page
|
28
|
+
@next = first_page.xpath("at:feed/at:link[@rel = 'next']/@href", NS::COMBINED).first
|
29
|
+
@pages[0] = first_page
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Integer] The length of the collection
|
34
|
+
def length
|
35
|
+
receive_page
|
36
|
+
if @length.nil?
|
37
|
+
i = 1
|
38
|
+
while @next
|
39
|
+
receive_page
|
40
|
+
i += 1
|
41
|
+
end
|
42
|
+
@elements.length
|
43
|
+
else
|
44
|
+
@length
|
45
|
+
end
|
46
|
+
end
|
47
|
+
alias size length
|
48
|
+
cache :length
|
49
|
+
|
50
|
+
def empty?
|
51
|
+
at(0)
|
52
|
+
@elements.empty?
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return [Array]
|
56
|
+
def to_a
|
57
|
+
while @next
|
58
|
+
receive_page
|
59
|
+
end
|
60
|
+
@elements
|
61
|
+
end
|
62
|
+
|
63
|
+
def at(index)
|
64
|
+
index = sanitize_index(index)
|
65
|
+
if index < @elements.length
|
66
|
+
@elements[index]
|
67
|
+
elsif index > length
|
68
|
+
nil
|
69
|
+
else
|
70
|
+
while @next && @elements.length < index
|
71
|
+
receive_page
|
72
|
+
end
|
73
|
+
@elements[index]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def [](index, length = nil)
|
78
|
+
if length
|
79
|
+
index = sanitize_index(index)
|
80
|
+
range_get(index, index + length - 1)
|
81
|
+
elsif Range === index
|
82
|
+
range_get(sanitize_index(index.begin), index.exclude_end? ? sanitize_index(index.end) - 1 : sanitize_index(index.end))
|
83
|
+
else
|
84
|
+
at(index)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
alias slice []
|
88
|
+
|
89
|
+
def first
|
90
|
+
at(0)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Gets all object and returns last
|
94
|
+
def last
|
95
|
+
at(-1)
|
96
|
+
end
|
97
|
+
|
98
|
+
# @return [Array]
|
99
|
+
def each
|
100
|
+
length.times { |i| yield self[i] }
|
101
|
+
end
|
102
|
+
|
103
|
+
# @return [Array]
|
104
|
+
def reverse_each
|
105
|
+
(length - 1).downto(0) { |i| yield self[i] }
|
106
|
+
end
|
107
|
+
|
108
|
+
# @return [String]
|
109
|
+
def inspect
|
110
|
+
"#<Collection %s>" % url
|
111
|
+
end
|
112
|
+
|
113
|
+
# @return [String]
|
114
|
+
def to_s
|
115
|
+
to_a.to_s
|
116
|
+
end
|
117
|
+
|
118
|
+
# @return [Array]
|
119
|
+
def uniq
|
120
|
+
to_a.uniq
|
121
|
+
end
|
122
|
+
|
123
|
+
# @return [Array]
|
124
|
+
def sort
|
125
|
+
to_a.sort
|
126
|
+
end
|
127
|
+
|
128
|
+
# @return [Array]
|
129
|
+
def reverse
|
130
|
+
to_a.reverse
|
131
|
+
end
|
132
|
+
|
133
|
+
# @return [void]
|
134
|
+
def reload
|
135
|
+
@pages = []
|
136
|
+
@elements = []
|
137
|
+
@next = @url
|
138
|
+
__reload
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def sanitize_index(index)
|
144
|
+
index < 0 ? size + index : index
|
145
|
+
end
|
146
|
+
|
147
|
+
def range_get(from, to)
|
148
|
+
(from..to).map { |i| at(i) }
|
149
|
+
end
|
150
|
+
|
151
|
+
def receive_page(i = nil)
|
152
|
+
i ||= @pages.length
|
153
|
+
@pages[i] ||= begin
|
154
|
+
return nil unless @next
|
155
|
+
xml = conn.get_xml(@next)
|
156
|
+
|
157
|
+
@next = xml.xpath("at:feed/at:link[@rel = 'next']/@href", NS::COMBINED).first
|
158
|
+
@next = @next.nil? ? nil : @next.text
|
159
|
+
|
160
|
+
new_elements = xml.xpath('at:feed/at:entry', NS::COMBINED).map &@map_entry
|
161
|
+
@elements.concat(new_elements)
|
162
|
+
|
163
|
+
num_items = xml.xpath("at:feed/cra:numItems", NS::COMBINED).first
|
164
|
+
@length ||= num_items.text.to_i if num_items # We could also test on the repository
|
165
|
+
|
166
|
+
xml
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def conn
|
171
|
+
repository.conn
|
172
|
+
end
|
173
|
+
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,314 @@
|
|
1
|
+
module ActiveCMIS
|
2
|
+
class Document < ActiveCMIS::Object
|
3
|
+
# Returns an ActiveCMIS::Rendition to the content stream or nil if there is none
|
4
|
+
# @return [Rendition]
|
5
|
+
def content_stream
|
6
|
+
if content = data.xpath("at:content", NS::COMBINED).first
|
7
|
+
if content['src']
|
8
|
+
ActiveCMIS::Rendition.new(repository, "href" => content['src'], "type" => content["type"])
|
9
|
+
else
|
10
|
+
if content['type'] =~ /\+xml$/
|
11
|
+
content_data = content.to_xml # FIXME: this may not preserve whitespace
|
12
|
+
else
|
13
|
+
content_data = data.unpack("m*").first
|
14
|
+
end
|
15
|
+
ActiveCMIS::Rendition.new(repository, "data" => content_data, "type" => content["type"])
|
16
|
+
end
|
17
|
+
elsif content = data.xpath("cra:content", NS::COMBINED).first
|
18
|
+
content.children.each do |node|
|
19
|
+
next unless node.namespace and node.namespace.href == NS::CMIS_REST
|
20
|
+
content_data = node.text if node.name == "base64"
|
21
|
+
content_type = node.text if node.name == "mediaType"
|
22
|
+
end
|
23
|
+
data = content_data.unpack("m*").first
|
24
|
+
ActiveCMIS::Rendition.new(repository, "data" => content_data, "type" => content_type)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
cache :content_stream
|
28
|
+
|
29
|
+
# Will reload if renditionFilter was not set or cmis:none, but not in other circumstances
|
30
|
+
# @return [Array<Rendition>]
|
31
|
+
def renditions
|
32
|
+
filter = used_parameters["renditionFilter"]
|
33
|
+
if filter.nil? || filter == "cmis:none"
|
34
|
+
reload
|
35
|
+
end
|
36
|
+
|
37
|
+
links = data.xpath("at:link[@rel = 'alternate']", NS::COMBINED)
|
38
|
+
links.map do |link|
|
39
|
+
ActiveCMIS::Rendition.new(repository, link)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
cache :renditions
|
43
|
+
|
44
|
+
# Sets new content to be uploaded, does not alter values you will get from content_stream (for the moment)
|
45
|
+
# @param [Hash] options A hash containing exactly one of :file or :data
|
46
|
+
# @option options [String] :file The name of a file to upload
|
47
|
+
# @option options [#read] :data Data you want to upload (if #length is defined it should give the total length that can be read)
|
48
|
+
# @option options [Boolean] :overwrite (true) Whether the contents should be overwritten (ignored in case of checkin)
|
49
|
+
# @option options [String] :mime_type
|
50
|
+
#
|
51
|
+
# @return [void]
|
52
|
+
def set_content_stream(options)
|
53
|
+
if key.nil?
|
54
|
+
if self.class.content_stream_allowed == "notallowed"
|
55
|
+
raise Error::StreamNotSupported.new("Documents of this type can't have content")
|
56
|
+
end
|
57
|
+
else
|
58
|
+
updatability = repository.capabilities["ContentStreamUpdatability"]
|
59
|
+
if updatability == "none"
|
60
|
+
raise Error::NotSupported.new("Content can't be updated in this repository")
|
61
|
+
elsif updatability == "pwconly" && !working_copy?
|
62
|
+
raise Error::Constraint.new("Content can only be updated for working copies in this repository")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
@updated_contents = options
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns all documents in the version series of this document.
|
69
|
+
# Uses self to represent the version of this document
|
70
|
+
# @return [Collection<Document>, Array(self)]
|
71
|
+
def versions
|
72
|
+
link = data.xpath("at:link[@rel = 'version-history']/@href", NS::COMBINED)
|
73
|
+
if link = link.first
|
74
|
+
Collection.new(repository, link) # Problem: does not in fact use self
|
75
|
+
else
|
76
|
+
# The document is not versionable
|
77
|
+
[self]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
cache :versions
|
81
|
+
|
82
|
+
# Returns self if this is the latest version
|
83
|
+
# Note: There will allways be a latest version in a version series
|
84
|
+
# @return [Document]
|
85
|
+
def latest_version
|
86
|
+
link = data.xpath("at:link[@rel = 'current-version']/@href", NS::COMBINED)
|
87
|
+
if link.first
|
88
|
+
entry = conn.get_atom_entry(link.first.text)
|
89
|
+
self_or_new(entry)
|
90
|
+
else
|
91
|
+
# FIXME: should somehow return the current version even for opencmis
|
92
|
+
self
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns self if this is the working copy
|
97
|
+
# Returns nil if there is no working copy
|
98
|
+
# @return [Document]
|
99
|
+
def working_copy
|
100
|
+
link = data.xpath("at:link[@rel = 'working-copy']/@href", NS::COMBINED)
|
101
|
+
if link.first
|
102
|
+
entry = conn.get_atom_entry(link.first.text)
|
103
|
+
self_or_new(entry)
|
104
|
+
else
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def latest?
|
110
|
+
attributes["cmis:isLatestVersion"]
|
111
|
+
end
|
112
|
+
def major?
|
113
|
+
attributes["cmis:isMajorVersion"]
|
114
|
+
end
|
115
|
+
def latest_major?
|
116
|
+
attributes["cmis:isLatestMajorVersion"]
|
117
|
+
end
|
118
|
+
|
119
|
+
def working_copy?
|
120
|
+
return false if key.nil?
|
121
|
+
|
122
|
+
# NOTE: This may not be a sufficient condition, but according to the spec it should be
|
123
|
+
!data.xpath("at:link[@rel = 'via']", NS::COMBINED).empty?
|
124
|
+
end
|
125
|
+
|
126
|
+
# Returns information about the checked out status of this document
|
127
|
+
#
|
128
|
+
# @return [Hash,nil] Keys are :by for the owner of the PWC and :id for the CMIS ID, both can be unset according to the spec
|
129
|
+
def version_series_checked_out
|
130
|
+
attributes = self.attributes
|
131
|
+
if attributes["cmis:isVersionSeriesCheckedOut"]
|
132
|
+
result = {}
|
133
|
+
if attributes.has_key? "cmis:versionSeriesCheckedOutBy"
|
134
|
+
result[:by] = attributes["cmis:versionSeriesCheckedOutBy"]
|
135
|
+
end
|
136
|
+
if attributes.has_key? "cmis:versionSeriesCheckedOutId"
|
137
|
+
result[:id] = attributes["cmis:versionSeriesCheckedOutId"]
|
138
|
+
end
|
139
|
+
result
|
140
|
+
else
|
141
|
+
nil
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# The checkout operation results in a Private Working Copy
|
146
|
+
#
|
147
|
+
# Most properties should be the same as for the document that was checked out,
|
148
|
+
# certain properties may differ such as cmis:objectId and cmis:creationDate.
|
149
|
+
#
|
150
|
+
# The content stream of the PWC may be identical to that of the document
|
151
|
+
# that was checked out, or it may be unset.
|
152
|
+
# @return [Document] The checked out version of this document
|
153
|
+
def checkout
|
154
|
+
body = render_atom_entry(self.class.attributes.reject {|k,v| k != "cmis:objectId"})
|
155
|
+
|
156
|
+
response = conn.post_response(repository.checkedout.url, body)
|
157
|
+
if 200 <= response.code.to_i && response.code.to_i < 300
|
158
|
+
entry = Nokogiri::XML.parse(response.body).xpath("/at:entry", NS::COMBINED)
|
159
|
+
result = self_or_new(entry)
|
160
|
+
if result.working_copy? # Work around a bug in OpenCMIS where result returned is the version checked out not the PWC
|
161
|
+
result
|
162
|
+
else
|
163
|
+
conn.logger.warn "Repository did not return working copy for checkout operation"
|
164
|
+
result.working_copy
|
165
|
+
end
|
166
|
+
else
|
167
|
+
raise response.body
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# This action may not be permitted (query allowable_actions to see whether it is permitted)
|
172
|
+
# @return [void]
|
173
|
+
def cancel_checkout
|
174
|
+
if !self.class.versionable
|
175
|
+
raise Error::Constraint.new("Object is not versionable, can't cancel checkout")
|
176
|
+
elsif working_copy?
|
177
|
+
conn.delete(self_link)
|
178
|
+
else
|
179
|
+
raise Error::InvalidArgument.new("Not a working copy")
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# You can specify whether the new version should be major (defaults to true)
|
184
|
+
# You can optionally give a list of attributes that need to be set.
|
185
|
+
#
|
186
|
+
# This operation exists only for Private Working Copies
|
187
|
+
# @return [Document] The final version that results from the checkin
|
188
|
+
def checkin(major = true, comment = "", updated_attributes = {})
|
189
|
+
if working_copy?
|
190
|
+
update(updated_attributes)
|
191
|
+
result = self
|
192
|
+
updated_aspects([true, major, comment]).each do |hash|
|
193
|
+
result = result.send(hash[:message], *hash[:parameters])
|
194
|
+
end
|
195
|
+
result
|
196
|
+
else
|
197
|
+
raise "Not a working copy"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# @return [void]
|
202
|
+
def reload
|
203
|
+
@updated_contents = nil
|
204
|
+
super
|
205
|
+
end
|
206
|
+
|
207
|
+
private
|
208
|
+
attr_reader :updated_contents
|
209
|
+
|
210
|
+
# Optional parameters:
|
211
|
+
# - properties: a hash key/definition pairs of properties to be rendered (defaults to all attributes)
|
212
|
+
# - attributes: a hash key/value pairs used to determine the values rendered (defaults to self.attributes)
|
213
|
+
def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
|
214
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
215
|
+
xml.entry(NS::COMBINED) do
|
216
|
+
xml.parent.namespace = xml.parent.namespace_definitions.detect {|ns| ns.prefix == "at"}
|
217
|
+
xml["at"].author do
|
218
|
+
xml["at"].name conn.user # FIXME: find reliable way to set author?
|
219
|
+
end
|
220
|
+
if updated_contents && (options[:create] || options[:checkin])
|
221
|
+
xml["cra"].content do
|
222
|
+
xml["cra"].mediatype(updated_contents[:mime_type] || "application/binary")
|
223
|
+
data = updated_contents[:data] || File.read(updated_contents[:file])
|
224
|
+
xml["cra"].base64 [data].pack("m")
|
225
|
+
end
|
226
|
+
end
|
227
|
+
xml["cra"].object do
|
228
|
+
xml["c"].properties do
|
229
|
+
properties.each do |key, definition|
|
230
|
+
definition.render_property(xml, attributes[key])
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
conn.logger.debug builder.to_xml
|
237
|
+
builder.to_xml
|
238
|
+
end
|
239
|
+
|
240
|
+
|
241
|
+
def updated_aspects(checkin = nil)
|
242
|
+
if working_copy? && !(checkin || repository.pwc_ubdatable)
|
243
|
+
raise Error::NotSupported.new("Updating a PWC without checking in is not supported by repository")
|
244
|
+
end
|
245
|
+
unless working_copy? || checkin.nil?
|
246
|
+
raise Error::NotSupported.new("Can't check in when not checked out")
|
247
|
+
end
|
248
|
+
|
249
|
+
result = super
|
250
|
+
|
251
|
+
unless checkin || key.nil? || updated_contents.nil?
|
252
|
+
# Don't set content_stream separately if it can be done by setting the content during create
|
253
|
+
#
|
254
|
+
# TODO: For checkin we could try to see if we can save it via puts *before* we checkin,
|
255
|
+
# If not checking in we should also try to see if we can actually save it
|
256
|
+
result << {:message => :save_content_stream, :parameters => [updated_contents]}
|
257
|
+
end
|
258
|
+
|
259
|
+
result
|
260
|
+
end
|
261
|
+
|
262
|
+
def self_or_new(entry)
|
263
|
+
if entry.nil?
|
264
|
+
nil
|
265
|
+
elsif entry.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
|
266
|
+
reload
|
267
|
+
@data = entry
|
268
|
+
self
|
269
|
+
else
|
270
|
+
ActiveCMIS::Object.from_atom_entry(repository, entry)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def create_url
|
275
|
+
if f = parent_folders.first
|
276
|
+
url = f.items.url
|
277
|
+
if self.class.versionable # Necessary in OpenCMIS at least
|
278
|
+
url
|
279
|
+
else
|
280
|
+
Internal::Utils.append_parameters(url, "versioningState" => "none")
|
281
|
+
end
|
282
|
+
else
|
283
|
+
raise Error::NotSupported.new("Creating an unfiled document is not supported by CMIS")
|
284
|
+
# Can't create documents that are unfiled, CMIS does not support it (note this means exceptions should not actually be NotSupported)
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
def save_content_stream(stream)
|
289
|
+
# Should never occur (is private method)
|
290
|
+
raise "no content to save" if stream.nil?
|
291
|
+
|
292
|
+
# put to link with rel 'edit-media' if it's there
|
293
|
+
# NOTE: cmislib uses the src link of atom:content instead, that might be correct
|
294
|
+
edit_links = Internal::Utils.extract_links(data, "edit-media")
|
295
|
+
if edit_links.length == 1
|
296
|
+
link = edit_links.first
|
297
|
+
elsif edit_links.empty?
|
298
|
+
raise Error.new("No edit-media link, can't save content")
|
299
|
+
else
|
300
|
+
raise Error.new("Too many edit-media links, don't know how to choose")
|
301
|
+
end
|
302
|
+
data = stream[:data] || File.open(stream[:file])
|
303
|
+
content_type = stream[:mime_type] || "application/octet-stream"
|
304
|
+
|
305
|
+
if stream.has_key?(:overwrite)
|
306
|
+
url = Internal::Utils.append_parameters(link, "overwrite" => stream[:overwrite])
|
307
|
+
else
|
308
|
+
url = link
|
309
|
+
end
|
310
|
+
conn.put(url, data, "Content-Type" => content_type)
|
311
|
+
self
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|