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.
@@ -0,0 +1,543 @@
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' => 'entries')
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
+ private
292
+ # Internal value, not meant for common-day use
293
+ # @private
294
+ # @return [Hash]
295
+ attr_reader :used_parameters
296
+
297
+ def self_link(options = {})
298
+ url = @self_link
299
+ if options.empty?
300
+ url
301
+ else
302
+ Internal::Utils.append_parameters(url, options)
303
+ end
304
+ #repository.object_by_id_url(options.merge("id" => id))
305
+ end
306
+
307
+ def data
308
+ parameters = {"includeAllowableActions" => true, "renditionFilter" => "*", "includeACL" => true}
309
+ data = conn.get_atom_entry(self_link(parameters))
310
+ @used_parameters = parameters
311
+ data
312
+ end
313
+ cache :data
314
+
315
+ def conn
316
+ @repository.conn
317
+ end
318
+
319
+ def _allowable_actions
320
+ if actions = data.xpath('cra:object/c:allowableActions', NS::COMBINED).first
321
+ actions
322
+ else
323
+ links = data.xpath("at:link[@rel = '#{Rel[repository.cmis_version][:allowableactions]}']/@href", NS::COMBINED)
324
+ if link = links.first
325
+ conn.get_xml(link.text)
326
+ else
327
+ nil
328
+ end
329
+ end
330
+ end
331
+
332
+ # @param properties a hash key/definition pairs of properties to be rendered (defaults to all attributes)
333
+ # @param attributes a hash key/value pairs used to determine the values rendered (defaults to self.attributes)
334
+ # @param options
335
+ def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
336
+ builder = Nokogiri::XML::Builder.new do |xml|
337
+ xml.entry(NS::COMBINED) do
338
+ xml.parent.namespace = xml.parent.namespace_definitions.detect {|ns| ns.prefix == "at"}
339
+ xml["at"].author do
340
+ xml["at"].name conn.user # FIXME: find reliable way to set author?
341
+ end
342
+ xml["cra"].object do
343
+ xml["c"].properties do
344
+ properties.each do |key, definition|
345
+ definition.render_property(xml, attributes[key])
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
351
+ builder.to_xml
352
+ end
353
+
354
+ # @private
355
+ attr_writer :updated_attributes
356
+
357
+ def updated_aspects(checkin = nil)
358
+ result = []
359
+
360
+ if key.nil?
361
+ result << {:message => :save_new_object, :parameters => []}
362
+ if parent_folders.length > 1
363
+ # We started from 0 folders, we already added the first when creating the document
364
+
365
+ # Note: to keep a save operation at least somewhat atomic this might be better done in save_new_object
366
+ result << {:message => :save_folders, :parameters => [parent_folders]}
367
+ end
368
+ else
369
+ if !updated_attributes.empty?
370
+ result << {:message => :save_attributes, :parameters => [updated_attributes, attributes, checkin]}
371
+ end
372
+ if @original_parent_folders
373
+ result << {:message => :save_folders, :parameters => [parent_folders, checkin && !updated_attributes]}
374
+ end
375
+ end
376
+ if acl && acl.updated # We need to be able to do this for newly created documents and merge the two
377
+ result << {:message => :save_acl, :parameters => [acl]}
378
+ end
379
+
380
+ if result.empty? && checkin
381
+ # NOTE: this needs some thinking through: in particular this may not work well if there would be an updated content stream
382
+ result << {:message => :save_attributes, :parameters => [[], [], checkin]}
383
+ end
384
+
385
+ result
386
+ end
387
+
388
+ def save_new_object
389
+ if self.class.required_attributes.any? {|a, _| attribute(a).nil? }
390
+ raise Error::InvalidArgument.new("Not all required attributes are filled in")
391
+ end
392
+
393
+ properties = self.class.attributes.reject do |key, definition|
394
+ !updated_attributes.include?(key) && !definition.required
395
+ end
396
+ body = render_atom_entry(properties, attributes, :create => true)
397
+
398
+ url = create_url
399
+ response = conn.post(create_url, body, "Content-Type" => "application/atom+xml;type=entry")
400
+ # XXX: Currently ignoring Location header in response
401
+
402
+ response_data = Nokogiri::XML::parse(response).xpath("at:entry", NS::COMBINED) # Assume that a response indicates success?
403
+
404
+ @self_link = response_data.xpath("at:link[@rel = 'self']/@href", NS::COMBINED).first
405
+ @self_link = @self_link.text
406
+ reload
407
+ @key = attribute("cmis:objectId")
408
+
409
+ self
410
+ end
411
+
412
+ def save_attributes(attributes, values, checkin = nil)
413
+ if attributes.empty? && checkin.nil?
414
+ raise "Error: saving attributes but nothing to do"
415
+ end
416
+ properties = self.class.attributes.reject {|key,_| !updated_attributes.include?(key)}
417
+ body = render_atom_entry(properties, values, :checkin => checkin)
418
+
419
+ if checkin.nil?
420
+ parameters = {}
421
+ else
422
+ checkin, major, comment = *checkin
423
+ parameters = {"checkin" => checkin}
424
+ if checkin
425
+ parameters.merge! "major" => !!major, "checkinComment" => Internal::Utils.escape_url_parameter(comment)
426
+
427
+ if properties.empty?
428
+ # The standard specifies that we can have an empty body here, that does not seem to be true for OpenCMIS
429
+ # body = ""
430
+ end
431
+ end
432
+ end
433
+
434
+ # NOTE: Spec says Entity Tag should be used for changeTokens, that does not seem to work
435
+ if ct = attribute("cmis:changeToken")
436
+ parameters.merge! "changeToken" => Internal::Utils.escape_url_parameter(ct)
437
+ end
438
+
439
+ uri = self_link(parameters)
440
+ response = conn.put(uri, body)
441
+
442
+ data = Nokogiri::XML.parse(response).xpath("at:entry", NS::COMBINED)
443
+ if data.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
444
+ reload
445
+ @data = data
446
+ self
447
+ else
448
+ reload # Updated attributes should be forgotten here
449
+ ActiveCMIS::Object.from_atom_entry(repository, data)
450
+ end
451
+ end
452
+
453
+ def save_folders(requested_parent_folders, checkin = nil)
454
+ current = parent_folders.to_a
455
+ future = requested_parent_folders.to_a
456
+
457
+ common_folders = future.map {|f| f.id}.select {|id| current.any? {|f| f.id == id } }
458
+
459
+ added = future.select {|f1| current.all? {|f2| f1.id != f2.id } }
460
+ removed = current.select {|f1| future.all? {|f2| f1.id != f2.id } }
461
+
462
+ # NOTE: an absent atom:content is important here according to the spec, for the moment I did not suffer from this
463
+ body = render_atom_entry("cmis:objectId" => self.class.attributes["cmis:objectId"])
464
+
465
+ # Note: change token does not seem to matter here
466
+ # FIXME: currently we assume the data returned by post is not important, I'm not sure that this is always true
467
+ if added.empty?
468
+ removed.each do |folder|
469
+ url = repository.unfiled.url
470
+ url = Internal::Utils.append_parameters(url, "removeFrom" => Internal::Utils.escape_url_parameter(removed.id))
471
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
472
+ end
473
+ elsif removed.empty?
474
+ added.each do |folder|
475
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
476
+ end
477
+ else
478
+ removed.zip(added) do |r, a|
479
+ url = a.items.url
480
+ url = Internal::Utils.append_parameters(url, "sourceFolderId" => Internal::Utils.escape_url_parameter(r.id))
481
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
482
+ end
483
+ if extra = added[removed.length..-1]
484
+ extra.each do |folder|
485
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
486
+ end
487
+ end
488
+ end
489
+
490
+ self
491
+ end
492
+
493
+ def save_acl(acl)
494
+ acl.save
495
+ reload
496
+ self
497
+ end
498
+
499
+ class << self
500
+ # The repository this type is defined in
501
+ # @return [Repository]
502
+ attr_reader :repository
503
+
504
+ # @private
505
+ def from_atom_entry(repository, data, parameters = {})
506
+ query = "cra:object/c:properties/c:propertyId[@propertyDefinitionId = '%s']/c:value"
507
+ type_id = data.xpath(query % "cmis:objectTypeId", NS::COMBINED).text
508
+ klass = repository.type_by_id(type_id)
509
+ if klass
510
+ if klass <= self
511
+ klass.new(repository, data, parameters)
512
+ else
513
+ raise "You tried to do from_atom_entry on a type which is not a supertype of the type of the document you identified"
514
+ end
515
+ else
516
+ raise "The object #{extract_property(data, "String", 'cmis:name')} has an unrecognized type #{type_id}"
517
+ end
518
+ end
519
+
520
+ # @private
521
+ def from_parameters(repository, parameters)
522
+ url = repository.object_by_id_url(parameters)
523
+ data = repository.conn.get_atom_entry(url)
524
+ from_atom_entry(repository, data, parameters)
525
+ end
526
+
527
+ # A list of all attributes defined on this object
528
+ # @param [Boolean] inherited Nonfunctional
529
+ # @return [Hash{String => PropertyDefinition}]
530
+ def attributes(inherited = false)
531
+ {}
532
+ end
533
+
534
+ # The key of the CMIS Type
535
+ # @return [String]
536
+ # @raise [NotImplementedError] for Object/Folder/Document/Policy/Relationship
537
+ def key
538
+ raise NotImplementedError
539
+ end
540
+
541
+ end
542
+ end
543
+ end