cmis_active 0.3.7
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/AUTHORS +9 -0
- data/LICENSE +26 -0
- data/README.md +55 -0
- data/Rakefile +34 -0
- data/TODO +7 -0
- data/VERSION.yml +5 -0
- data/active_cmis.gemspec +79 -0
- data/lib/active_cmis.rb +30 -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 +87 -0
- data/lib/active_cmis/atomic_types.rb +245 -0
- data/lib/active_cmis/attribute_prefix.rb +35 -0
- data/lib/active_cmis/collection.rb +206 -0
- data/lib/active_cmis/document.rb +356 -0
- data/lib/active_cmis/exceptions.rb +82 -0
- data/lib/active_cmis/folder.rb +36 -0
- data/lib/active_cmis/internal/caching.rb +86 -0
- data/lib/active_cmis/internal/connection.rb +241 -0
- data/lib/active_cmis/internal/utils.rb +82 -0
- data/lib/active_cmis/ns.rb +18 -0
- data/lib/active_cmis/object.rb +563 -0
- data/lib/active_cmis/policy.rb +13 -0
- data/lib/active_cmis/property_definition.rb +179 -0
- data/lib/active_cmis/query_result.rb +40 -0
- data/lib/active_cmis/rel.rb +17 -0
- data/lib/active_cmis/relationship.rb +49 -0
- data/lib/active_cmis/rendition.rb +86 -0
- data/lib/active_cmis/repository.rb +327 -0
- data/lib/active_cmis/server.rb +121 -0
- data/lib/active_cmis/type.rb +200 -0
- data/lib/active_cmis/version.rb +10 -0
- metadata +132 -0
@@ -0,0 +1,356 @@
|
|
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, self, "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, self, "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, self, "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, self, 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
|
+
# Sets versioning state.
|
69
|
+
# Only possible on new documents, or PWC documents
|
70
|
+
#
|
71
|
+
# @param ["major", "minor", "none", "checkedout"] state A string of the desired versioning state
|
72
|
+
#
|
73
|
+
# @return [void]
|
74
|
+
def set_versioning_state(state)
|
75
|
+
raise Error::Constraint.new("Can only set a different version state on PWC documents, or unsaved new documents") unless key.nil? || working_copy?
|
76
|
+
raise ArgumentError, "You must pass a String" unless state.is_a?(String)
|
77
|
+
if key.nil?
|
78
|
+
possible_values = %w[major minor none checkedout]
|
79
|
+
else
|
80
|
+
possible_values = %w[major minor]
|
81
|
+
end
|
82
|
+
raise ArgumentError, "Given state is invalid. Possible values are #{possible_values.join(", ")}" unless possible_values.include?(state)
|
83
|
+
|
84
|
+
@versioning_state = state
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns all documents in the version series of this document.
|
88
|
+
# Uses self to represent the version of this document
|
89
|
+
# @return [Collection<Document>, Array(self)]
|
90
|
+
def versions
|
91
|
+
link = data.xpath("at:link[@rel = 'version-history']/@href", NS::COMBINED)
|
92
|
+
if link = link.first
|
93
|
+
Collection.new(repository, link) # Problem: does not in fact use self
|
94
|
+
else
|
95
|
+
# The document is not versionable
|
96
|
+
[self]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
cache :versions
|
100
|
+
|
101
|
+
# Returns self if this is the latest version
|
102
|
+
# Note: There will allways be a latest version in a version series
|
103
|
+
# @return [Document]
|
104
|
+
def latest_version
|
105
|
+
link = data.xpath("at:link[@rel = 'current-version']/@href", NS::COMBINED)
|
106
|
+
if link.first
|
107
|
+
entry = conn.get_atom_entry(link.first.text)
|
108
|
+
self_or_new(entry)
|
109
|
+
else
|
110
|
+
# FIXME: should somehow return the current version even for opencmis
|
111
|
+
self
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Returns self if this is the working copy
|
116
|
+
# Returns nil if there is no working copy
|
117
|
+
# @return [Document]
|
118
|
+
def working_copy
|
119
|
+
link = data.xpath("at:link[@rel = 'working-copy']/@href", NS::COMBINED)
|
120
|
+
if link.first
|
121
|
+
entry = conn.get_atom_entry(link.first.text)
|
122
|
+
self_or_new(entry)
|
123
|
+
else
|
124
|
+
nil
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def latest?
|
129
|
+
attributes["cmis:isLatestVersion"]
|
130
|
+
end
|
131
|
+
def major?
|
132
|
+
attributes["cmis:isMajorVersion"]
|
133
|
+
end
|
134
|
+
def latest_major?
|
135
|
+
attributes["cmis:isLatestMajorVersion"]
|
136
|
+
end
|
137
|
+
|
138
|
+
def working_copy?
|
139
|
+
return false if key.nil?
|
140
|
+
|
141
|
+
# NOTE: This may not be a sufficient condition, but according to the spec it should be
|
142
|
+
!data.xpath("at:link[@rel = 'via']", NS::COMBINED).empty?
|
143
|
+
end
|
144
|
+
|
145
|
+
# Returns information about the checked out status of this document
|
146
|
+
#
|
147
|
+
# @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
|
148
|
+
def version_series_checked_out
|
149
|
+
attributes = self.attributes
|
150
|
+
if attributes["cmis:isVersionSeriesCheckedOut"]
|
151
|
+
result = {}
|
152
|
+
if attributes.has_key? "cmis:versionSeriesCheckedOutBy"
|
153
|
+
result[:by] = attributes["cmis:versionSeriesCheckedOutBy"]
|
154
|
+
end
|
155
|
+
if attributes.has_key? "cmis:versionSeriesCheckedOutId"
|
156
|
+
result[:id] = attributes["cmis:versionSeriesCheckedOutId"]
|
157
|
+
end
|
158
|
+
result
|
159
|
+
else
|
160
|
+
nil
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# The checkout operation results in a Private Working Copy
|
165
|
+
#
|
166
|
+
# Most properties should be the same as for the document that was checked out,
|
167
|
+
# certain properties may differ such as cmis:objectId and cmis:creationDate.
|
168
|
+
#
|
169
|
+
# The content stream of the PWC may be identical to that of the document
|
170
|
+
# that was checked out, or it may be unset.
|
171
|
+
# @return [Document] The checked out version of this document
|
172
|
+
def checkout
|
173
|
+
body = render_atom_entry(self.class.attributes.reject {|k,v| k != "cmis:objectId"})
|
174
|
+
|
175
|
+
response = conn.post_response(repository.checkedout.url, body)
|
176
|
+
if 200 <= response.code.to_i && response.code.to_i < 300
|
177
|
+
entry = Nokogiri::XML.parse(response.body, nil, nil, Nokogiri::XML::ParseOptions::STRICT).xpath("/at:entry", NS::COMBINED)
|
178
|
+
result = self_or_new(entry)
|
179
|
+
if result.working_copy? # Work around a bug in OpenCMIS where result returned is the version checked out not the PWC
|
180
|
+
result
|
181
|
+
else
|
182
|
+
conn.logger.warn "Repository did not return working copy for checkout operation"
|
183
|
+
result.working_copy
|
184
|
+
end
|
185
|
+
else
|
186
|
+
raise response.body
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# This action may not be permitted (query allowable_actions to see whether it is permitted)
|
191
|
+
# @return [void]
|
192
|
+
def cancel_checkout
|
193
|
+
if !self.class.versionable
|
194
|
+
raise Error::Constraint.new("Object is not versionable, can't cancel checkout")
|
195
|
+
elsif working_copy?
|
196
|
+
conn.delete(self_link)
|
197
|
+
else
|
198
|
+
raise Error::InvalidArgument.new("Not a working copy")
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# @overload checkin(major, comment = "", updated_attributes = {})
|
203
|
+
# Check in the private working copy. Raises a constraint error when the
|
204
|
+
# document is not a working copy
|
205
|
+
#
|
206
|
+
# @param [Boolean] major Whether the document will be checked in as a major version
|
207
|
+
# @param [String] comment An optional comment to use when creating the new version
|
208
|
+
# @param [Hash] updated_attributes A hash with updated attributes
|
209
|
+
# @return [Document] The final version that results from the checkin
|
210
|
+
# @overload checkin(comment = "", updated_attributes = {})
|
211
|
+
# Check in the private working copy. Raises a constraint error when the
|
212
|
+
# document is not a working copy
|
213
|
+
# The version will be the version set by set_versioning_state (default is
|
214
|
+
# a major version)
|
215
|
+
#
|
216
|
+
# @param [String] comment An optional comment to use when creating the new version
|
217
|
+
# @param [Hash] updated_attributes A hash with updated attributes
|
218
|
+
# @return [Document] The final version that results from the checkin
|
219
|
+
# @overload checkin(updated_attributes = {})
|
220
|
+
# Check in the private working copy with an empty message.
|
221
|
+
# Raises a constraint error when the document is not a working copy
|
222
|
+
# The version will be the version set by set_versioning_state (default is
|
223
|
+
# a major version)
|
224
|
+
#
|
225
|
+
# @param [Hash] updated_attributes A hash with updated attributes
|
226
|
+
# @return [Document] The final version that results from the checkin
|
227
|
+
def checkin(*options)
|
228
|
+
if options.length > 3
|
229
|
+
raise ArgumentError, "Too many arguments for checkin"
|
230
|
+
else
|
231
|
+
major, comment, updated_attributes = *options
|
232
|
+
if TrueClass === major or FalseClass === major
|
233
|
+
# Nothing changes: only defaults need to be filled in (if necessary)
|
234
|
+
elsif String === major
|
235
|
+
updated_attributes = comment
|
236
|
+
comment = major
|
237
|
+
# major will be true if: @versioning_state == "major", or if it's not set
|
238
|
+
major = @versioning_state != "minor"
|
239
|
+
elsif Hash === major
|
240
|
+
updated_attributes = major
|
241
|
+
major = @versioning_state != "minor"
|
242
|
+
end
|
243
|
+
comment ||= ""
|
244
|
+
updated_attributes ||= {}
|
245
|
+
end
|
246
|
+
|
247
|
+
if working_copy?
|
248
|
+
update(updated_attributes)
|
249
|
+
result = self
|
250
|
+
updated_aspects([true, major, comment]).each do |hash|
|
251
|
+
result = result.send(hash[:message], *hash[:parameters])
|
252
|
+
end
|
253
|
+
@versioning_state = nil
|
254
|
+
result
|
255
|
+
else
|
256
|
+
raise Error::Constraint, "Not a working copy"
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# @return [void]
|
261
|
+
def reload
|
262
|
+
@updated_contents = nil
|
263
|
+
super
|
264
|
+
end
|
265
|
+
|
266
|
+
private
|
267
|
+
attr_reader :updated_contents
|
268
|
+
|
269
|
+
def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
|
270
|
+
super(properties, attributes, options) do |entry|
|
271
|
+
if updated_contents && (options[:create] || options[:checkin])
|
272
|
+
entry["cra"].content do
|
273
|
+
entry["cra"].mediatype(updated_contents[:mime_type] || "application/binary")
|
274
|
+
data = updated_contents[:data] || File.read(updated_contents[:file])
|
275
|
+
entry["cra"].base64 [data].pack("m")
|
276
|
+
end
|
277
|
+
end
|
278
|
+
if block_given?
|
279
|
+
yield(entry)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
|
285
|
+
def updated_aspects(checkin = nil)
|
286
|
+
if working_copy? && !(checkin || repository.pwc_updatable?)
|
287
|
+
raise Error::NotSupported.new("Updating a PWC without checking in is not supported by repository")
|
288
|
+
end
|
289
|
+
unless working_copy? || checkin.nil?
|
290
|
+
raise Error::NotSupported.new("Can't check in when not checked out")
|
291
|
+
end
|
292
|
+
|
293
|
+
result = super
|
294
|
+
|
295
|
+
if !key.nil? && !updated_contents.nil?
|
296
|
+
if checkin
|
297
|
+
# Update the content stream before checking in
|
298
|
+
result.unshift(:message => :save_content_stream, :parameters => [updated_contents])
|
299
|
+
else
|
300
|
+
# TODO: check that the content stream is updateable
|
301
|
+
result << {:message => :save_content_stream, :parameters => [updated_contents]}
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
result
|
306
|
+
end
|
307
|
+
|
308
|
+
def self_or_new(entry)
|
309
|
+
if entry.nil?
|
310
|
+
nil
|
311
|
+
elsif entry.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
|
312
|
+
reload
|
313
|
+
@data = entry
|
314
|
+
self
|
315
|
+
else
|
316
|
+
ActiveCMIS::Object.from_atom_entry(repository, entry)
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
def create_url
|
321
|
+
if f = parent_folders.first
|
322
|
+
url = f.items.url
|
323
|
+
Internal::Utils.append_parameters(url, "versioningState" => (self.class.versionable ? (@versioning_state || "major") : "none"))
|
324
|
+
else
|
325
|
+
raise Error::NotSupported.new("Creating an unfiled document is not supported by CMIS")
|
326
|
+
# Can't create documents that are unfiled, CMIS does not support it (note this means exceptions should not actually be NotSupported)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def save_content_stream(stream)
|
331
|
+
# Should never occur (is private method)
|
332
|
+
raise "no content to save" if stream.nil?
|
333
|
+
|
334
|
+
# put to link with rel 'edit-media' if it's there
|
335
|
+
# NOTE: cmislib uses the src link of atom:content instead, that might be correct
|
336
|
+
edit_links = Internal::Utils.extract_links(data, "edit-media")
|
337
|
+
if edit_links.length == 1
|
338
|
+
link = edit_links.first
|
339
|
+
elsif edit_links.empty?
|
340
|
+
raise Error.new("No edit-media link, can't save content")
|
341
|
+
else
|
342
|
+
raise Error.new("Too many edit-media links, don't know how to choose")
|
343
|
+
end
|
344
|
+
data = stream[:data] || File.open(stream[:file])
|
345
|
+
content_type = stream[:mime_type] || "application/octet-stream"
|
346
|
+
|
347
|
+
if stream.has_key?(:overwrite)
|
348
|
+
url = Internal::Utils.append_parameters(link, "overwrite" => stream[:overwrite])
|
349
|
+
else
|
350
|
+
url = link
|
351
|
+
end
|
352
|
+
conn.put(url, data, "Content-Type" => content_type)
|
353
|
+
self
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module ActiveCMIS
|
2
|
+
# The base class for all CMIS exceptions,
|
3
|
+
# HTTP communication errors and the like are not catched by this
|
4
|
+
class Error < StandardError
|
5
|
+
# === Cause
|
6
|
+
# One or more of the input parameters to the service method is missing or invalid
|
7
|
+
class InvalidArgument < Error; end
|
8
|
+
|
9
|
+
# === Cause
|
10
|
+
# The service call has specified an object that does not exist in the Repository
|
11
|
+
class ObjectNotFound < Error; end
|
12
|
+
|
13
|
+
# === Cause
|
14
|
+
# The service method invoked requires an optional capability not supported by the repository
|
15
|
+
class NotSupported < Error; end
|
16
|
+
|
17
|
+
# === Cause
|
18
|
+
# The caller of the service method does not have sufficient permissions to perform the operation
|
19
|
+
class PermissionDenied < Error; end
|
20
|
+
|
21
|
+
# === Cause
|
22
|
+
# Any cause not expressible by another CMIS exception
|
23
|
+
class Runtime < Error; end
|
24
|
+
|
25
|
+
# === Intent
|
26
|
+
# The operation violates a Repository- or Object-level constraint defined in the CMIS domain model
|
27
|
+
#
|
28
|
+
# === Methods
|
29
|
+
# see the CMIS specification
|
30
|
+
class Constraint < Error; end
|
31
|
+
# === Intent
|
32
|
+
# The operation attempts to set the content stream for a Document
|
33
|
+
# that already has a content stream without explicitly specifying the
|
34
|
+
# "overwriteFlag" parameter
|
35
|
+
#
|
36
|
+
# === Methods
|
37
|
+
# see the CMIS specification
|
38
|
+
class ContentAlreadyExists < Error; end
|
39
|
+
# === Intent
|
40
|
+
# The property filter or rendition filter input to the operation is not valid
|
41
|
+
#
|
42
|
+
# === Methods
|
43
|
+
# see the CMIS specification
|
44
|
+
class FilterNotValid < Error; end
|
45
|
+
# === Intent
|
46
|
+
# The repository is not able to store the object that the user is creating/updating due to a name constraint violation
|
47
|
+
#
|
48
|
+
# === Methods
|
49
|
+
# see the CMIS specification
|
50
|
+
class NameConstraintViolation < Error; end
|
51
|
+
# === Intent
|
52
|
+
# The repository is not able to store the object that the user is creating/updating due to an internal storage problam
|
53
|
+
#
|
54
|
+
# === Methods
|
55
|
+
# see the CMIS specification
|
56
|
+
class Storage < Error; end
|
57
|
+
# === Intent
|
58
|
+
#
|
59
|
+
#
|
60
|
+
# === Methods
|
61
|
+
# see the CMIS specification
|
62
|
+
class StreamNotSupported < Error; end
|
63
|
+
# === Intent
|
64
|
+
#
|
65
|
+
#
|
66
|
+
# === Methods
|
67
|
+
# see the CMIS specification
|
68
|
+
class UpdateConflict < Error; end
|
69
|
+
# === Intent
|
70
|
+
#
|
71
|
+
#
|
72
|
+
# === Methods
|
73
|
+
# see the CMIS specification
|
74
|
+
class Versioning < Error; end
|
75
|
+
end
|
76
|
+
|
77
|
+
class HTTPError < StandardError
|
78
|
+
class ServerError < HTTPError; end
|
79
|
+
class ClientError < HTTPError; end
|
80
|
+
class AuthenticationError < HTTPError; end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module ActiveCMIS
|
2
|
+
class Folder < ActiveCMIS::Object
|
3
|
+
# Returns a collection of all items contained in this folder (1 level deep)
|
4
|
+
# @return [Collection<Document,Folder,Policy>]
|
5
|
+
def items
|
6
|
+
item_feed = Internal::Utils.extract_links(data, 'down', 'application/atom+xml','type' => 'feed')
|
7
|
+
raise "No child feed link for folder" if item_feed.empty?
|
8
|
+
Collection.new(repository, item_feed.first)
|
9
|
+
end
|
10
|
+
cache :items
|
11
|
+
|
12
|
+
def allowed_object_types
|
13
|
+
if attributes["cmis:allowedChildObjectTypeIds"].empty?
|
14
|
+
repository.types.select { |type| type.fileable }
|
15
|
+
else
|
16
|
+
# TODO: it is repository specific if subtypes of the allowed types MAY be filed (line 976)
|
17
|
+
#
|
18
|
+
# There is as far as I can see no other mention of this possibility in the spec, no way to
|
19
|
+
# check if this is so for any specific repository. In addition there is in a few places a
|
20
|
+
# requirement that an error is thrown if the cmis:objectTypeId is not in the list of allowed
|
21
|
+
# values. So for now this is not supported at all.
|
22
|
+
attributes["cmis:allowedChildObjectTypeIds"].map { |type_id| repository.type_by_id(type_id) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
cache :allowed_object_types
|
26
|
+
|
27
|
+
private
|
28
|
+
def create_url
|
29
|
+
if f = parent_folders.first
|
30
|
+
f.items.url
|
31
|
+
else
|
32
|
+
raise "Not possible to create folder without parent folder"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|