ro-bundle 0.1.0

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