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.
@@ -0,0 +1,86 @@
1
+ module ActiveCMIS
2
+ module Internal
3
+ module Caching
4
+ def self.included(cl)
5
+ cl.extend ClassMethods
6
+ end
7
+
8
+ # A module for internal use only.
9
+ module ClassMethods
10
+
11
+ # Creates a proxy method for the given method names that caches the result.
12
+ #
13
+ # Parameters are passed and ignored, cached values will be returned regardless of the parameters.
14
+ # @param [Symbol, <Symbol>] names Names of methods that will be cached
15
+ # @return [void]
16
+ def cache(*names)
17
+ (@cached_methods ||= []).concat(names).uniq!
18
+ names.each do |name|
19
+ alias_method("#{name}__uncached", name)
20
+ class_eval <<-RUBY, __FILE__, __LINE__+1
21
+ if private_method_defined? :"#{name}"
22
+ private_method = true
23
+ end
24
+ def #{name}(*a, &b)
25
+ if defined? @#{name}
26
+ @#{name}
27
+ else
28
+ @#{name} = #{name}__uncached(*a, &b)
29
+ end
30
+ end
31
+ if private_method
32
+ private :"#{name}__uncached"
33
+ private :"#{name}"
34
+ end
35
+ RUBY
36
+ end
37
+ reloadable
38
+ end
39
+
40
+ # Creates methods to retrieve attributes with the given names.
41
+ #
42
+ # If the given attribute does not yet exist the method #load_from_data will be called
43
+ #
44
+ # @param [Symbol, <Symbol>] names Names of desired attributes
45
+ # @return [void]
46
+ def cached_reader(*names)
47
+ (@cached_methods ||= []).concat(names).uniq!
48
+ names.each do |name|
49
+ define_method "#{name}" do
50
+ if instance_variable_defined? "@#{name}"
51
+ instance_variable_get("@#{name}")
52
+ else
53
+ load_from_data # FIXME: make flexible?
54
+ instance_variable_get("@#{name}")
55
+ end
56
+ end
57
+ end
58
+ reloadable
59
+ end
60
+
61
+ private
62
+ def reloadable
63
+ class_eval <<-RUBY, __FILE__, __LINE__
64
+ def __reload
65
+ #{@cached_methods.inspect}.map do |method|
66
+ :"@\#{method}"
67
+ end.select do |iv|
68
+ instance_variable_defined? iv
69
+ end.each do |iv|
70
+ remove_instance_variable iv
71
+ end + (defined?(super) ? super : [])
72
+ end
73
+ private :__reload
74
+ RUBY
75
+ unless instance_methods.include? "reload"
76
+ class_eval <<-RUBY, __FILE__, __LINE__
77
+ def reload
78
+ __reload
79
+ end
80
+ RUBY
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,241 @@
1
+
2
+ module ActiveCMIS
3
+ module Internal
4
+ class Connection
5
+ # @return [String, nil] The user that is used with the authentication to the server
6
+ attr_reader :user
7
+ # @return [Logger] A logger used to send debug and info messages
8
+ attr_reader :logger
9
+ # @return [Hash] Options to be used by the HTTP objects
10
+ attr_reader :options
11
+
12
+ # @param [Logger] Initialize with a logger of your choice
13
+ def initialize(logger, options)
14
+ @logger = logger || ActiveCMIS.default_logger
15
+ @options = options || {}
16
+ end
17
+
18
+ # Use authentication to access the CMIS repository
19
+ #
20
+ # @param method [Symbol] Currently only :basic is supported
21
+ # @param params The parameters that need to be sent to the Net::HTTP authentication method used, username and password for basic authentication
22
+ # @return [void]
23
+ # @example Basic authentication
24
+ # repo.authenticate(:basic, "username", "password")
25
+ # @example NTLM authentication
26
+ # repo.authenticate(:ntlm, "username", "password")
27
+ def authenticate(method, *params)
28
+ case method
29
+ when :basic, "basic"
30
+ @authentication = {:method => :basic_auth, :params => params}
31
+ @user = params.first
32
+ when :ntlm, "ntlm"
33
+ require 'net/ntlm_http'
34
+ @authentication = {:method => :ntlm_auth, :params => params}
35
+ @user = params.first
36
+ else raise "Authentication method not supported"
37
+ end
38
+ end
39
+
40
+ # The return value is the unparsed body, unless an error occured
41
+ # If an error occurred, exceptions are thrown (see _ActiveCMIS::Exception
42
+ #
43
+ # @private
44
+ # @return [String] returns the body of the request, unless an error occurs
45
+ def get(url)
46
+ uri = normalize_url(url)
47
+
48
+ # Ensure the parsed URL is an HTTP one
49
+ raise HTTPError::ClientError.new("Invalid URL #{url}") unless uri.is_a?(URI::HTTP)
50
+
51
+ req = Net::HTTP::Get.new(uri.request_uri)
52
+ handle_request(uri, req)
53
+ end
54
+
55
+ # Does not throw errors, returns the full response (includes status code and headers)
56
+ # @private
57
+ # @return [Net::HTTP::Response]
58
+ def get_response(url)
59
+ logger.debug "GET (response) #{url}"
60
+ uri = normalize_url(url)
61
+
62
+ req = Net::HTTP::Get.new(uri.request_uri)
63
+ http = authenticate_request(uri, req)
64
+ response = nil
65
+ http.request(req) do |res|
66
+ logger.debug "GOT (#{res.code}) #{url}"
67
+ response = res
68
+ end
69
+ response
70
+ end
71
+
72
+ # Returns the parsed body of the result
73
+ # @private
74
+ # @return [Nokogiri::XML::Document]
75
+ def get_xml(url)
76
+ Nokogiri::XML.parse(get(url), nil, nil, Nokogiri::XML::ParseOptions::STRICT)
77
+ end
78
+
79
+ # @private
80
+ # @return [Nokogiri::XML::Node]
81
+ def get_atom_entry(url)
82
+ # FIXME: add validation that first child is really an entry
83
+ get_xml(url).child
84
+ end
85
+
86
+ # @private
87
+ def put(url, body, headers = {})
88
+ uri = normalize_url(url)
89
+
90
+ req = Net::HTTP::Put.new(uri.request_uri)
91
+ headers.each {|k,v| req.add_field k, v}
92
+ assign_body(req, body)
93
+ handle_request(uri, req)
94
+ end
95
+
96
+ # @private
97
+ def delete(url, headers = {})
98
+ uri = normalize_url(url)
99
+
100
+ req = Net::HTTP::Put.new(uri.request_uri)
101
+ headers.each {|k,v| req.add_field k, v}
102
+ handle_request(uri, req)
103
+ end
104
+
105
+ # @private
106
+ def post(url, body, headers = {})
107
+ uri = normalize_url(url)
108
+
109
+ req = Net::HTTP::Post.new(uri.request_uri)
110
+ headers.each {|k,v| req.add_field k, v}
111
+ assign_body(req, body)
112
+ handle_request(uri, req)
113
+ end
114
+
115
+ # Does not throw errors, returns the full response (includes status code and headers)
116
+ # @private
117
+ def post_response(url, body, headers = {})
118
+ logger.debug "POST (response) #{url}"
119
+ uri = normalize_url(url)
120
+
121
+ req = Net::HTTP::Post.new(uri.request_uri)
122
+ headers.each {|k,v| req.add_field k, v}
123
+ assign_body(req, body)
124
+
125
+ http = authenticate_request(uri, req)
126
+ response = http.request(req)
127
+ logger.debug "POSTED (#{response.code}) #{url}"
128
+ response
129
+ end
130
+
131
+ # @private
132
+ def delete(url)
133
+ uri = normalize_url(url)
134
+
135
+ req = Net::HTTP::Delete.new(uri.request_uri)
136
+ handle_request(uri, req)
137
+ end
138
+
139
+ private
140
+ def normalize_url(url)
141
+ case url
142
+ when URI; url
143
+ else URI.parse(url.to_s)
144
+ end
145
+ end
146
+
147
+ def http_class
148
+ @http_class ||= begin
149
+ if proxy = ENV['HTTP_PROXY'] || ENV['http_proxy'] then
150
+ p_uri = URI.parse(proxy)
151
+ p_user, p_pass = p_uri.user, p_uri.password if p_uri.user
152
+ Net::HTTP::Proxy(p_uri.host, p_uri.port, p_user, p_pass)
153
+ else
154
+ Net::HTTP
155
+ end
156
+ end
157
+ end
158
+
159
+ def authenticate_request(uri, req)
160
+ http = http_class.new(uri.host, uri.port)
161
+ # Force to use SSL
162
+ http.use_ssl = (uri.scheme == 'https')
163
+
164
+ if options[:ssl_verfiy] == false
165
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
166
+ end
167
+
168
+ # Set the timeout
169
+ if options[:timeout]
170
+ http.open_timeout = options[:timeout]
171
+ http.read_timeout = options[:timeout]
172
+ end
173
+ if auth = @authentication
174
+ req.send(auth[:method], *auth[:params])
175
+ end
176
+ http
177
+ end
178
+
179
+ def assign_body(req, body)
180
+ if body.respond_to? :length
181
+ req.body = body
182
+ else
183
+ req.body_stream = body
184
+ if body.respond_to? :stat
185
+ req["Content-Length"] = body.stat.size.to_s
186
+ elsif req["Content-Size"].nil?
187
+ req["Transfer-Encoding"] = 'chunked'
188
+ end
189
+ end
190
+ end
191
+
192
+ def handle_request(uri, req, retry_count = 0)
193
+ logger.debug "#{req.method} #{uri}"
194
+ http = authenticate_request(uri, req)
195
+
196
+ status, body, headers = nil
197
+ http.request(req) { |resp|
198
+ status = resp.code.to_i
199
+ body = resp.body
200
+ headers = resp
201
+ }
202
+
203
+ logger.debug "RECEIVED #{status}"
204
+
205
+ if 200 <= status && status < 300
206
+ return body
207
+ elsif 300 <= status && status < 400
208
+ # follow the redirected a limited number of times
209
+ location = headers["location"]
210
+ logger.debug "REDIRECTING: #{location.inspect}"
211
+ if retry_count <= 3
212
+ new_uri = URI.parse(location)
213
+ if new_uri.relative?
214
+ new_uri = uri + location
215
+ end
216
+ new_req = req.class.new(uri.request_uri)
217
+ handle_request(new_uri, new_req, retry_count + 1)
218
+ else
219
+ raise HTTPError.new("Too many redirects")
220
+ end
221
+ elsif 400 <= status && status < 500
222
+ # Problem: some codes 400, 405, 403, 409, 500 have multiple meanings
223
+ logger.error "Error occurred when handling request:\n#{body}"
224
+ case status
225
+ when 400; raise Error::InvalidArgument.new(body)
226
+ # FIXME: can also be filterNotValid
227
+ when 401; raise HTTPError::AuthenticationError.new(body)
228
+ when 404; raise Error::ObjectNotFound.new(body)
229
+ when 403; raise Error::PermissionDenied.new(body)
230
+ # FIXME: can also be streamNotSupported (?? shouldn't that be 405??)
231
+ when 405; raise Error::NotSupported.new(body)
232
+ else
233
+ raise HTTPError::ClientError.new("A HTTP #{status} error occured, for more precision update the code:\n" + body)
234
+ end
235
+ elsif 500 <= status
236
+ raise HTTPError::ServerError.new("The server encountered an internal error #{status} (this could be a client error though):\n" + body)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,82 @@
1
+ module ActiveCMIS
2
+ module Internal
3
+ # @private
4
+ module Utils
5
+ # @private
6
+ def self.escape_url_parameter(parameter)
7
+ control = "\x00-\x1F\x7F"
8
+ space = " "
9
+ delims = "<>#%\""
10
+ unwise = '{}|\\\\^\[\]`'
11
+ query = ";/?:@&=+,$"
12
+ escape(parameter, /[#{control+space+delims+unwise+query}]/o)
13
+ end
14
+
15
+ # Given an url (string or URI) returns that url with the given parameters appended
16
+ #
17
+ # This method does not perform any encoding on the paramter or key values.
18
+ # This method does not check the existing parameters for duplication in keys
19
+ # @private
20
+ def self.append_parameters(uri, parameters)
21
+ uri = case uri
22
+ when String; string = true; URI.parse(uri)
23
+ when URI; uri.dup
24
+ end
25
+ uri.query = [uri.query, *parameters.map {|key, value| "#{key}=#{value}"} ].compact.join "&"
26
+ if string
27
+ uri.to_s
28
+ else
29
+ uri
30
+ end
31
+ end
32
+
33
+ # FIXME?? percent_encode and escape_url_parameter serve nearly the same purpose, replace one?
34
+ # @private
35
+ def self.percent_encode(string)
36
+ escape(string, /[^#{URI::PATTERN::UNRESERVED}]/o)
37
+ end
38
+
39
+ def self.escape(string, pattern)
40
+ if defined?(URI::Parser)
41
+ parser = URI::Parser.new
42
+ parser.escape(string, pattern)
43
+ else
44
+ URI.escape(string, pattern)
45
+ end
46
+ end
47
+
48
+ # Returns id if id is already an object, object_by_id if id is a string, nil otherwise
49
+ # @private
50
+ def self.string_or_id_to_object(repository, id)
51
+ # FIXME: only used in lib/activecmis/relationship.rb, the repository parameter
52
+ # would be unnecessary if included.
53
+ # Should this be a generic method, or should this be moved to the Relationship class?
54
+ # Or should I start including this module in every place that needs it?
55
+ case id
56
+ when String; repository.object_by_id(id)
57
+ when ::ActiveCMIS::Object; id
58
+ end
59
+ end
60
+
61
+ # @private
62
+ def self.extract_links(xml, rel, type_main = nil, type_params = {})
63
+ links = xml.xpath("at:link[@rel = '#{rel}']", NS::COMBINED)
64
+
65
+ if type_main
66
+ type_main = Regexp.escape(type_main)
67
+ if type_params.empty?
68
+ regex = /#{type_main}/
69
+ else
70
+ parameters = type_params.map {|k,v| "#{Regexp.escape(k)}=#{Regexp.escape(v)}" }.join(";\s*")
71
+ regex = /#{type_main};\s*#{parameters}/
72
+ end
73
+ links = links.select do |node|
74
+ regex === node.attribute("type").to_s
75
+ end
76
+ end
77
+
78
+ links.map {|l| l.attribute("href").to_s}
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,18 @@
1
+ module ActiveCMIS
2
+ # This module defines namespaces that often occur in the REST/Atompub API to CMIS
3
+ module NS
4
+ CMIS_CORE = "http://docs.oasis-open.org/ns/cmis/core/200908/"
5
+ CMIS_REST = "http://docs.oasis-open.org/ns/cmis/restatom/200908/"
6
+ CMIS_MESSAGING = "http://docs.oasis-open.org/ns/cmis/messaging/200908/"
7
+ APP = "http://www.w3.org/2007/app"
8
+ ATOM = "http://www.w3.org/2005/Atom"
9
+
10
+ COMBINED = {
11
+ "xmlns:c" => CMIS_CORE,
12
+ "xmlns:cra" => CMIS_REST,
13
+ "xmlns:cm" => CMIS_MESSAGING,
14
+ "xmlns:app" => APP,
15
+ "xmlns:at" => ATOM
16
+ }
17
+ end
18
+ end
@@ -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.default_namespace = NS::COMBINED["xmlns:at"]
350
+ xml.author do
351
+ xml.name conn.user # FIXME: find reliable way to set author?
352
+ end
353
+ xml.title attributes["cmis:name"]
354
+ if attributes["cmis:objectId"]
355
+ xml.id_ attributes["cmis:objectId"]
356
+ else
357
+ xml.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.select {|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, "Content-Type" => "application/atom+xml;type=entry")
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