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,35 @@
1
+ module ActiveCMIS
2
+ # A class used to get and set attributes that have a prefix like cmis: in their attribute IDs
3
+ class AttributePrefix
4
+ # @return [Object] The object that the attribute getting and setting will take place on
5
+ attr_reader :object
6
+ # @return [String]
7
+ attr_reader :prefix
8
+
9
+ # @private
10
+ def initialize(object, prefix)
11
+ @object = object
12
+ @prefix = prefix
13
+ end
14
+
15
+ # For known attributes will act as a getter and setter
16
+ def method_missing(method, *parameters)
17
+ string = method.to_s
18
+ if string[-1] == ?=
19
+ assignment = true
20
+ string = string[0..-2]
21
+ end
22
+ attribute = "#{prefix}:#{string}"
23
+ if object.class.attributes.keys.include? attribute
24
+ if assignment
25
+ object.update(attribute => parameters.first)
26
+ else
27
+ object.attribute(attribute)
28
+ end
29
+ else
30
+ # TODO: perhaps here we should try to look a bit further to see if there is a second :
31
+ super
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,175 @@
1
+ module ActiveCMIS
2
+
3
+ # A Collection represents an atom feed, and can be used to lazily load data through paging
4
+ class Collection
5
+ include Internal::Caching
6
+ include ::Enumerable
7
+
8
+ # The repository that contains this feed
9
+ # @return [Repository]
10
+ attr_reader :repository
11
+ # The basic link that represents the beginning of this feed
12
+ # @return [URI]
13
+ attr_reader :url
14
+
15
+ def initialize(repository, url, first_page = nil, &map_entry)
16
+ @repository = repository
17
+ @url = URI.parse(url)
18
+
19
+ @next = @url
20
+ @elements = []
21
+ @pages = []
22
+
23
+ @map_entry = map_entry || Proc.new do |e|
24
+ ActiveCMIS::Object.from_atom_entry(repository, e)
25
+ end
26
+
27
+ if first_page
28
+ @next = first_page.xpath("at:feed/at:link[@rel = 'next']/@href", NS::COMBINED).first
29
+ @pages[0] = first_page
30
+ end
31
+ end
32
+
33
+ # @return [Integer] The length of the collection
34
+ def length
35
+ receive_page
36
+ if @length.nil?
37
+ i = 1
38
+ while @next
39
+ receive_page
40
+ i += 1
41
+ end
42
+ @elements.length
43
+ else
44
+ @length
45
+ end
46
+ end
47
+ alias size length
48
+ cache :length
49
+
50
+ def empty?
51
+ at(0)
52
+ @elements.empty?
53
+ end
54
+
55
+ # @return [Array]
56
+ def to_a
57
+ while @next
58
+ receive_page
59
+ end
60
+ @elements
61
+ end
62
+
63
+ def at(index)
64
+ index = sanitize_index(index)
65
+ if index < @elements.length
66
+ @elements[index]
67
+ elsif index > length
68
+ nil
69
+ else
70
+ while @next && @elements.length < index
71
+ receive_page
72
+ end
73
+ @elements[index]
74
+ end
75
+ end
76
+
77
+ def [](index, length = nil)
78
+ if length
79
+ index = sanitize_index(index)
80
+ range_get(index, index + length - 1)
81
+ elsif Range === index
82
+ range_get(sanitize_index(index.begin), index.exclude_end? ? sanitize_index(index.end) - 1 : sanitize_index(index.end))
83
+ else
84
+ at(index)
85
+ end
86
+ end
87
+ alias slice []
88
+
89
+ def first
90
+ at(0)
91
+ end
92
+
93
+ # Gets all object and returns last
94
+ def last
95
+ at(-1)
96
+ end
97
+
98
+ # @return [Array]
99
+ def each
100
+ length.times { |i| yield self[i] }
101
+ end
102
+
103
+ # @return [Array]
104
+ def reverse_each
105
+ (length - 1).downto(0) { |i| yield self[i] }
106
+ end
107
+
108
+ # @return [String]
109
+ def inspect
110
+ "#<Collection %s>" % url
111
+ end
112
+
113
+ # @return [String]
114
+ def to_s
115
+ to_a.to_s
116
+ end
117
+
118
+ # @return [Array]
119
+ def uniq
120
+ to_a.uniq
121
+ end
122
+
123
+ # @return [Array]
124
+ def sort
125
+ to_a.sort
126
+ end
127
+
128
+ # @return [Array]
129
+ def reverse
130
+ to_a.reverse
131
+ end
132
+
133
+ # @return [void]
134
+ def reload
135
+ @pages = []
136
+ @elements = []
137
+ @next = @url
138
+ __reload
139
+ end
140
+
141
+ private
142
+
143
+ def sanitize_index(index)
144
+ index < 0 ? size + index : index
145
+ end
146
+
147
+ def range_get(from, to)
148
+ (from..to).map { |i| at(i) }
149
+ end
150
+
151
+ def receive_page(i = nil)
152
+ i ||= @pages.length
153
+ @pages[i] ||= begin
154
+ return nil unless @next
155
+ xml = conn.get_xml(@next)
156
+
157
+ @next = xml.xpath("at:feed/at:link[@rel = 'next']/@href", NS::COMBINED).first
158
+ @next = @next.nil? ? nil : @next.text
159
+
160
+ new_elements = xml.xpath('at:feed/at:entry', NS::COMBINED).map &@map_entry
161
+ @elements.concat(new_elements)
162
+
163
+ num_items = xml.xpath("at:feed/cra:numItems", NS::COMBINED).first
164
+ @length ||= num_items.text.to_i if num_items # We could also test on the repository
165
+
166
+ xml
167
+ end
168
+ end
169
+
170
+ def conn
171
+ repository.conn
172
+ end
173
+
174
+ end
175
+ end
@@ -0,0 +1,314 @@
1
+ module ActiveCMIS
2
+ class Document < ActiveCMIS::Object
3
+ # Returns an ActiveCMIS::Rendition to the content stream or nil if there is none
4
+ # @return [Rendition]
5
+ def content_stream
6
+ if content = data.xpath("at:content", NS::COMBINED).first
7
+ if content['src']
8
+ ActiveCMIS::Rendition.new(repository, "href" => content['src'], "type" => content["type"])
9
+ else
10
+ if content['type'] =~ /\+xml$/
11
+ content_data = content.to_xml # FIXME: this may not preserve whitespace
12
+ else
13
+ content_data = data.unpack("m*").first
14
+ end
15
+ ActiveCMIS::Rendition.new(repository, "data" => content_data, "type" => content["type"])
16
+ end
17
+ elsif content = data.xpath("cra:content", NS::COMBINED).first
18
+ content.children.each do |node|
19
+ next unless node.namespace and node.namespace.href == NS::CMIS_REST
20
+ content_data = node.text if node.name == "base64"
21
+ content_type = node.text if node.name == "mediaType"
22
+ end
23
+ data = content_data.unpack("m*").first
24
+ ActiveCMIS::Rendition.new(repository, "data" => content_data, "type" => content_type)
25
+ end
26
+ end
27
+ cache :content_stream
28
+
29
+ # Will reload if renditionFilter was not set or cmis:none, but not in other circumstances
30
+ # @return [Array<Rendition>]
31
+ def renditions
32
+ filter = used_parameters["renditionFilter"]
33
+ if filter.nil? || filter == "cmis:none"
34
+ reload
35
+ end
36
+
37
+ links = data.xpath("at:link[@rel = 'alternate']", NS::COMBINED)
38
+ links.map do |link|
39
+ ActiveCMIS::Rendition.new(repository, link)
40
+ end
41
+ end
42
+ cache :renditions
43
+
44
+ # Sets new content to be uploaded, does not alter values you will get from content_stream (for the moment)
45
+ # @param [Hash] options A hash containing exactly one of :file or :data
46
+ # @option options [String] :file The name of a file to upload
47
+ # @option options [#read] :data Data you want to upload (if #length is defined it should give the total length that can be read)
48
+ # @option options [Boolean] :overwrite (true) Whether the contents should be overwritten (ignored in case of checkin)
49
+ # @option options [String] :mime_type
50
+ #
51
+ # @return [void]
52
+ def set_content_stream(options)
53
+ if key.nil?
54
+ if self.class.content_stream_allowed == "notallowed"
55
+ raise Error::StreamNotSupported.new("Documents of this type can't have content")
56
+ end
57
+ else
58
+ updatability = repository.capabilities["ContentStreamUpdatability"]
59
+ if updatability == "none"
60
+ raise Error::NotSupported.new("Content can't be updated in this repository")
61
+ elsif updatability == "pwconly" && !working_copy?
62
+ raise Error::Constraint.new("Content can only be updated for working copies in this repository")
63
+ end
64
+ end
65
+ @updated_contents = options
66
+ end
67
+
68
+ # Returns all documents in the version series of this document.
69
+ # Uses self to represent the version of this document
70
+ # @return [Collection<Document>, Array(self)]
71
+ def versions
72
+ link = data.xpath("at:link[@rel = 'version-history']/@href", NS::COMBINED)
73
+ if link = link.first
74
+ Collection.new(repository, link) # Problem: does not in fact use self
75
+ else
76
+ # The document is not versionable
77
+ [self]
78
+ end
79
+ end
80
+ cache :versions
81
+
82
+ # Returns self if this is the latest version
83
+ # Note: There will allways be a latest version in a version series
84
+ # @return [Document]
85
+ def latest_version
86
+ link = data.xpath("at:link[@rel = 'current-version']/@href", NS::COMBINED)
87
+ if link.first
88
+ entry = conn.get_atom_entry(link.first.text)
89
+ self_or_new(entry)
90
+ else
91
+ # FIXME: should somehow return the current version even for opencmis
92
+ self
93
+ end
94
+ end
95
+
96
+ # Returns self if this is the working copy
97
+ # Returns nil if there is no working copy
98
+ # @return [Document]
99
+ def working_copy
100
+ link = data.xpath("at:link[@rel = 'working-copy']/@href", NS::COMBINED)
101
+ if link.first
102
+ entry = conn.get_atom_entry(link.first.text)
103
+ self_or_new(entry)
104
+ else
105
+ nil
106
+ end
107
+ end
108
+
109
+ def latest?
110
+ attributes["cmis:isLatestVersion"]
111
+ end
112
+ def major?
113
+ attributes["cmis:isMajorVersion"]
114
+ end
115
+ def latest_major?
116
+ attributes["cmis:isLatestMajorVersion"]
117
+ end
118
+
119
+ def working_copy?
120
+ return false if key.nil?
121
+
122
+ # NOTE: This may not be a sufficient condition, but according to the spec it should be
123
+ !data.xpath("at:link[@rel = 'via']", NS::COMBINED).empty?
124
+ end
125
+
126
+ # Returns information about the checked out status of this document
127
+ #
128
+ # @return [Hash,nil] Keys are :by for the owner of the PWC and :id for the CMIS ID, both can be unset according to the spec
129
+ def version_series_checked_out
130
+ attributes = self.attributes
131
+ if attributes["cmis:isVersionSeriesCheckedOut"]
132
+ result = {}
133
+ if attributes.has_key? "cmis:versionSeriesCheckedOutBy"
134
+ result[:by] = attributes["cmis:versionSeriesCheckedOutBy"]
135
+ end
136
+ if attributes.has_key? "cmis:versionSeriesCheckedOutId"
137
+ result[:id] = attributes["cmis:versionSeriesCheckedOutId"]
138
+ end
139
+ result
140
+ else
141
+ nil
142
+ end
143
+ end
144
+
145
+ # The checkout operation results in a Private Working Copy
146
+ #
147
+ # Most properties should be the same as for the document that was checked out,
148
+ # certain properties may differ such as cmis:objectId and cmis:creationDate.
149
+ #
150
+ # The content stream of the PWC may be identical to that of the document
151
+ # that was checked out, or it may be unset.
152
+ # @return [Document] The checked out version of this document
153
+ def checkout
154
+ body = render_atom_entry(self.class.attributes.reject {|k,v| k != "cmis:objectId"})
155
+
156
+ response = conn.post_response(repository.checkedout.url, body)
157
+ if 200 <= response.code.to_i && response.code.to_i < 300
158
+ entry = Nokogiri::XML.parse(response.body).xpath("/at:entry", NS::COMBINED)
159
+ result = self_or_new(entry)
160
+ if result.working_copy? # Work around a bug in OpenCMIS where result returned is the version checked out not the PWC
161
+ result
162
+ else
163
+ conn.logger.warn "Repository did not return working copy for checkout operation"
164
+ result.working_copy
165
+ end
166
+ else
167
+ raise response.body
168
+ end
169
+ end
170
+
171
+ # This action may not be permitted (query allowable_actions to see whether it is permitted)
172
+ # @return [void]
173
+ def cancel_checkout
174
+ if !self.class.versionable
175
+ raise Error::Constraint.new("Object is not versionable, can't cancel checkout")
176
+ elsif working_copy?
177
+ conn.delete(self_link)
178
+ else
179
+ raise Error::InvalidArgument.new("Not a working copy")
180
+ end
181
+ end
182
+
183
+ # You can specify whether the new version should be major (defaults to true)
184
+ # You can optionally give a list of attributes that need to be set.
185
+ #
186
+ # This operation exists only for Private Working Copies
187
+ # @return [Document] The final version that results from the checkin
188
+ def checkin(major = true, comment = "", updated_attributes = {})
189
+ if working_copy?
190
+ update(updated_attributes)
191
+ result = self
192
+ updated_aspects([true, major, comment]).each do |hash|
193
+ result = result.send(hash[:message], *hash[:parameters])
194
+ end
195
+ result
196
+ else
197
+ raise "Not a working copy"
198
+ end
199
+ end
200
+
201
+ # @return [void]
202
+ def reload
203
+ @updated_contents = nil
204
+ super
205
+ end
206
+
207
+ private
208
+ attr_reader :updated_contents
209
+
210
+ # Optional parameters:
211
+ # - properties: a hash key/definition pairs of properties to be rendered (defaults to all attributes)
212
+ # - attributes: a hash key/value pairs used to determine the values rendered (defaults to self.attributes)
213
+ def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
214
+ builder = Nokogiri::XML::Builder.new do |xml|
215
+ xml.entry(NS::COMBINED) do
216
+ xml.parent.namespace = xml.parent.namespace_definitions.detect {|ns| ns.prefix == "at"}
217
+ xml["at"].author do
218
+ xml["at"].name conn.user # FIXME: find reliable way to set author?
219
+ end
220
+ if updated_contents && (options[:create] || options[:checkin])
221
+ xml["cra"].content do
222
+ xml["cra"].mediatype(updated_contents[:mime_type] || "application/binary")
223
+ data = updated_contents[:data] || File.read(updated_contents[:file])
224
+ xml["cra"].base64 [data].pack("m")
225
+ end
226
+ end
227
+ xml["cra"].object do
228
+ xml["c"].properties do
229
+ properties.each do |key, definition|
230
+ definition.render_property(xml, attributes[key])
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ conn.logger.debug builder.to_xml
237
+ builder.to_xml
238
+ end
239
+
240
+
241
+ def updated_aspects(checkin = nil)
242
+ if working_copy? && !(checkin || repository.pwc_ubdatable)
243
+ raise Error::NotSupported.new("Updating a PWC without checking in is not supported by repository")
244
+ end
245
+ unless working_copy? || checkin.nil?
246
+ raise Error::NotSupported.new("Can't check in when not checked out")
247
+ end
248
+
249
+ result = super
250
+
251
+ unless checkin || key.nil? || updated_contents.nil?
252
+ # Don't set content_stream separately if it can be done by setting the content during create
253
+ #
254
+ # TODO: For checkin we could try to see if we can save it via puts *before* we checkin,
255
+ # If not checking in we should also try to see if we can actually save it
256
+ result << {:message => :save_content_stream, :parameters => [updated_contents]}
257
+ end
258
+
259
+ result
260
+ end
261
+
262
+ def self_or_new(entry)
263
+ if entry.nil?
264
+ nil
265
+ elsif entry.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
266
+ reload
267
+ @data = entry
268
+ self
269
+ else
270
+ ActiveCMIS::Object.from_atom_entry(repository, entry)
271
+ end
272
+ end
273
+
274
+ def create_url
275
+ if f = parent_folders.first
276
+ url = f.items.url
277
+ if self.class.versionable # Necessary in OpenCMIS at least
278
+ url
279
+ else
280
+ Internal::Utils.append_parameters(url, "versioningState" => "none")
281
+ end
282
+ else
283
+ raise Error::NotSupported.new("Creating an unfiled document is not supported by CMIS")
284
+ # Can't create documents that are unfiled, CMIS does not support it (note this means exceptions should not actually be NotSupported)
285
+ end
286
+ end
287
+
288
+ def save_content_stream(stream)
289
+ # Should never occur (is private method)
290
+ raise "no content to save" if stream.nil?
291
+
292
+ # put to link with rel 'edit-media' if it's there
293
+ # NOTE: cmislib uses the src link of atom:content instead, that might be correct
294
+ edit_links = Internal::Utils.extract_links(data, "edit-media")
295
+ if edit_links.length == 1
296
+ link = edit_links.first
297
+ elsif edit_links.empty?
298
+ raise Error.new("No edit-media link, can't save content")
299
+ else
300
+ raise Error.new("Too many edit-media links, don't know how to choose")
301
+ end
302
+ data = stream[:data] || File.open(stream[:file])
303
+ content_type = stream[:mime_type] || "application/octet-stream"
304
+
305
+ if stream.has_key?(:overwrite)
306
+ url = Internal::Utils.append_parameters(link, "overwrite" => stream[:overwrite])
307
+ else
308
+ url = link
309
+ end
310
+ conn.put(url, data, "Content-Type" => content_type)
311
+ self
312
+ end
313
+ end
314
+ end