timfel-active_cmis 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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