timfel-active_cmis 0.3.1

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.
@@ -0,0 +1,563 @@
1
+ module ActiveCMIS
2
+ class Object
3
+ include Internal::Caching
4
+
5
+ # The repository that contains this object
6
+ # @return [Repository]
7
+ attr_reader :repository
8
+
9
+ # The cmis:objectId of the object, or nil if the document does not yet exist in the repository
10
+ # @return [String,nil]
11
+ attr_reader :key
12
+ alias id key
13
+
14
+ # Creates a representation of an CMIS Object in the repository
15
+ #
16
+ # Not meant for direct use, use {Repository#object_by_id} instead. To create a new object use the new method on the type that you want the new object to have.
17
+ #
18
+ # @param [Repository] repository The repository this object belongs to
19
+ # @param [Nokogiri::XML::Node,nil] data The preparsed XML Atom Entry or nil if the object does not yet exist
20
+ # @param [Hash] parameters A list of parameters used to get the Atom Entry
21
+ def initialize(repository, data, parameters)
22
+ @repository = repository
23
+ @data = data
24
+
25
+ @updated_attributes = []
26
+
27
+ if @data.nil?
28
+ # Creating a new type from scratch
29
+ raise Error::Constraint.new("This type is not creatable") unless self.class.creatable
30
+ @key = parameters["id"]
31
+ @allowable_actions = {}
32
+ @parent_folders = [] # start unlinked
33
+ else
34
+ @key = parameters["id"] || attribute('cmis:objectId')
35
+ @self_link = data.xpath("at:link[@rel = 'self']/@href", NS::COMBINED).first
36
+ @self_link = @self_link.text
37
+ end
38
+ @used_parameters = parameters
39
+ # FIXME: decide? parameters to use?? always same ? or parameter with reload ?
40
+ end
41
+
42
+ # Via method missing attribute accessors and setters are provided for the CMIS attributes of an object.
43
+ # If attributes have a colon in their name you can access them by changing the colon in a dot
44
+ #
45
+ # @example Set an attribute named DateTimePropMV
46
+ # my_object.DateTimePropMV = Time.now #=> "Wed Apr 07 14:34:19 0200 2010"
47
+ # @example Read the attribute named DateTimePropMV
48
+ # my_object.DateTimePropMV #=> "Wed Apr 07 14:34:19 0200 2010"
49
+ # @example Get the cmis:name of an object
50
+ # my_object.cmis.name #=> "My object 25"
51
+ def method_missing(method, *parameters)
52
+ string = method.to_s
53
+ if string[-1] == ?=
54
+ assignment = true
55
+ string = string[0..-2]
56
+ end
57
+ if attributes.keys.include? string
58
+ if assignment
59
+ update(string => parameters.first)
60
+ else
61
+ attribute(string)
62
+ end
63
+ elsif self.class.attribute_prefixes.include? string
64
+ if assignment
65
+ raise NotImplementedError.new("Mass assignment not yet supported to prefix")
66
+ else
67
+ @attribute_prefix ||= {}
68
+ @attribute_prefix[method] ||= AttributePrefix.new(self, string)
69
+ end
70
+ else
71
+ super
72
+ end
73
+ end
74
+
75
+ # @return [String]
76
+ def inspect
77
+ "#<#{self.class.inspect} @key=#{key}>"
78
+ end
79
+
80
+ # Shorthand for the cmis:name of an object
81
+ # @return [String]
82
+ def name
83
+ attribute('cmis:name')
84
+ end
85
+ cache :name
86
+
87
+ # A list of all attributes that have changed locally
88
+ # @return [Array<String>]
89
+ attr_reader :updated_attributes
90
+
91
+ # Attribute getter for the CMIS attributes of an object
92
+ # @param [String] name The property id of the attribute
93
+ def attribute(name)
94
+ attributes[name]
95
+ end
96
+
97
+ # Attribute getter for the CMIS attributes of an object
98
+ # @return [Hash{String => ::Object}] All attributes, the keys are the property ids of the attributes
99
+ def attributes
100
+ self.class.attributes.inject({}) do |hash, (key, attr)|
101
+ if data.nil?
102
+ if key == "cmis:objectTypeId"
103
+ hash[key] = self.class.id
104
+ else
105
+ hash[key] = nil
106
+ end
107
+ else
108
+ properties = data.xpath("cra:object/c:properties", NS::COMBINED)
109
+ values = attr.extract_property(properties)
110
+ hash[key] = if values.nil? || values.empty?
111
+ if attr.repeating
112
+ []
113
+ else
114
+ nil
115
+ end
116
+ elsif attr.repeating
117
+ values.map do |value|
118
+ attr.property_type.cmis2rb(value)
119
+ end
120
+ else
121
+ attr.property_type.cmis2rb(values.first)
122
+ end
123
+ end
124
+ hash
125
+ end
126
+ end
127
+ cache :attributes
128
+
129
+ # Attribute setter for all CMIS attributes. This only updates this copy of the object.
130
+ # Use save to make these changes permanent and visible in the repositorhy.
131
+ # (use {#reload} after save on other instances of this document to reflect these changes)
132
+ #
133
+ # @param [{String => ::Object}] attributes A hash with new values for selected attributes
134
+ # @raise [Error::Constraint] if a readonly attribute is set
135
+ # @raise if a value can't be converted to the necessary type or falls outside the constraints
136
+ # @return [{String => ::Object}] The updated attributes hash
137
+ def update(attributes)
138
+ attributes.each do |key, value|
139
+ if (property = self.class.attributes[key.to_s]).nil?
140
+ raise Error::Constraint.new("You are trying to add an unknown attribute (#{key})")
141
+ else
142
+ property.validate_ruby_value(value)
143
+ end
144
+ end
145
+ self.updated_attributes.concat(attributes.keys).uniq!
146
+ self.attributes.merge!(attributes)
147
+ end
148
+
149
+ # Saves all changes to the object in the repository.
150
+ #
151
+ # *WARNING*: because of the way CMIS is constructed the save operation is not atomic if updates happen to different aspects of the object
152
+ # (parent folders, attributes, content stream, acl), we can't work around this because CMIS lacks transactions
153
+ # @return [Object]
154
+ def save
155
+ # FIXME: find a way to handle errors?
156
+ # FIXME: what if multiple objects are created in the course of a save operation?
157
+ result = self
158
+ updated_aspects.each do |hash|
159
+ result = result.send(hash[:message], *hash[:parameters])
160
+ end
161
+ result
162
+ end
163
+
164
+ # @return [Hash{String => Boolean,String}] A hash containing all actions allowed on this object for the current user
165
+ def allowable_actions
166
+ actions = {}
167
+ _allowable_actions.children.map do |node|
168
+ actions[node.name.sub("can", "")] = case t = node.text
169
+ when "true", "1"; true
170
+ when "false", "0"; false
171
+ else t
172
+ end
173
+ end
174
+ actions
175
+ end
176
+ cache :allowable_actions
177
+
178
+ # Returns all relationships where this object is the target
179
+ # @return [Collection]
180
+ def target_relations
181
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:relationships]}']/@href"
182
+ link = data.xpath(query, NS::COMBINED)
183
+ if link.length == 1
184
+ link = Internal::Utils.append_parameters(link.text, "relationshipDirection" => "target", "includeSubRelationshipTypes" => true)
185
+ Collection.new(repository, link)
186
+ else
187
+ raise "Expected exactly 1 relationships link for #{key}, got #{link.length}, are you sure this is a document/folder?"
188
+ end
189
+ end
190
+ cache :target_relations
191
+
192
+ # Returns all relationships where this object is the source
193
+ # @return [Collection]
194
+ def source_relations
195
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:relationships]}']/@href"
196
+ link = data.xpath(query, NS::COMBINED)
197
+ if link.length == 1
198
+ link = Internal::Utils.append_parameters(link.text, "relationshipDirection" => "source", "includeSubRelationshipTypes" => true)
199
+ Collection.new(repository, link)
200
+ else
201
+ raise "Expected exactly 1 relationships link for #{key}, got #{link.length}, are you sure this is a document/folder?"
202
+ end
203
+ end
204
+ cache :source_relations
205
+
206
+ # @return [Acl,nil] The ACL of the document, if there is any at all
207
+ def acl
208
+ if repository.acls_readable? && allowable_actions["GetACL"]
209
+ # FIXME: actual query should perhaps look at CMIS version before deciding which relation is applicable?
210
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:acl]}']/@href"
211
+ link = data.xpath(query, NS::COMBINED)
212
+ if link.length == 1
213
+ Acl.new(repository, self, link.first.text, data.xpath("cra:object/c:acl", NS::COMBINED))
214
+ else
215
+ raise "Expected exactly 1 acl for #{key}, got #{link.length}"
216
+ end
217
+ end
218
+ end
219
+
220
+ # Depending on the repository there can be more than 1 parent folder
221
+ # Always returns [] for relationships, policies may also return []
222
+ #
223
+ # @return [Array<Folder>,Collection] The parent folders in an array or a collection
224
+ def parent_folders
225
+ parent_feed = Internal::Utils.extract_links(data, 'up', 'application/atom+xml','type' => 'feed')
226
+ unless parent_feed.empty?
227
+ Collection.new(repository, parent_feed.first)
228
+ else
229
+ parent_entry = Internal::Utils.extract_links(data, 'up', 'application/atom+xml','type' => 'entry')
230
+ unless parent_entry.empty?
231
+ e = conn.get_atom_entry(parent_entry.first)
232
+ [ActiveCMIS::Object.from_atom_entry(repository, e)]
233
+ else
234
+ []
235
+ end
236
+ end
237
+ end
238
+ cache :parent_folders
239
+
240
+ # Files an object in a folder, if the repository supports multi-filing this will be an additional folder, else it will replace the previous folder
241
+ #
242
+ # @param [Folder] folder The (replacement) folder
243
+ # @return [void]
244
+ def file(folder)
245
+ raise Error::Constraint.new("Filing not supported for objects of type: #{self.class.id}") unless self.class.fileable
246
+ @original_parent_folders ||= parent_folders.dup
247
+ if repository.capabilities["MultiFiling"]
248
+ @parent_folders << folder unless @parent_folders.detect {|f| f.id == folder.id }
249
+ else
250
+ @parent_folders = [folder]
251
+ end
252
+ end
253
+
254
+ # Removes an object from a given folder or all folders. If the repository does not support unfiling this method throws an error if the document would have no folders left after unfiling.
255
+ #
256
+ # @param [Folder,nil] folder
257
+ # @return [void]
258
+ def unfile(folder = nil)
259
+ # Conundrum: should this throw exception if folder is not actually among parent_folders?
260
+ raise Error::Constraint.new("Filing not supported for objects of type: #{self.class.id}") unless self.class.fileable
261
+ @original_parent_folders ||= parent_folders.dup
262
+ if repository.capabilities["UnFiling"]
263
+ if folder.nil?
264
+ @parent_folders = []
265
+ else
266
+ @parent_folders.delete_if {|f| f.id == folder.id}
267
+ end
268
+ else
269
+ @parent_folders.delete_if {|f| f.id == folder.id}
270
+ if @parent_folders.empty?
271
+ @parent_folders = @original_parent_folders
272
+ @original_parent_folders = nil
273
+ raise Error::NotSupported.new("Unfiling not supported for this repository")
274
+ end
275
+ end
276
+ end
277
+
278
+ # Empties the locally cached and updated values, updated data is asked from the server the next time a value is requested.
279
+ # @raise [RuntimeError] if the object is not yet created on the server
280
+ # @return [void]
281
+ def reload
282
+ if @self_link.nil?
283
+ raise "Can't reload unsaved object"
284
+ else
285
+ __reload
286
+ @updated_attributes = []
287
+ @original_parent_folders = nil
288
+ end
289
+ end
290
+
291
+ # Tries to delete the object
292
+ # To delete all versions of a Document try #all_versions.delete
293
+ #
294
+ # For policies this may just remove the policy from the policy group
295
+ # of a document, this depends on how you retrieved the policy. Be careful
296
+ def destroy
297
+ conn.delete(self_link)
298
+ end
299
+
300
+ private
301
+ # Internal value, not meant for common-day use
302
+ # @private
303
+ # @return [Hash]
304
+ attr_reader :used_parameters
305
+
306
+ def self_link(options = {})
307
+ url = @self_link
308
+ if options.empty?
309
+ url
310
+ else
311
+ Internal::Utils.append_parameters(url, options)
312
+ end
313
+ #repository.object_by_id_url(options.merge("id" => id))
314
+ end
315
+
316
+ def data
317
+ parameters = {"includeAllowableActions" => true, "renditionFilter" => "*", "includeACL" => true}
318
+ data = conn.get_atom_entry(self_link(parameters))
319
+ @used_parameters = parameters
320
+ data
321
+ end
322
+ cache :data
323
+
324
+ def conn
325
+ @repository.conn
326
+ end
327
+
328
+ def _allowable_actions
329
+ if actions = data.xpath('cra:object/c:allowableActions', NS::COMBINED).first
330
+ actions
331
+ else
332
+ links = data.xpath("at:link[@rel = '#{Rel[repository.cmis_version][:allowableactions]}']/@href", NS::COMBINED)
333
+ if link = links.first
334
+ conn.get_xml(link.text)
335
+ else
336
+ nil
337
+ end
338
+ end
339
+ end
340
+
341
+ # @param properties a hash key/definition pairs of properties to be rendered (defaults to all attributes)
342
+ # @param attributes a hash key/value pairs used to determine the values rendered (defaults to self.attributes)
343
+ # @param options
344
+ # @yield [entry] Optional block to customize the rendered atom entry
345
+ # @yieldparam [Nokogiri::XML::Builder] entry The entry XML builder element on which you can add additional tags (uses the NS::COMBINED namespaces)
346
+ def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
347
+ builder = Nokogiri::XML::Builder.new do |xml|
348
+ xml.entry(NS::COMBINED) do
349
+ xml.parent.namespace = xml.parent.namespace_definitions.detect {|ns| ns.prefix == "at"}
350
+ xml["at"].author do
351
+ xml["at"].name conn.user # FIXME: find reliable way to set author?
352
+ end
353
+ xml["at"].title attributes["cmis:name"]
354
+ if attributes["cmis:objectId"]
355
+ xml["at"].id_ attributes["cmis:objectId"]
356
+ else
357
+ xml["at"].id_ "random-garbage"
358
+ end
359
+ xml["cra"].object do
360
+ xml["c"].properties do
361
+ properties.each do |key, definition|
362
+ definition.render_property(xml, attributes[key])
363
+ end
364
+ end
365
+ end
366
+ yield(xml) if block_given?
367
+ end
368
+ end
369
+ conn.logger.debug builder.to_xml
370
+ builder.to_xml
371
+ end
372
+
373
+ # @private
374
+ attr_writer :updated_attributes
375
+
376
+ def updated_aspects(checkin = nil)
377
+ result = []
378
+
379
+ if key.nil?
380
+ result << {:message => :save_new_object, :parameters => []}
381
+ if parent_folders.length > 1
382
+ # We started from 0 folders, we already added the first when creating the document
383
+
384
+ # Note: to keep a save operation at least somewhat atomic this might be better done in save_new_object
385
+ result << {:message => :save_folders, :parameters => [parent_folders]}
386
+ end
387
+ else
388
+ if !updated_attributes.empty?
389
+ result << {:message => :save_attributes, :parameters => [updated_attributes, attributes, checkin]}
390
+ end
391
+ if @original_parent_folders
392
+ result << {:message => :save_folders, :parameters => [parent_folders, checkin && !updated_attributes]}
393
+ end
394
+ end
395
+ if acl && acl.updated # We need to be able to do this for newly created documents and merge the two
396
+ result << {:message => :save_acl, :parameters => [acl]}
397
+ end
398
+
399
+ if result.empty? && checkin
400
+ # NOTE: this needs some thinking through: in particular this may not work well if there would be an updated content stream
401
+ result << {:message => :save_attributes, :parameters => [[], [], checkin]}
402
+ end
403
+
404
+ result
405
+ end
406
+
407
+ def save_new_object
408
+ if self.class.required_attributes.any? {|a, _| attribute(a).nil? }
409
+ raise Error::InvalidArgument.new("Not all required attributes are filled in")
410
+ end
411
+
412
+ properties = self.class.attributes.reject do |key, definition|
413
+ # !updated_attributes.include?(key) && !definition.required
414
+ attributes[key].nil? or definition.updatability == "readonly"
415
+ end
416
+ body = render_atom_entry(properties, attributes, :create => true)
417
+
418
+ url = create_url
419
+ response = conn.post(create_url, body, "Content-Type" => "application/atom+xml;type=entry")
420
+ # XXX: Currently ignoring Location header in response
421
+
422
+ response_data = Nokogiri::XML::parse(response).xpath("at:entry", NS::COMBINED) # Assume that a response indicates success?
423
+
424
+ @self_link = response_data.xpath("at:link[@rel = 'self']/@href", NS::COMBINED).first
425
+ @self_link = @self_link.text
426
+ reload
427
+ @key = attribute("cmis:objectId")
428
+
429
+ self
430
+ end
431
+
432
+ def save_attributes(attributes, values, checkin = nil)
433
+ if attributes.empty? && checkin.nil?
434
+ raise "Error: saving attributes but nothing to do"
435
+ end
436
+ properties = self.class.attributes.reject {|key,_| !updated_attributes.include?(key)}
437
+ body = render_atom_entry(properties, values, :checkin => checkin)
438
+
439
+ if checkin.nil?
440
+ parameters = {}
441
+ else
442
+ checkin, major, comment = *checkin
443
+ parameters = {"checkin" => checkin}
444
+ if checkin
445
+ parameters.merge! "major" => !!major, "checkinComment" => Internal::Utils.escape_url_parameter(comment)
446
+
447
+ if properties.empty?
448
+ # The standard specifies that we can have an empty body here, that does not seem to be true for OpenCMIS
449
+ # body = ""
450
+ end
451
+ end
452
+ end
453
+
454
+ # NOTE: Spec says Entity Tag should be used for changeTokens, that does not seem to work
455
+ if ct = attribute("cmis:changeToken")
456
+ parameters.merge! "changeToken" => Internal::Utils.escape_url_parameter(ct)
457
+ end
458
+
459
+ uri = self_link(parameters)
460
+ response = conn.put(uri, body)
461
+
462
+ data = Nokogiri::XML.parse(response, nil, nil, Nokogiri::XML::ParseOptions::STRICT).xpath("at:entry", NS::COMBINED)
463
+ if data.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
464
+ reload
465
+ @data = data
466
+ self
467
+ else
468
+ reload # Updated attributes should be forgotten here
469
+ ActiveCMIS::Object.from_atom_entry(repository, data)
470
+ end
471
+ end
472
+
473
+ def save_folders(requested_parent_folders, checkin = nil)
474
+ current = parent_folders.to_a
475
+ future = requested_parent_folders.to_a
476
+
477
+ common_folders = future.map {|f| f.id}.select {|id| current.any? {|f| f.id == id } }
478
+
479
+ added = future.select {|f1| current.all? {|f2| f1.id != f2.id } }
480
+ removed = current.select {|f1| future.all? {|f2| f1.id != f2.id } }
481
+
482
+ # NOTE: an absent atom:content is important here according to the spec, for the moment I did not suffer from this
483
+ body = render_atom_entry("cmis:objectId" => self.class.attributes["cmis:objectId"])
484
+
485
+ # Note: change token does not seem to matter here
486
+ # FIXME: currently we assume the data returned by post is not important, I'm not sure that this is always true
487
+ if added.empty?
488
+ removed.each do |folder|
489
+ url = repository.unfiled.url
490
+ url = Internal::Utils.append_parameters(url, "removeFrom" => Internal::Utils.escape_url_parameter(removed.id))
491
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
492
+ end
493
+ elsif removed.empty?
494
+ added.each do |folder|
495
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
496
+ end
497
+ else
498
+ removed.zip(added) do |r, a|
499
+ url = a.items.url
500
+ url = Internal::Utils.append_parameters(url, "sourceFolderId" => Internal::Utils.escape_url_parameter(r.id))
501
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
502
+ end
503
+ if extra = added[removed.length..-1]
504
+ extra.each do |folder|
505
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
506
+ end
507
+ end
508
+ end
509
+
510
+ self
511
+ end
512
+
513
+ def save_acl(acl)
514
+ acl.save
515
+ reload
516
+ self
517
+ end
518
+
519
+ class << self
520
+ # The repository this type is defined in
521
+ # @return [Repository]
522
+ attr_reader :repository
523
+
524
+ # @private
525
+ def from_atom_entry(repository, data, parameters = {})
526
+ query = "cra:object/c:properties/c:propertyId[@propertyDefinitionId = '%s']/c:value"
527
+ type_id = data.xpath(query % "cmis:objectTypeId", NS::COMBINED).text
528
+ klass = repository.type_by_id(type_id)
529
+ if klass
530
+ if klass <= self
531
+ klass.new(repository, data, parameters)
532
+ else
533
+ raise "You tried to do from_atom_entry on a type which is not a supertype of the type of the document you identified"
534
+ end
535
+ else
536
+ raise "The object #{extract_property(data, "String", 'cmis:name')} has an unrecognized type #{type_id}"
537
+ end
538
+ end
539
+
540
+ # @private
541
+ def from_parameters(repository, parameters)
542
+ url = repository.object_by_id_url(parameters)
543
+ data = repository.conn.get_atom_entry(url)
544
+ from_atom_entry(repository, data, parameters)
545
+ end
546
+
547
+ # A list of all attributes defined on this object
548
+ # @param [Boolean] inherited Nonfunctional
549
+ # @return [Hash{String => PropertyDefinition}]
550
+ def attributes(inherited = false)
551
+ {}
552
+ end
553
+
554
+ # The key of the CMIS Type
555
+ # @return [String]
556
+ # @raise [NotImplementedError] for Object/Folder/Document/Policy/Relationship
557
+ def key
558
+ raise NotImplementedError
559
+ end
560
+
561
+ end
562
+ end
563
+ end
@@ -0,0 +1,13 @@
1
+ module ActiveCMIS
2
+ class Policy < ActiveCMIS::Object
3
+ private
4
+ def create_url
5
+ if f = parent_folders.first
6
+ f.items.url
7
+ else
8
+ raise "not yet"
9
+ # Policy collection of containing document?
10
+ end
11
+ end
12
+ end
13
+ end