ro-bundle 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.
Files changed (47) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +11 -0
  3. data/.ruby-env +1 -0
  4. data/.ruby-gemset +2 -0
  5. data/.ruby-version +2 -0
  6. data/.travis.yml +11 -0
  7. data/Changes.rdoc +129 -0
  8. data/Gemfile +11 -0
  9. data/Licence.rdoc +29 -0
  10. data/Rakefile +29 -0
  11. data/ReadMe.rdoc +57 -0
  12. data/bin/dir2ro +48 -0
  13. data/bin/ro-bundle-info +45 -0
  14. data/bin/verify-ro-bundle +27 -0
  15. data/bin/zip2ro +57 -0
  16. data/lib/ro-bundle.rb +45 -0
  17. data/lib/ro-bundle/exceptions.rb +30 -0
  18. data/lib/ro-bundle/file.rb +323 -0
  19. data/lib/ro-bundle/ro/agent.rb +73 -0
  20. data/lib/ro-bundle/ro/aggregate.rb +107 -0
  21. data/lib/ro-bundle/ro/annotation.rb +89 -0
  22. data/lib/ro-bundle/ro/directory.rb +120 -0
  23. data/lib/ro-bundle/ro/manifest.rb +338 -0
  24. data/lib/ro-bundle/ro/provenance.rb +153 -0
  25. data/lib/ro-bundle/util.rb +57 -0
  26. data/lib/ro-bundle/version.rb +13 -0
  27. data/ro-bundle.gemspec +43 -0
  28. data/test/data/HelloAnyone.robundle +0 -0
  29. data/test/data/empty-manifest.json +1 -0
  30. data/test/data/example3-manifest.json +40 -0
  31. data/test/data/invalid-manifest.json +5 -0
  32. data/test/data/invalid-manifest.robundle +0 -0
  33. data/test/helpers/fake_manifest.rb +23 -0
  34. data/test/helpers/fake_provenance.rb +32 -0
  35. data/test/helpers/list_tests.rb +22 -0
  36. data/test/tc_add_annotation.rb +571 -0
  37. data/test/tc_agent.rb +63 -0
  38. data/test/tc_aggregate.rb +116 -0
  39. data/test/tc_annotation.rb +84 -0
  40. data/test/tc_create.rb +170 -0
  41. data/test/tc_manifest.rb +221 -0
  42. data/test/tc_provenance.rb +121 -0
  43. data/test/tc_read.rb +66 -0
  44. data/test/tc_remove.rb +140 -0
  45. data/test/tc_util.rb +64 -0
  46. data/test/ts_ro_bundle.rb +28 -0
  47. metadata +217 -0
@@ -0,0 +1,107 @@
1
+ #------------------------------------------------------------------------------
2
+ # Copyright (c) 2014 The University of Manchester, UK.
3
+ #
4
+ # BSD Licenced. See LICENCE.rdoc for details.
5
+ #
6
+ # Author: Robert Haines
7
+ #------------------------------------------------------------------------------
8
+
9
+ #
10
+ module ROBundle
11
+
12
+ # A class to represent an aggregated resource in a Research Object. It holds
13
+ # standard meta-data for either file or URI resources. An aggregate can only
14
+ # represent a file OR a URI resource, not both at once.
15
+ class Aggregate
16
+ include Provenance
17
+
18
+ # :call-seq:
19
+ # new(filename, mediatype = nil)
20
+ # new(URI)
21
+ #
22
+ # Create a new file or URI aggregate.
23
+ def initialize(object, second = nil)
24
+ @structure = {}
25
+
26
+ if object.instance_of?(Hash)
27
+ init_json(object)
28
+ else
29
+ init_file_or_uri(object)
30
+
31
+ if @structure[:file]
32
+ @structure[:mediatype] = second
33
+ end
34
+ end
35
+ end
36
+
37
+ # :call-seq:
38
+ # file
39
+ #
40
+ # The path of this aggregate. It should start with '/'.
41
+ def file
42
+ @structure[:file]
43
+ end
44
+
45
+ # :call-seq:
46
+ # file_entry
47
+ #
48
+ # The path of this aggregate in "rubyzip" format, i.e. no leading '/'.
49
+ def file_entry
50
+ Util.strip_leading_slash(file)
51
+ end
52
+
53
+ # :call-seq:
54
+ # uri
55
+ #
56
+ # The URI of this aggregate. It should be an absolute URI.
57
+ def uri
58
+ @structure[:uri]
59
+ end
60
+
61
+ # :call-seq:
62
+ # mediatype
63
+ #
64
+ # For a file aggregate, its
65
+ # {IANA media type}[http://www.iana.org/assignments/media-types].
66
+ def mediatype
67
+ @structure[:mediatype]
68
+ end
69
+
70
+ # :call-seq:
71
+ # to_json(options = nil) -> String
72
+ #
73
+ # Write this Aggregate out as a json string. Takes the same options as
74
+ # JSON#generate.
75
+ def to_json(*a)
76
+ Util.clean_json(@structure).to_json(*a)
77
+ end
78
+
79
+ private
80
+
81
+ def structure
82
+ @structure
83
+ end
84
+
85
+ def init_json(object)
86
+ init_file_or_uri(object[:file] || object[:uri])
87
+ @structure = init_provenance_defaults(object)
88
+
89
+ if @structure[:file]
90
+ @structure[:mediatype] = object[:mediatype]
91
+ end
92
+ end
93
+
94
+ def init_file_or_uri(object)
95
+ if object.is_a?(String) && !Util.is_absolute_uri?(object)
96
+ name = object.start_with?("/") ? object : "/#{object}"
97
+ @structure[:file] = name
98
+ elsif Util.is_absolute_uri?(object)
99
+ @structure[:uri] = object.to_s
100
+ else
101
+ raise InvalidAggregateError.new(object)
102
+ end
103
+ end
104
+
105
+ end
106
+
107
+ end
@@ -0,0 +1,89 @@
1
+ #------------------------------------------------------------------------------
2
+ # Copyright (c) 2014 The University of Manchester, UK.
3
+ #
4
+ # BSD Licenced. See LICENCE.rdoc for details.
5
+ #
6
+ # Author: Robert Haines
7
+ #------------------------------------------------------------------------------
8
+
9
+ #
10
+ module ROBundle
11
+
12
+ # A class to represent an Annotation in a Research Object.
13
+ class Annotation
14
+ include Provenance
15
+
16
+ # :call-seq:
17
+ # new(target, content = nil)
18
+ #
19
+ # Create a new Annotation with the specified "about" identifier. A new
20
+ # annotation ID is generated and set for the new annotation. The +content+
21
+ # parameter can be optionally used to set the file or URI that holds the
22
+ # body of the annotation.
23
+ #
24
+ # An annotation id is a UUID prefixed with "urn:uuid" as per
25
+ # {RFC4122}[http://www.ietf.org/rfc/rfc4122.txt].
26
+ def initialize(object, content = nil)
27
+ if object.instance_of?(Hash)
28
+ @structure = object
29
+ init_provenance_defaults(@structure)
30
+ else
31
+ @structure = {}
32
+ @structure[:about] = object
33
+ @structure[:annotation] = UUID.generate(:urn)
34
+ @structure[:content] = content
35
+ end
36
+ end
37
+
38
+ # :call-seq:
39
+ # target
40
+ #
41
+ # The identifier for the annotated resource. This is considered the target
42
+ # of the annotation, that is the resource the annotation content is
43
+ # "somewhat about".
44
+ def target
45
+ @structure[:about]
46
+ end
47
+
48
+ # :call-seq:
49
+ # content
50
+ #
51
+ # The identifier for a resource that contains the body of the annotation.
52
+ def content
53
+ @structure[:content]
54
+ end
55
+
56
+ # :call-seq:
57
+ # content = new_content
58
+ #
59
+ # Set the content of this annotation.
60
+ def content=(new_content)
61
+ @structure[:content] = new_content
62
+ end
63
+
64
+ # :call-seq:
65
+ # annotation_id -> String
66
+ #
67
+ # Return the annotation id of this Annotation.
68
+ def annotation_id
69
+ @structure[:annotation]
70
+ end
71
+
72
+ # :call-seq:
73
+ # to_json(options = nil) -> String
74
+ #
75
+ # Write this Annotation out as a json string. Takes the same options as
76
+ # JSON#generate.
77
+ def to_json(*a)
78
+ Util.clean_json(@structure).to_json(*a)
79
+ end
80
+
81
+ private
82
+
83
+ def structure
84
+ @structure
85
+ end
86
+
87
+ end
88
+
89
+ end
@@ -0,0 +1,120 @@
1
+ #------------------------------------------------------------------------------
2
+ # Copyright (c) 2014 The University of Manchester, UK.
3
+ #
4
+ # BSD Licenced. See LICENCE.rdoc for details.
5
+ #
6
+ # Author: Robert Haines
7
+ #------------------------------------------------------------------------------
8
+
9
+ #
10
+ module ROBundle
11
+
12
+ # The managed .ro directory entry of a Research Object.
13
+ #
14
+ # For internal use only.
15
+ class RODir < ZipContainer::ManagedDirectory
16
+
17
+ DIR_NAME = ".ro" # :nodoc:
18
+
19
+ # :call-seq:
20
+ # new(manifest)
21
+ #
22
+ # Create a new .ro managed directory entry with the specified manifest
23
+ # file object.
24
+ def initialize(manifest)
25
+ @manifest = manifest
26
+ @annotations_directory = AnnotationsDir.new
27
+
28
+ super(DIR_NAME, :required => true,
29
+ :entries => [@manifest, @annotations_directory])
30
+ end
31
+
32
+ # :stopdoc:
33
+ def cleanup_annotation_data
34
+ container.glob("#{@annotations_directory.full_name}/*",
35
+ :include_hidden => true) do |file|
36
+
37
+ found = false
38
+ @manifest.annotations.each do |ann|
39
+ content_name = normalize_content_name(ann.content)
40
+
41
+ if content_name == file.name
42
+ found = true
43
+ break
44
+ end
45
+ end
46
+
47
+ container.remove(file.name, true) unless found
48
+ end
49
+ end
50
+
51
+ def write_annotation_data(source, options)
52
+ uuid = UUID.generate
53
+
54
+ if options[:aggregate]
55
+ entry = uuid
56
+ content = "/#{uuid}"
57
+ else
58
+ mk_annotations_dir
59
+ content = "#{@annotations_directory.name}/#{uuid}"
60
+ entry = "#{full_name}/#{content}"
61
+ end
62
+
63
+ if ::File.exist?(source)
64
+ container.add(entry, source, options)
65
+ else
66
+ container.file.open(entry, "w") do |annotation|
67
+ annotation.write source.to_s
68
+ end
69
+
70
+ if options[:aggregate]
71
+ @manifest.add_aggregate(entry)
72
+ end
73
+ end
74
+
75
+ content
76
+ end
77
+ # :startdoc:
78
+
79
+ private
80
+
81
+ def mk_annotations_dir
82
+ dir_name = "#{full_name}/#{@annotations_directory.name}"
83
+ if container.find_entry(dir_name, :include_hidden => true).nil?
84
+ container.mkdir dir_name
85
+ end
86
+ end
87
+
88
+ # Convert an annotation content field into something compatible with the
89
+ # rubyzip file naming convention (i.e. full paths not prefixed with /).
90
+ def normalize_content_name(name)
91
+ return if name.nil?
92
+
93
+ if name.start_with?(@annotations_directory.name)
94
+ "#{full_name}/#{name}"
95
+ elsif name.start_with?("/#{@annotations_directory.full_name}")
96
+ name.slice(1, name.length)
97
+ else
98
+ nil
99
+ end
100
+ end
101
+
102
+ # The managed annotations directory within the .ro directory.
103
+ #
104
+ # For internal use only.
105
+ class AnnotationsDir < ZipContainer::ManagedDirectory
106
+
107
+ DIR_NAME = "annotations" # :nodoc:
108
+
109
+ # :call-seq:
110
+ # new
111
+ #
112
+ # Create a new annotations managed directory. The directory is hidden
113
+ # under normal circumstances.
114
+ def initialize
115
+ super(DIR_NAME, :hidden => true)
116
+ end
117
+
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,338 @@
1
+ #------------------------------------------------------------------------------
2
+ # Copyright (c) 2014 The University of Manchester, UK.
3
+ #
4
+ # BSD Licenced. See LICENCE.rdoc for details.
5
+ #
6
+ # Author: Robert Haines
7
+ #------------------------------------------------------------------------------
8
+
9
+ #
10
+ module ROBundle
11
+
12
+ # The manifest.json managed file entry for a Research Object.
13
+ class Manifest < ZipContainer::ManagedFile
14
+ include Provenance
15
+
16
+ FILE_NAME = "manifest.json" # :nodoc:
17
+ DEFAULT_CONTEXT = "https://w3id.org/bundle/context" # :nodoc:
18
+ DEFAULT_ID = "/" # :nodoc:
19
+
20
+ # :call-seq:
21
+ # new
22
+ #
23
+ # Create a new managed file entry to represent the manifest.json file.
24
+ def initialize
25
+ super(FILE_NAME, :required => true)
26
+
27
+ @edited = false
28
+ end
29
+
30
+ # :call-seq:
31
+ # context -> List of context URIs
32
+ #
33
+ # Return the list of @context URIs for this Research Object manifest.
34
+ def context
35
+ structure[:@context].dup
36
+ end
37
+
38
+ # :call-seq:
39
+ # add_context
40
+ #
41
+ # Add a URI to the front of the @context list.
42
+ def add_context(uri)
43
+ @edited = true
44
+ structure[:@context].insert(0, uri.to_s)
45
+ end
46
+
47
+ # :call-seq:
48
+ # id -> String
49
+ #
50
+ # An RO identifier (usually '/') indicating the relative top-level folder
51
+ # as the identifier.
52
+ def id
53
+ structure[:id]
54
+ end
55
+
56
+ # :call-seq:
57
+ # id = new_id
58
+ #
59
+ # Set the id of this Manifest.
60
+ def id=(new_id)
61
+ @edited = true
62
+ structure[:id] = new_id
63
+ end
64
+
65
+ # :call-seq:
66
+ # history -> List of history entry names
67
+ #
68
+ # Return a list of filenames that hold provenance information for this
69
+ # Research Object.
70
+ def history
71
+ structure[:history].dup
72
+ end
73
+
74
+ # :call-seq:
75
+ # add_history(entry)
76
+ #
77
+ # Add the given entry to the history list in this manifest.
78
+ # <tt>Errno:ENOENT</tt> is raised if the entry does not exist.
79
+ def add_history(entry)
80
+ raise Errno::ENOENT if container.find_entry(entry).nil?
81
+
82
+ # Mangle the filename according to the RO Bundle specification.
83
+ name = entry_name(entry)
84
+ dir = "#{@parent.full_name}/"
85
+ name = name.start_with?(dir) ? name.sub(dir, "") : "/#{name}"
86
+
87
+ @edited = true
88
+ structure[:history] << name
89
+ end
90
+
91
+ # :call-seq:
92
+ # aggregates -> List of aggregated resources.
93
+ #
94
+ # Return a list of all the aggregated resources in this Research Object.
95
+ def aggregates
96
+ structure[:aggregates].dup
97
+ end
98
+
99
+ # :call-seq:
100
+ # add_aggregate(entry) -> Aggregate
101
+ # add_aggregate(uri) -> Aggregate
102
+ #
103
+ # Add the given entry or URI to the list of aggregates in this manifest.
104
+ # <tt>Errno:ENOENT</tt> is raised if the entry does not exist.
105
+ #
106
+ # The Aggregate object added to the Research Object is returned.
107
+ def add_aggregate(entry)
108
+ unless entry.instance_of?(Aggregate)
109
+ unless Util.is_absolute_uri?(entry)
110
+ raise Errno::ENOENT if container.find_entry(entry).nil?
111
+ end
112
+
113
+ entry = Aggregate.new(entry)
114
+ end
115
+
116
+ @edited = true
117
+ structure[:aggregates] << entry
118
+ entry
119
+ end
120
+
121
+ # :call-seq:
122
+ # remove_aggregate(filename)
123
+ # remove_aggregate(uri)
124
+ # remove_aggregate(Aggregate)
125
+ #
126
+ # Remove (unregister) an aggregate from this Research Object. If a
127
+ # filename is supplied then the file is no longer aggregated, but it is
128
+ # not deleted from the bundle by this method.
129
+ #
130
+ # Any annotations with the removed aggregate as their target are also
131
+ # removed from the RO.
132
+ def remove_aggregate(object)
133
+ removed = nil
134
+
135
+ if object.is_a?(Aggregate)
136
+ removed = structure[:aggregates].delete(object)
137
+
138
+ unless removed.nil?
139
+ removed = removed.file.nil? ? removed.uri : removed.file
140
+ end
141
+ else
142
+ removed = remove_aggregate_by_file_or_uri(object)
143
+ end
144
+
145
+ unless removed.nil?
146
+ remove_annotation(removed)
147
+ @edited = true
148
+ end
149
+ end
150
+
151
+ # :call-seq:
152
+ # add_annotation(annotation) -> Annotation
153
+ # add_annotation(target, content = nil) -> Annotation
154
+ #
155
+ # Add an annotation to this Research Object. An annotation can either be
156
+ # an already created annotation object, or a pair of values to build a new
157
+ # annotation object explicitly.
158
+ #
159
+ # <tt>Errno:ENOENT</tt> is raised if the target of the annotation is not
160
+ # an annotatable resource in this RO.
161
+ #
162
+ # The Annotation object added to the Research Object is returned.
163
+ def add_annotation(object, content = nil)
164
+ if object.instance_of?(Annotation)
165
+ # If the supplied Annotation object is already registered then it is
166
+ # the annotation itself we are annotating!
167
+ if container.annotation?(object)
168
+ object = Annotation.new(object.annotation_id, content)
169
+ end
170
+ else
171
+ object = Annotation.new(object, content)
172
+ end
173
+
174
+ target = object.target
175
+ unless container.annotatable?(target)
176
+ raise Errno::ENOENT,
177
+ "'#{target}' is not a member of this Research Object or a URI."
178
+ end
179
+
180
+ @edited = true
181
+ structure[:annotations] << object
182
+ object
183
+ end
184
+
185
+ # :call-seq:
186
+ # remove_annotation(Annotation)
187
+ # remove_annotation(target)
188
+ # remove_annotation(id)
189
+ #
190
+ # Remove (unregister) annotations from this Research Object and return
191
+ # them. Return +nil+ if the annotation does not exist.
192
+ #
193
+ # Any annotation content that is stored in the .ro/annotations directory
194
+ # is automatically cleaned up when the RO is closed.
195
+ def remove_annotation(object)
196
+ if object.is_a?(Annotation)
197
+ removed = [structure[:annotations].delete(object)].compact
198
+ else
199
+ removed = remove_annotation_by_field(object)
200
+ end
201
+
202
+ removed.each do |ann|
203
+ id = ann.annotation_id
204
+ remove_annotation(id) unless id.nil?
205
+ end
206
+
207
+ @edited = true unless removed.empty?
208
+ end
209
+
210
+ # :call-seq:
211
+ # annotations
212
+ #
213
+ # Return a list of all the annotations in this Research Object.
214
+ def annotations
215
+ structure[:annotations].dup
216
+ end
217
+
218
+ # :call-seq:
219
+ # edited? -> true or false
220
+ #
221
+ # Has this manifest been altered in any way?
222
+ def edited?
223
+ @edited
224
+ end
225
+
226
+ # :call-seq:
227
+ # to_json(options = nil) -> String
228
+ #
229
+ # Write this Manifest out as a json string. Takes the same options as
230
+ # JSON#generate.
231
+ def to_json(*a)
232
+ Util.clean_json(structure).to_json(*a)
233
+ end
234
+
235
+ protected
236
+
237
+ # :call-seq:
238
+ # validate -> true or false
239
+ #
240
+ # Validate the correctness of the manifest file contents.
241
+ def validate
242
+ begin
243
+ structure
244
+ rescue JSON::ParserError, ROError
245
+ return false
246
+ end
247
+
248
+ true
249
+ end
250
+
251
+ private
252
+
253
+ # The internal structure of this class cannot be setup at construction
254
+ # time in the initializer as there is no route to its data on disk at that
255
+ # point. Once loaded, parts of the structure are converted to local
256
+ # objects where appropriate.
257
+ def structure
258
+ return @structure if @structure
259
+
260
+ begin
261
+ struct ||= JSON.parse(contents, :symbolize_names => true)
262
+ rescue Errno::ENOENT
263
+ struct = {}
264
+ end
265
+
266
+ @structure = init_defaults(struct)
267
+ end
268
+
269
+ def init_defaults(struct)
270
+ init_default_context(struct)
271
+ init_default_id(struct)
272
+ init_provenance_defaults(struct)
273
+ struct[:history] = [*struct.fetch(:history, [])]
274
+ struct[:aggregates] = [*struct.fetch(:aggregates, [])].map do |agg|
275
+ Aggregate.new(agg)
276
+ end
277
+ struct[:annotations] = [*struct.fetch(:annotations, [])].map do |ann|
278
+ Annotation.new(ann)
279
+ end
280
+
281
+ struct
282
+ end
283
+
284
+ def init_default_context(struct)
285
+ context = struct[:@context]
286
+ if context.nil?
287
+ @edited = true
288
+ struct[:@context] = [ DEFAULT_CONTEXT ]
289
+ else
290
+ struct[:@context] = [*context]
291
+ end
292
+
293
+ struct
294
+ end
295
+
296
+ def init_default_id(struct)
297
+ id = struct[:id]
298
+ if id.nil?
299
+ @edited = true
300
+ struct[:id] = DEFAULT_ID
301
+ end
302
+
303
+ struct
304
+ end
305
+
306
+ def remove_aggregate_by_file_or_uri(object)
307
+ aggregates.each do |agg|
308
+ if Util.is_absolute_uri?(object)
309
+ return structure[:aggregates].delete(agg).uri if object == agg.uri
310
+ else
311
+ if object == agg.file || object == agg.file_entry
312
+ return structure[:aggregates].delete(agg).file
313
+ end
314
+ end
315
+ end
316
+
317
+ # Return nil if nothing removed.
318
+ nil
319
+ end
320
+
321
+ def remove_annotation_by_field(object)
322
+ removed = []
323
+
324
+ annotations.each do |ann|
325
+ if ann.annotation_id == object ||
326
+ ann.target == object ||
327
+ ann.content == object
328
+
329
+ removed << structure[:annotations].delete(ann)
330
+ end
331
+ end
332
+
333
+ removed
334
+ end
335
+
336
+ end
337
+
338
+ end